Compare commits
1 Commits
09cc49d920
...
with-test
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e70de3a5a |
@@ -40,12 +40,12 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
{
|
||||
source: '/applicant/pipeline',
|
||||
destination: '/applicant/competition',
|
||||
destination: '/applicant/competitions',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/applicant/pipeline/:path*',
|
||||
destination: '/applicant/competition',
|
||||
destination: '/applicant/competitions',
|
||||
permanent: true,
|
||||
},
|
||||
]
|
||||
|
||||
334
package-lock.json
generated
334
package-lock.json
generated
@@ -11,12 +11,14 @@
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@auth/prisma-adapter": "^2.7.4",
|
||||
"@blocknote/core": "^0.46.2",
|
||||
"@blocknote/mantine": "^0.46.2",
|
||||
"@blocknote/react": "^0.46.2",
|
||||
"@blocknote/shadcn": "^0.46.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@mantine/core": "^8.3.13",
|
||||
"@mantine/hooks": "^8.3.13",
|
||||
"@notionhq/client": "^2.3.0",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
@@ -244,6 +246,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@blocknote/mantine": {
|
||||
"version": "0.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@blocknote/mantine/-/mantine-0.46.2.tgz",
|
||||
"integrity": "sha512-2/A82VIby8NNuQbJrXZURnGsksVMWiGWtUOfhvaawCTiB2thYDOV1XONFF1G4xZ2UreodOKLUTwhLm3u25lGrw==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"@blocknote/core": "0.46.2",
|
||||
"@blocknote/react": "0.46.2",
|
||||
"react-icons": "^5.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "^8.3.11",
|
||||
"@mantine/hooks": "^8.3.11",
|
||||
"@mantine/utils": "^6.0.22",
|
||||
"react": "^18.0 || ^19.0 || >= 19.0.0-rc",
|
||||
"react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@blocknote/react": {
|
||||
"version": "0.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.46.2.tgz",
|
||||
@@ -276,55 +296,6 @@
|
||||
"integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@blocknote/shadcn": {
|
||||
"version": "0.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@blocknote/shadcn/-/shadcn-0.46.2.tgz",
|
||||
"integrity": "sha512-rCmML5M814D/dcLrm5/6REQeEJKPNyygls4n751F0jBLpk8IuWsQYvGqnU6iPW2Cz+9IchA2ivg4Vltna83J1Q==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"@blocknote/core": "0.46.2",
|
||||
"@blocknote/react": "0.46.2",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react-hook-form": "^7.65.0",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0 || ^19.0 || >= 19.0.0-rc",
|
||||
"react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc",
|
||||
"tailwindcss": "^4.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@blocknote/shadcn/node_modules/lucide-react": {
|
||||
"version": "0.525.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz",
|
||||
"integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@blocknote/shadcn/node_modules/tailwind-merge": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
|
||||
"integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
}
|
||||
},
|
||||
"node_modules/@date-fns/tz": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
|
||||
@@ -1681,6 +1652,34 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/core": {
|
||||
"version": "8.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.13.tgz",
|
||||
"integrity": "sha512-ZgW4vqN4meaPyIMxzAufBvsgmJRfYZdTpsrAOcS8pWy7m9e8i685E7XsAxnwJCOIHudpvpvt+7Bx7VaIjEsYEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.16",
|
||||
"clsx": "^2.1.1",
|
||||
"react-number-format": "^5.4.4",
|
||||
"react-remove-scroll": "^2.7.1",
|
||||
"react-textarea-autosize": "8.5.9",
|
||||
"type-fest": "^4.41.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/hooks": "8.3.13",
|
||||
"react": "^18.x || ^19.x",
|
||||
"react-dom": "^18.x || ^19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/hooks": {
|
||||
"version": "8.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.13.tgz",
|
||||
"integrity": "sha512-7YMbMW/tR9E8m/9DbBW01+3RNApm2mE/JbRKXf9s9+KxgbjQvq0FYGWV8Y4+Sjz48AO4vtWk2qBriUTgBMKAyg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.x || ^19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
||||
@@ -6011,42 +6010,6 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.24",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
|
||||
"integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.28.1",
|
||||
"caniuse-lite": "^1.0.30001766",
|
||||
"fraction.js": "^5.3.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"autoprefixer": "bin/autoprefixer"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
@@ -6128,18 +6091,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
@@ -6194,39 +6145,6 @@
|
||||
"integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
"electron-to-chromium": "^1.5.263",
|
||||
"node-releases": "^2.0.27",
|
||||
"update-browserslist-db": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
|
||||
@@ -7102,12 +7020,6 @@
|
||||
"fast-check": "^3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.302",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
|
||||
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-mart": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
|
||||
@@ -7378,15 +7290,6 @@
|
||||
"@esbuild/win32-x64": "0.27.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
@@ -8112,19 +8015,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "11.18.2",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
|
||||
@@ -11210,12 +11100,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.13",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||
@@ -11744,12 +11628,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.24.3",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
||||
@@ -12372,6 +12250,16 @@
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-number-format": {
|
||||
"version": "5.4.4",
|
||||
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz",
|
||||
"integrity": "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-phone-number-input": {
|
||||
"version": "3.4.14",
|
||||
"resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.14.tgz",
|
||||
@@ -12473,6 +12361,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-textarea-autosize": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
|
||||
"integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"use-composed-ref": "^1.3.0",
|
||||
"use-latest": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
@@ -13824,6 +13729,18 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-array-buffer": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
||||
@@ -14103,36 +14020,6 @@
|
||||
"@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escalade": "^3.2.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"update-browserslist-db": "cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
@@ -14164,6 +14051,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-composed-ref": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz",
|
||||
"integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-debounce": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.0.tgz",
|
||||
@@ -14176,6 +14077,37 @@
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/use-isomorphic-layout-effect": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-latest": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz",
|
||||
"integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-isomorphic-layout-effect": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sidecar": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||
|
||||
@@ -24,12 +24,14 @@
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@auth/prisma-adapter": "^2.7.4",
|
||||
"@blocknote/core": "^0.46.2",
|
||||
"@blocknote/mantine": "^0.46.2",
|
||||
"@blocknote/react": "^0.46.2",
|
||||
"@blocknote/shadcn": "^0.46.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@mantine/core": "^8.3.13",
|
||||
"@mantine/hooks": "^8.3.13",
|
||||
"@notionhq/client": "^2.3.0",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Add isTest field to User, Program, Project, Competition for test environment isolation
|
||||
ALTER TABLE "User" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Program" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Project" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Competition" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Index for efficient test data filtering
|
||||
CREATE INDEX "Competition_isTest_idx" ON "Competition"("isTest");
|
||||
@@ -1,16 +0,0 @@
|
||||
-- Learning Hub Overhaul: Remove ResourceType/CohortLevel enums, add accessJson + coverImageKey
|
||||
|
||||
-- Drop columns that reference the enums
|
||||
ALTER TABLE "LearningResource" DROP COLUMN "resourceType";
|
||||
ALTER TABLE "LearningResource" DROP COLUMN "cohortLevel";
|
||||
|
||||
-- Drop the cohortLevel index
|
||||
DROP INDEX IF EXISTS "LearningResource_cohortLevel_idx";
|
||||
|
||||
-- Add new columns
|
||||
ALTER TABLE "LearningResource" ADD COLUMN "accessJson" JSONB;
|
||||
ALTER TABLE "LearningResource" ADD COLUMN "coverImageKey" TEXT;
|
||||
|
||||
-- Drop the enum types
|
||||
DROP TYPE IF EXISTS "ResourceType";
|
||||
DROP TYPE IF EXISTS "CohortLevel";
|
||||
@@ -115,6 +115,19 @@ enum NotificationChannel {
|
||||
NONE
|
||||
}
|
||||
|
||||
enum ResourceType {
|
||||
PDF
|
||||
VIDEO
|
||||
DOCUMENT
|
||||
LINK
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum CohortLevel {
|
||||
ALL
|
||||
SEMIFINALIST
|
||||
FINALIST
|
||||
}
|
||||
|
||||
enum PartnerVisibility {
|
||||
ADMIN_ONLY
|
||||
@@ -337,6 +350,9 @@ model User {
|
||||
preferredWorkload Int?
|
||||
availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string }
|
||||
|
||||
// Test environment isolation
|
||||
isTest Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
lastLoginAt DateTime?
|
||||
@@ -481,6 +497,9 @@ model Program {
|
||||
description String?
|
||||
settingsJson Json? @db.JsonB
|
||||
|
||||
// Test environment isolation
|
||||
isTest Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -605,6 +624,9 @@ model Project {
|
||||
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
|
||||
externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc.
|
||||
|
||||
// Test environment isolation
|
||||
isTest Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -997,12 +1019,13 @@ model NotificationEmailSetting {
|
||||
// =============================================================================
|
||||
|
||||
model LearningResource {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
programId String? // null = global resource
|
||||
title String
|
||||
description String? @db.Text
|
||||
contentJson Json? @db.JsonB // BlockNote document structure
|
||||
accessJson Json? @db.JsonB // Fine-grained access rules
|
||||
description String? @db.Text
|
||||
contentJson Json? @db.JsonB // BlockNote document structure
|
||||
resourceType ResourceType
|
||||
cohortLevel CohortLevel @default(ALL)
|
||||
|
||||
// File storage (for uploaded resources)
|
||||
fileName String?
|
||||
@@ -1011,9 +1034,6 @@ model LearningResource {
|
||||
bucket String?
|
||||
objectKey String?
|
||||
|
||||
// Cover image (stored in MinIO)
|
||||
coverImageKey String?
|
||||
|
||||
// External link
|
||||
externalUrl String?
|
||||
|
||||
@@ -1030,6 +1050,7 @@ model LearningResource {
|
||||
accessLogs ResourceAccess[]
|
||||
|
||||
@@index([programId])
|
||||
@@index([cohortLevel])
|
||||
@@index([isPublished])
|
||||
@@index([sortOrder])
|
||||
}
|
||||
@@ -2078,6 +2099,9 @@ model Competition {
|
||||
notifyOnDeadlineApproach Boolean @default(true)
|
||||
deadlineReminderDays Int[] @default([7, 3, 1])
|
||||
|
||||
// Test environment isolation
|
||||
isTest Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -2092,6 +2116,7 @@ model Competition {
|
||||
|
||||
@@index([programId])
|
||||
@@index([status])
|
||||
@@index([isTest])
|
||||
}
|
||||
|
||||
model Round {
|
||||
|
||||
@@ -68,8 +68,19 @@ export default function AssignmentsDashboardPage() {
|
||||
|
||||
if (!competition) {
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
||||
<p>Competition not found</p>
|
||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<p className="font-medium">Competition not found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The requested competition does not exist or you don't have access.
|
||||
</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,16 +13,34 @@ import type { Route } from 'next';
|
||||
export default function AwardsPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
|
||||
const params = use(paramsPromise);
|
||||
const router = useRouter();
|
||||
const { data: competition } = trpc.competition.getById.useQuery({
|
||||
const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery({
|
||||
id: params.competitionId
|
||||
});
|
||||
|
||||
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({
|
||||
const { data: awards, isLoading, isError: isAwardsError } = trpc.specialAward.list.useQuery({
|
||||
programId: competition?.programId
|
||||
}, {
|
||||
enabled: !!competition?.programId
|
||||
});
|
||||
|
||||
if (isCompError || isAwardsError) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Error Loading Awards</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Could not load competition or awards data. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -43,13 +43,13 @@ export default function DeliberationListPage({
|
||||
participantUserIds: [] as string[]
|
||||
});
|
||||
|
||||
const { data: sessions = [], isLoading } = trpc.deliberation.listSessions.useQuery(
|
||||
const { data: sessions = [], isLoading, isError: isSessionsError } = trpc.deliberation.listSessions.useQuery(
|
||||
{ competitionId: params.competitionId },
|
||||
{ enabled: !!params.competitionId }
|
||||
);
|
||||
|
||||
// Get rounds for this competition
|
||||
const { data: competition } = trpc.competition.getById.useQuery(
|
||||
const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery(
|
||||
{ id: params.competitionId },
|
||||
{ enabled: !!params.competitionId }
|
||||
);
|
||||
@@ -121,6 +121,24 @@ export default function DeliberationListPage({
|
||||
return <Badge variant={variants[status] || 'outline'}>{labels[status] || status}</Badge>;
|
||||
};
|
||||
|
||||
if (isCompError || isSessionsError) {
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Go back">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Error Loading Deliberations</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Could not load competition or deliberation data. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
Loader2,
|
||||
Plus,
|
||||
CalendarDays,
|
||||
Radio,
|
||||
} from 'lucide-react'
|
||||
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
|
||||
|
||||
@@ -435,6 +436,19 @@ export default function CompetitionDetailPage() {
|
||||
<span className="truncate">{round.juryGroup.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live Control link for LIVE_FINAL rounds */}
|
||||
{round.roundType === 'LIVE_FINAL' && (
|
||||
<Link
|
||||
href={`/admin/competitions/${competitionId}/live/${round.id}` as Route}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button size="sm" variant="outline" className="w-full text-xs gap-1.5">
|
||||
<Radio className="h-3.5 w-3.5" />
|
||||
Live Control
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
@@ -8,11 +8,15 @@ import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -20,14 +24,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -39,62 +35,46 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Loader2,
|
||||
Settings,
|
||||
Eye,
|
||||
FileText,
|
||||
Video,
|
||||
Link as LinkIcon,
|
||||
File,
|
||||
Trash2,
|
||||
Eye,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Dynamically import editors to avoid SSR issues
|
||||
// Dynamically import BlockEditor to avoid SSR issues
|
||||
const BlockEditor = dynamic(
|
||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="mx-auto max-w-3xl min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
const ResourceRenderer = dynamic(
|
||||
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'JURY_MEMBER', label: 'Jury Members' },
|
||||
{ value: 'MENTOR', label: 'Mentors' },
|
||||
{ value: 'OBSERVER', label: 'Observers' },
|
||||
{ value: 'APPLICANT', label: 'Applicants' },
|
||||
{ value: 'AWARD_MASTER', label: 'Award Masters' },
|
||||
const resourceTypeOptions = [
|
||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
||||
{ value: 'OTHER', label: 'Other', icon: File },
|
||||
]
|
||||
|
||||
type AccessRule =
|
||||
| { type: 'everyone' }
|
||||
| { type: 'roles'; roles: string[] }
|
||||
| { type: 'jury_group'; juryGroupIds: string[] }
|
||||
| { type: 'round'; roundIds: string[] }
|
||||
|
||||
function parseAccessJson(accessJson: unknown): { mode: 'everyone' | 'roles'; roles: string[] } {
|
||||
if (!accessJson || !Array.isArray(accessJson) || accessJson.length === 0) {
|
||||
return { mode: 'everyone', roles: [] }
|
||||
}
|
||||
const firstRule = accessJson[0] as AccessRule
|
||||
if (firstRule.type === 'roles' && 'roles' in firstRule) {
|
||||
return { mode: 'roles', roles: firstRule.roles }
|
||||
}
|
||||
return { mode: 'everyone', roles: [] }
|
||||
}
|
||||
const cohortOptions = [
|
||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
||||
]
|
||||
|
||||
export default function EditLearningResourcePage() {
|
||||
const params = useParams()
|
||||
@@ -109,14 +89,11 @@ export default function EditLearningResourcePage() {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [contentJson, setContentJson] = useState<string>('')
|
||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
||||
const [externalUrl, setExternalUrl] = useState('')
|
||||
const [isPublished, setIsPublished] = useState(false)
|
||||
const [programId, setProgramId] = useState<string | null>(null)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
|
||||
// Access rules state
|
||||
const [accessMode, setAccessMode] = useState<'everyone' | 'roles'>('everyone')
|
||||
const [selectedRoles, setSelectedRoles] = useState<string[]>([])
|
||||
|
||||
// API
|
||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||
@@ -138,13 +115,11 @@ export default function EditLearningResourcePage() {
|
||||
setTitle(resource.title)
|
||||
setDescription(resource.description || '')
|
||||
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
|
||||
setResourceType(resource.resourceType)
|
||||
setCohortLevel(resource.cohortLevel)
|
||||
setExternalUrl(resource.externalUrl || '')
|
||||
setIsPublished(resource.isPublished)
|
||||
setProgramId(resource.programId)
|
||||
|
||||
const { mode, roles } = parseAccessJson(resource.accessJson)
|
||||
setAccessMode(mode)
|
||||
setSelectedRoles(roles)
|
||||
}
|
||||
}, [resource])
|
||||
|
||||
@@ -159,89 +134,75 @@ export default function EditLearningResourcePage() {
|
||||
await fetch(url, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: { 'Content-Type': file.type },
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
})
|
||||
|
||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||
} catch {
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload file')
|
||||
throw new Error('Upload failed')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const buildAccessJson = (): AccessRule[] | null => {
|
||||
if (accessMode === 'everyone') return null
|
||||
if (accessMode === 'roles' && selectedRoles.length > 0) {
|
||||
return [{ type: 'roles', roles: selectedRoles }]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
toast.error('Please enter a title')
|
||||
return
|
||||
}
|
||||
|
||||
if (resourceType === 'LINK' && !externalUrl) {
|
||||
toast.error('Please enter an external URL')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateResource.mutateAsync({
|
||||
id: resourceId,
|
||||
programId,
|
||||
title,
|
||||
description: description || null,
|
||||
description: description || undefined,
|
||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||
accessJson: buildAccessJson(),
|
||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
||||
externalUrl: externalUrl || null,
|
||||
isPublished,
|
||||
})
|
||||
|
||||
toast.success('Resource updated')
|
||||
toast.success('Resource updated successfully')
|
||||
router.push('/admin/learning')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
|
||||
}
|
||||
}, [title, description, contentJson, externalUrl, isPublished, programId, accessMode, selectedRoles, resourceId])
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteResource.mutateAsync({ id: resourceId })
|
||||
toast.success('Resource deleted')
|
||||
toast.success('Resource deleted successfully')
|
||||
router.push('/admin/learning')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+S save
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleSubmit])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
</div>
|
||||
<div className="flex-1 px-4 py-8">
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<Skeleton className="h-10 w-2/3" />
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<Skeleton className="h-px w-full" />
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -249,7 +210,7 @@ export default function EditLearningResourcePage() {
|
||||
|
||||
if (error || !resource) {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="space-y-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Resource not found</AlertTitle>
|
||||
@@ -268,250 +229,253 @@ export default function EditLearningResourcePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* Sticky toolbar */}
|
||||
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={previewing ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPreviewing(!previewing)}
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{previewing ? 'Edit' : 'Preview'}
|
||||
</Button>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Edit Resource</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Update this learning resource
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{resource.title}"? This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteResource.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Resource Settings</SheetTitle>
|
||||
<SheetDescription>
|
||||
Configure publishing, access, and metadata
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resource Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about this resource
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Ocean Conservation Best Practices"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
{/* Publish toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Published</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Make visible to users
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPublished}
|
||||
onCheckedChange={setIsPublished}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Short Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this resource"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Program */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Program</Label>
|
||||
<Select
|
||||
value={programId || 'global'}
|
||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select program" />
|
||||
<Label htmlFor="type">Resource Type</Label>
|
||||
<Select value={resourceType} onValueChange={setResourceType}>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||
{programs?.map((program) => (
|
||||
<SelectItem key={program.id} value={program.id}>
|
||||
{program.year} Edition
|
||||
{resourceTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<option.icon className="h-4 w-4" />
|
||||
{option.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Access Rules */}
|
||||
<div className="space-y-3">
|
||||
<Label>Access Rules</Label>
|
||||
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
|
||||
<SelectTrigger>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cohort">Access Level</Label>
|
||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
||||
<SelectTrigger id="cohort">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="everyone">Everyone</SelectItem>
|
||||
<SelectItem value="roles">By Role</SelectItem>
|
||||
{cohortOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{accessMode === 'roles' && (
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<label key={role.value} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={selectedRoles.includes(role.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedRoles(
|
||||
checked
|
||||
? [...selectedRoles, role.value]
|
||||
: selectedRoles.filter((r) => r !== role.value)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{role.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* External URL */}
|
||||
{resourceType === 'LINK' && (
|
||||
<div className="space-y-2">
|
||||
<Label>External URL</Label>
|
||||
<Label htmlFor="url">External URL *</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="url"
|
||||
value={externalUrl}
|
||||
onChange={(e) => setExternalUrl(e.target.value)}
|
||||
placeholder="https://example.com/resource"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional link to an external resource
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Content Editor */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Content</CardTitle>
|
||||
<CardDescription>
|
||||
Rich text content with images and videos. Type / for commands.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BlockEditor
|
||||
key={resourceId}
|
||||
initialContent={contentJson || undefined}
|
||||
onChange={setContentJson}
|
||||
onUploadFile={handleUploadFile}
|
||||
className="min-h-[300px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Publish Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Publish Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="published">Published</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Make this resource visible to jury members
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Statistics */}
|
||||
{stats && (
|
||||
<div className="space-y-2">
|
||||
<Label>Statistics</Label>
|
||||
<div className="grid grid-cols-2 gap-4 rounded-lg border p-3">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
||||
<p className="text-xs text-muted-foreground">Total views</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
||||
<p className="text-xs text-muted-foreground">Unique users</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-destructive">Danger Zone</Label>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Resource
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{resource.title}"?
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteResource.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
<Switch
|
||||
id="published"
|
||||
checked={isPublished}
|
||||
onCheckedChange={setIsPublished}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={updateResource.isPending || !title.trim()}
|
||||
>
|
||||
{updateResource.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="program">Program</Label>
|
||||
<Select
|
||||
value={programId || 'global'}
|
||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||
>
|
||||
<SelectTrigger id="program">
|
||||
<SelectValue placeholder="Select program" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||
{programs?.map((program) => (
|
||||
<SelectItem key={program.id} value={program.id}>
|
||||
{program.year} Edition
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Statistics */}
|
||||
{stats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Eye className="h-5 w-5" />
|
||||
Statistics
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
||||
<p className="text-sm text-muted-foreground">Total views</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
||||
<p className="text-sm text-muted-foreground">Unique users</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={updateResource.isPending || !title.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{updateResource.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button variant="outline" asChild className="w-full">
|
||||
<Link href="/admin/learning">Cancel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 px-4 py-8">
|
||||
{previewing ? (
|
||||
<ResourceRenderer
|
||||
title={title || 'Untitled'}
|
||||
description={description || null}
|
||||
contentJson={contentJson ? JSON.parse(contentJson) : null}
|
||||
/>
|
||||
) : (
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
{/* Inline title */}
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Untitled"
|
||||
className="w-full border-0 bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 focus:outline-none sm:text-4xl"
|
||||
/>
|
||||
|
||||
{/* Inline description */}
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Add a description..."
|
||||
className="w-full border-0 bg-transparent text-lg text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none"
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
<hr className="border-border" />
|
||||
|
||||
{/* Block editor */}
|
||||
<BlockEditor
|
||||
key={resourceId}
|
||||
initialContent={contentJson || undefined}
|
||||
onChange={setContentJson}
|
||||
onUploadFile={handleUploadFile}
|
||||
className="min-h-[400px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
@@ -8,9 +8,15 @@ import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -18,57 +24,33 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Loader2,
|
||||
Settings,
|
||||
Eye,
|
||||
} from 'lucide-react'
|
||||
import { ArrowLeft, Save, Loader2, FileText, Video, Link as LinkIcon, File } from 'lucide-react'
|
||||
|
||||
// Dynamically import editors to avoid SSR issues
|
||||
// Dynamically import BlockEditor to avoid SSR issues
|
||||
const BlockEditor = dynamic(
|
||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="mx-auto max-w-3xl min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
const ResourceRenderer = dynamic(
|
||||
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'JURY_MEMBER', label: 'Jury Members' },
|
||||
{ value: 'MENTOR', label: 'Mentors' },
|
||||
{ value: 'OBSERVER', label: 'Observers' },
|
||||
{ value: 'APPLICANT', label: 'Applicants' },
|
||||
{ value: 'AWARD_MASTER', label: 'Award Masters' },
|
||||
const resourceTypeOptions = [
|
||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
||||
{ value: 'OTHER', label: 'Other', icon: File },
|
||||
]
|
||||
|
||||
type AccessRule =
|
||||
| { type: 'everyone' }
|
||||
| { type: 'roles'; roles: string[] }
|
||||
| { type: 'jury_group'; juryGroupIds: string[] }
|
||||
| { type: 'round'; roundIds: string[] }
|
||||
const cohortOptions = [
|
||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
||||
]
|
||||
|
||||
export default function NewLearningResourcePage() {
|
||||
const router = useRouter()
|
||||
@@ -77,17 +59,14 @@ export default function NewLearningResourcePage() {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [contentJson, setContentJson] = useState<string>('')
|
||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
||||
const [externalUrl, setExternalUrl] = useState('')
|
||||
const [isPublished, setIsPublished] = useState(false)
|
||||
const [programId, setProgramId] = useState<string | null>(null)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
|
||||
// Access rules state
|
||||
const [accessMode, setAccessMode] = useState<'everyone' | 'roles'>('everyone')
|
||||
const [selectedRoles, setSelectedRoles] = useState<string[]>([])
|
||||
|
||||
// API
|
||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||
const [programId, setProgramId] = useState<string | null>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const createResource = trpc.learningResource.create.useMutation({
|
||||
@@ -103,41 +82,43 @@ export default function NewLearningResourcePage() {
|
||||
mimeType: file.type,
|
||||
})
|
||||
|
||||
// Upload to MinIO
|
||||
await fetch(url, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: { 'Content-Type': file.type },
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
})
|
||||
|
||||
// Return the MinIO URL
|
||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||
} catch {
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload file')
|
||||
throw new Error('Upload failed')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const buildAccessJson = (): AccessRule[] | null => {
|
||||
if (accessMode === 'everyone') return null
|
||||
if (accessMode === 'roles' && selectedRoles.length > 0) {
|
||||
return [{ type: 'roles', roles: selectedRoles }]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
toast.error('Please enter a title')
|
||||
return
|
||||
}
|
||||
|
||||
if (resourceType === 'LINK' && !externalUrl) {
|
||||
toast.error('Please enter an external URL')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createResource.mutateAsync({
|
||||
programId,
|
||||
title,
|
||||
description: description || undefined,
|
||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||
accessJson: buildAccessJson(),
|
||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
||||
externalUrl: externalUrl || undefined,
|
||||
isPublished,
|
||||
})
|
||||
@@ -147,205 +128,200 @@ export default function NewLearningResourcePage() {
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
|
||||
}
|
||||
}, [title, description, contentJson, externalUrl, isPublished, programId, accessMode, selectedRoles])
|
||||
|
||||
// Ctrl+S save
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleSubmit])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* Sticky toolbar */}
|
||||
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={previewing ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPreviewing(!previewing)}
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{previewing ? 'Edit' : 'Preview'}
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Add Resource</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create a new learning resource for jury members
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Resource Settings</SheetTitle>
|
||||
<SheetDescription>
|
||||
Configure publishing, access, and metadata
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resource Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about this resource
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Ocean Conservation Best Practices"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
{/* Publish toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Published</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Make visible to users
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPublished}
|
||||
onCheckedChange={setIsPublished}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Short Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this resource"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Program */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Program</Label>
|
||||
<Select
|
||||
value={programId || 'global'}
|
||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select program" />
|
||||
<Label htmlFor="type">Resource Type</Label>
|
||||
<Select value={resourceType} onValueChange={setResourceType}>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||
{programs?.map((program) => (
|
||||
<SelectItem key={program.id} value={program.id}>
|
||||
{program.year} Edition
|
||||
{resourceTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<option.icon className="h-4 w-4" />
|
||||
{option.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Access Rules */}
|
||||
<div className="space-y-3">
|
||||
<Label>Access Rules</Label>
|
||||
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
|
||||
<SelectTrigger>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cohort">Access Level</Label>
|
||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
||||
<SelectTrigger id="cohort">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="everyone">Everyone</SelectItem>
|
||||
<SelectItem value="roles">By Role</SelectItem>
|
||||
{cohortOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{accessMode === 'roles' && (
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<label key={role.value} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={selectedRoles.includes(role.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedRoles(
|
||||
checked
|
||||
? [...selectedRoles, role.value]
|
||||
: selectedRoles.filter((r) => r !== role.value)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{role.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* External URL */}
|
||||
{resourceType === 'LINK' && (
|
||||
<div className="space-y-2">
|
||||
<Label>External URL</Label>
|
||||
<Label htmlFor="url">External URL *</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="url"
|
||||
value={externalUrl}
|
||||
onChange={(e) => setExternalUrl(e.target.value)}
|
||||
placeholder="https://example.com/resource"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional link to an external resource
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Content Editor */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Content</CardTitle>
|
||||
<CardDescription>
|
||||
Rich text content with images and videos. Type / for commands.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BlockEditor
|
||||
initialContent={contentJson || undefined}
|
||||
onChange={setContentJson}
|
||||
onUploadFile={handleUploadFile}
|
||||
className="min-h-[300px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Publish Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Publish Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="published">Published</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Make this resource visible to jury members
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="published"
|
||||
checked={isPublished}
|
||||
onCheckedChange={setIsPublished}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={createResource.isPending || !title.trim()}
|
||||
>
|
||||
{createResource.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="program">Program</Label>
|
||||
<Select
|
||||
value={programId || 'global'}
|
||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||
>
|
||||
<SelectTrigger id="program">
|
||||
<SelectValue placeholder="Select program" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||
{programs?.map((program) => (
|
||||
<SelectItem key={program.id} value={program.id}>
|
||||
{program.year} Edition
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={createResource.isPending || !title.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{createResource.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Create Resource
|
||||
</Button>
|
||||
<Button variant="outline" asChild className="w-full">
|
||||
<Link href="/admin/learning">Cancel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 px-4 py-8">
|
||||
{previewing ? (
|
||||
<ResourceRenderer
|
||||
title={title || 'Untitled'}
|
||||
description={description || null}
|
||||
contentJson={contentJson ? JSON.parse(contentJson) : null}
|
||||
/>
|
||||
) : (
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
{/* Inline title */}
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Untitled"
|
||||
autoFocus
|
||||
className="w-full border-0 bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 focus:outline-none sm:text-4xl"
|
||||
/>
|
||||
|
||||
{/* Inline description */}
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Add a description..."
|
||||
className="w-full border-0 bg-transparent text-lg text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none"
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
<hr className="border-border" />
|
||||
|
||||
{/* Block editor */}
|
||||
<BlockEditor
|
||||
onChange={setContentJson}
|
||||
onUploadFile={handleUploadFile}
|
||||
className="min-h-[400px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -23,212 +22,48 @@ import {
|
||||
import {
|
||||
Plus,
|
||||
FileText,
|
||||
Video,
|
||||
Link as LinkIcon,
|
||||
File,
|
||||
Pencil,
|
||||
ExternalLink,
|
||||
Search,
|
||||
GripVertical,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type Resource = {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
isPublished: boolean
|
||||
sortOrder: number
|
||||
externalUrl: string | null
|
||||
objectKey: string | null
|
||||
contentJson: unknown
|
||||
accessJson: unknown
|
||||
_count: { accessLogs: number }
|
||||
program: { id: string; name: string; year: number } | null
|
||||
const resourceTypeIcons = {
|
||||
PDF: FileText,
|
||||
VIDEO: Video,
|
||||
DOCUMENT: File,
|
||||
LINK: LinkIcon,
|
||||
OTHER: File,
|
||||
}
|
||||
|
||||
function getAccessSummary(accessJson: unknown): string {
|
||||
if (!accessJson || !Array.isArray(accessJson) || accessJson.length === 0) {
|
||||
return 'Everyone'
|
||||
}
|
||||
const rule = accessJson[0] as { type: string; roles?: string[] }
|
||||
if (rule.type === 'everyone') return 'Everyone'
|
||||
if (rule.type === 'roles' && rule.roles) {
|
||||
if (rule.roles.length === 1) return rule.roles[0].replace('_', ' ').toLowerCase()
|
||||
return `${rule.roles.length} roles`
|
||||
}
|
||||
if (rule.type === 'jury_group') return 'Jury groups'
|
||||
if (rule.type === 'round') return 'By round'
|
||||
return 'Custom'
|
||||
}
|
||||
|
||||
function SortableResourceCard({
|
||||
resource,
|
||||
onTogglePublished,
|
||||
}: {
|
||||
resource: Resource
|
||||
onTogglePublished: (id: string, published: boolean) => void
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: resource.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={isDragging ? 'opacity-50 shadow-lg' : ''}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
{/* Drag handle */}
|
||||
<button
|
||||
className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
|
||||
aria-label="Drag to reorder"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||
<FileText className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{/* Title & meta */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium truncate">{resource.title}</h3>
|
||||
{!resource.isPublished && (
|
||||
<Badge variant="secondary" className="text-xs">Draft</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||
<span className="capitalize">{getAccessSummary(resource.accessJson)}</span>
|
||||
<span>·</span>
|
||||
<span>{resource._count.accessLogs} views</span>
|
||||
{resource.program && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{resource.program.year}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick publish toggle */}
|
||||
<Switch
|
||||
checked={resource.isPublished}
|
||||
onCheckedChange={(checked) => onTogglePublished(resource.id, checked)}
|
||||
aria-label={resource.isPublished ? 'Unpublish' : 'Publish'}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{resource.externalUrl && (
|
||||
<a
|
||||
href={resource.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
<Link href={`/admin/learning/${resource.id}`}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
const cohortColors: Record<string, string> = {
|
||||
ALL: 'bg-gray-100 text-gray-800',
|
||||
SEMIFINALIST: 'bg-blue-100 text-blue-800',
|
||||
FINALIST: 'bg-purple-100 text-purple-800',
|
||||
}
|
||||
|
||||
export default function LearningHubPage() {
|
||||
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 100 })
|
||||
const resources = (data?.data || []) as Resource[]
|
||||
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 50 })
|
||||
const resources = data?.data
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
const [publishedFilter, setPublishedFilter] = useState('all')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const reorderMutation = trpc.learningResource.reorder.useMutation({
|
||||
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||
})
|
||||
const updateMutation = trpc.learningResource.update.useMutation({
|
||||
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||
})
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [cohortFilter, setCohortFilter] = useState('all')
|
||||
|
||||
const filteredResources = useMemo(() => {
|
||||
if (!resources) return []
|
||||
return resources.filter((resource) => {
|
||||
const matchesSearch =
|
||||
!debouncedSearch ||
|
||||
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
const matchesPublished =
|
||||
publishedFilter === 'all' ||
|
||||
(publishedFilter === 'published' && resource.isPublished) ||
|
||||
(publishedFilter === 'draft' && !resource.isPublished)
|
||||
return matchesSearch && matchesPublished
|
||||
const matchesType = typeFilter === 'all' || resource.resourceType === typeFilter
|
||||
const matchesCohort = cohortFilter === 'all' || resource.cohortLevel === cohortFilter
|
||||
return matchesSearch && matchesType && matchesCohort
|
||||
})
|
||||
}, [resources, debouncedSearch, publishedFilter])
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
const oldIndex = filteredResources.findIndex((r) => r.id === active.id)
|
||||
const newIndex = filteredResources.findIndex((r) => r.id === over.id)
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
|
||||
const reordered = arrayMove(filteredResources, oldIndex, newIndex)
|
||||
const items = reordered.map((r, i) => ({ id: r.id, sortOrder: i }))
|
||||
|
||||
reorderMutation.mutate({ items }, {
|
||||
onError: () => toast.error('Failed to reorder'),
|
||||
})
|
||||
}
|
||||
|
||||
const handleTogglePublished = (id: string, published: boolean) => {
|
||||
updateMutation.mutate({ id, isPublished: published }, {
|
||||
onSuccess: () => toast.success(published ? 'Published' : 'Unpublished'),
|
||||
onError: () => toast.error('Failed to update'),
|
||||
})
|
||||
}
|
||||
}, [resources, debouncedSearch, typeFilter, cohortFilter])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -240,20 +75,25 @@ export default function LearningHubPage() {
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Skeleton className="h-10 flex-1" />
|
||||
<Skeleton className="h-10 w-[160px]" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-10 w-[160px]" />
|
||||
<Skeleton className="h-10 w-[160px]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{/* Resource list skeleton */}
|
||||
<div className="grid gap-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-9 w-9 rounded-lg" />
|
||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -269,7 +109,7 @@ export default function LearningHubPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Learning Hub</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage educational resources for program participants
|
||||
Manage educational resources for jury members
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/learning/new">
|
||||
@@ -291,49 +131,92 @@ export default function LearningHubPage() {
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={publishedFilter} onValueChange={setPublishedFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="published">Published</SelectItem>
|
||||
<SelectItem value="draft">Drafts</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="PDF">PDF</SelectItem>
|
||||
<SelectItem value="VIDEO">Video</SelectItem>
|
||||
<SelectItem value="DOCUMENT">Document</SelectItem>
|
||||
<SelectItem value="LINK">Link</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={cohortFilter} onValueChange={setCohortFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All cohorts" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All cohorts</SelectItem>
|
||||
<SelectItem value="ALL">All (cohort)</SelectItem>
|
||||
<SelectItem value="SEMIFINALIST">Semifinalist</SelectItem>
|
||||
<SelectItem value="FINALIST">Finalist</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
{resources.length > 0 && (
|
||||
{resources && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredResources.length} of {resources.length} resources
|
||||
{reorderMutation.isPending && ' · Saving order...'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Resource List with DnD */}
|
||||
{/* Resource List */}
|
||||
{filteredResources.length > 0 ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={filteredResources.map((r) => r.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
{filteredResources.map((resource) => (
|
||||
<SortableResourceCard
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
onTogglePublished={handleTogglePublished}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : resources.length > 0 ? (
|
||||
<div className="grid gap-4">
|
||||
{filteredResources.map((resource) => {
|
||||
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
|
||||
return (
|
||||
<Card key={resource.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium truncate">{resource.title}</h3>
|
||||
{!resource.isPublished && (
|
||||
<Badge variant="secondary">Draft</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Badge className={cohortColors[resource.cohortLevel] || ''} variant="outline">
|
||||
{resource.cohortLevel}
|
||||
</Badge>
|
||||
<span>{resource.resourceType}</span>
|
||||
<span>-</span>
|
||||
<span>{resource._count.accessLogs} views</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.externalUrl && (
|
||||
<a
|
||||
href={resource.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
<Link href={`/admin/learning/${resource.id}`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : resources && resources.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function MentorDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
redirect(`/admin/members/${id}`)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function MentorsPage() {
|
||||
redirect('/admin/members')
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
||||
|
||||
if (!editionId) {
|
||||
const defaultEdition = await prisma.program.findFirst({
|
||||
where: { status: 'ACTIVE' },
|
||||
where: { status: 'ACTIVE', isTest: false },
|
||||
orderBy: { year: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
@@ -38,6 +38,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
||||
|
||||
if (!editionId) {
|
||||
const anyEdition = await prisma.program.findFirst({
|
||||
where: { isTest: false },
|
||||
orderBy: { year: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ArrowLeft, Pencil, Plus } from 'lucide-react'
|
||||
import { ArrowLeft, GraduationCap, Pencil, Plus } from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
|
||||
interface ProgramDetailPageProps {
|
||||
@@ -65,12 +65,20 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/programs/${id}/edit`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/programs/${id}/mentorship` as Route}>
|
||||
<GraduationCap className="mr-2 h-4 w-4" />
|
||||
Mentorship
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/programs/${id}/edit`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{program.description && (
|
||||
|
||||
@@ -40,7 +40,7 @@ import { formatDateOnly } from '@/lib/utils'
|
||||
|
||||
async function ProgramsContent() {
|
||||
const programs = await prisma.program.findMany({
|
||||
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
|
||||
where: { isTest: false },
|
||||
include: {
|
||||
competitions: {
|
||||
include: {
|
||||
|
||||
@@ -88,8 +88,6 @@ import {
|
||||
Mail,
|
||||
History,
|
||||
ChevronRight,
|
||||
ArrowRightLeft,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Command,
|
||||
@@ -1669,8 +1667,6 @@ export default function RoundDetailPage() {
|
||||
<InlineMemberCap
|
||||
memberId={member.id}
|
||||
currentValue={member.maxAssignmentsOverride as number | null}
|
||||
roundId={roundId}
|
||||
jurorUserId={member.userId}
|
||||
onSave={(val) => updateJuryMemberMutation.mutate({
|
||||
id: member.id,
|
||||
maxAssignmentsOverride: val,
|
||||
@@ -2246,43 +2242,20 @@ function InlineMemberCap({
|
||||
memberId,
|
||||
currentValue,
|
||||
onSave,
|
||||
roundId,
|
||||
jurorUserId,
|
||||
}: {
|
||||
memberId: string
|
||||
currentValue: number | null
|
||||
onSave: (val: number | null) => void
|
||||
roundId?: string
|
||||
jurorUserId?: string
|
||||
}) {
|
||||
const utils = trpc.useUtils()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [value, setValue] = useState(currentValue?.toString() ?? '')
|
||||
const [overCapInfo, setOverCapInfo] = useState<{ total: number; overCapCount: number; movableOverCap: number; immovableOverCap: number } | null>(null)
|
||||
const [showBanner, setShowBanner] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const redistributeMutation = trpc.assignment.redistributeOverCap.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.assignment.listByStage.invalidate()
|
||||
utils.analytics.getJurorWorkload.invalidate()
|
||||
utils.roundAssignment.unassignedQueue.invalidate()
|
||||
setShowBanner(false)
|
||||
setOverCapInfo(null)
|
||||
if (data.failed > 0) {
|
||||
toast.warning(`Redistributed ${data.redistributed} project(s). ${data.failed} could not be reassigned.`)
|
||||
} else {
|
||||
toast.success(`Redistributed ${data.redistributed} project(s) to other jurors.`)
|
||||
}
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) inputRef.current?.focus()
|
||||
}, [editing])
|
||||
|
||||
const save = async () => {
|
||||
const save = () => {
|
||||
const trimmed = value.trim()
|
||||
const newVal = trimmed === '' ? null : parseInt(trimmed, 10)
|
||||
if (newVal !== null && (isNaN(newVal) || newVal < 1)) {
|
||||
@@ -2293,76 +2266,10 @@ function InlineMemberCap({
|
||||
setEditing(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Check over-cap impact before saving
|
||||
if (newVal !== null && roundId && jurorUserId) {
|
||||
try {
|
||||
const preview = await utils.client.assignment.getOverCapPreview.query({
|
||||
roundId,
|
||||
jurorId: jurorUserId,
|
||||
newCap: newVal,
|
||||
})
|
||||
if (preview.overCapCount > 0) {
|
||||
setOverCapInfo(preview)
|
||||
setShowBanner(true)
|
||||
setEditing(false)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// If preview fails, just save the cap normally
|
||||
}
|
||||
}
|
||||
|
||||
onSave(newVal)
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
const handleRedistribute = () => {
|
||||
const newVal = parseInt(value.trim(), 10)
|
||||
onSave(newVal)
|
||||
if (roundId && jurorUserId) {
|
||||
redistributeMutation.mutate({ roundId, jurorId: jurorUserId, newCap: newVal })
|
||||
}
|
||||
}
|
||||
|
||||
const handleJustSave = () => {
|
||||
const newVal = value.trim() === '' ? null : parseInt(value.trim(), 10)
|
||||
onSave(newVal)
|
||||
setShowBanner(false)
|
||||
setOverCapInfo(null)
|
||||
}
|
||||
|
||||
if (showBanner && overCapInfo) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800">
|
||||
<p>New cap of <strong>{value}</strong> is below current load (<strong>{overCapInfo.total}</strong> assignments). <strong>{overCapInfo.movableOverCap}</strong> can be redistributed.</p>
|
||||
{overCapInfo.immovableOverCap > 0 && (
|
||||
<p className="text-amber-600 mt-0.5">{overCapInfo.immovableOverCap} submitted evaluation(s) cannot be moved.</p>
|
||||
)}
|
||||
<div className="flex gap-1.5 mt-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-6 text-xs px-2"
|
||||
disabled={redistributeMutation.isPending || overCapInfo.movableOverCap === 0}
|
||||
onClick={handleRedistribute}
|
||||
>
|
||||
{redistributeMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
||||
Redistribute
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs px-2" onClick={handleJustSave}>
|
||||
Just save cap
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-6 text-xs px-2" onClick={() => { setShowBanner(false); setOverCapInfo(null) }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<Input
|
||||
@@ -2457,8 +2364,6 @@ function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: { roundId: strin
|
||||
|
||||
function JuryProgressTable({ roundId }: { roundId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const [transferJuror, setTransferJuror] = useState<{ id: string; name: string } | null>(null)
|
||||
|
||||
const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 15_000 },
|
||||
@@ -2488,7 +2393,6 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Jury Progress</CardTitle>
|
||||
@@ -2544,22 +2448,6 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setTransferJuror({ id: juror.id, name: juror.name })}
|
||||
>
|
||||
<ArrowRightLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left"><p>Transfer assignments to other jurors</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -2601,318 +2489,6 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{transferJuror && (
|
||||
<TransferAssignmentsDialog
|
||||
roundId={roundId}
|
||||
sourceJuror={transferJuror}
|
||||
open={!!transferJuror}
|
||||
onClose={() => setTransferJuror(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Transfer Assignments Dialog ──────────────────────────────────────────
|
||||
|
||||
function TransferAssignmentsDialog({
|
||||
roundId,
|
||||
sourceJuror,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
roundId: string
|
||||
sourceJuror: { id: string; name: string }
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const utils = trpc.useUtils()
|
||||
const [step, setStep] = useState<1 | 2>(1)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// Fetch source juror's assignments
|
||||
const { data: sourceAssignments, isLoading: loadingAssignments } = trpc.assignment.listByStage.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: open },
|
||||
)
|
||||
|
||||
const jurorAssignments = useMemo(() =>
|
||||
(sourceAssignments ?? []).filter((a: any) => a.userId === sourceJuror.id),
|
||||
[sourceAssignments, sourceJuror.id],
|
||||
)
|
||||
|
||||
// Fetch transfer candidates when in step 2
|
||||
const { data: candidateData, isLoading: loadingCandidates } = trpc.assignment.getTransferCandidates.useQuery(
|
||||
{ roundId, sourceJurorId: sourceJuror.id, assignmentIds: [...selectedIds] },
|
||||
{ enabled: step === 2 && selectedIds.size > 0 },
|
||||
)
|
||||
|
||||
// Per-assignment destination overrides
|
||||
const [destOverrides, setDestOverrides] = useState<Record<string, string>>({})
|
||||
const [forceOverCap, setForceOverCap] = useState(false)
|
||||
|
||||
// Auto-assign: distribute assignments across eligible candidates balanced by load
|
||||
const handleAutoAssign = () => {
|
||||
if (!candidateData) return
|
||||
const movable = candidateData.assignments.filter((a) => a.movable)
|
||||
if (movable.length === 0) return
|
||||
|
||||
// Simulate load starting from each candidate's current load
|
||||
const simLoad = new Map<string, number>()
|
||||
for (const c of candidateData.candidates) {
|
||||
simLoad.set(c.userId, c.currentLoad)
|
||||
}
|
||||
|
||||
const overrides: Record<string, string> = {}
|
||||
|
||||
for (const assignment of movable) {
|
||||
const eligible = candidateData.candidates
|
||||
.filter((c) => c.eligibleProjectIds.includes(assignment.projectId))
|
||||
|
||||
if (eligible.length === 0) continue
|
||||
|
||||
// Sort: prefer not-all-completed, then under cap, then lowest simulated load
|
||||
const sorted = [...eligible].sort((a, b) => {
|
||||
// Prefer jurors who haven't completed all evaluations
|
||||
if (a.allCompleted !== b.allCompleted) return a.allCompleted ? 1 : -1
|
||||
const loadA = simLoad.get(a.userId) ?? 0
|
||||
const loadB = simLoad.get(b.userId) ?? 0
|
||||
// Prefer jurors under their cap
|
||||
const overCapA = loadA >= a.cap ? 1 : 0
|
||||
const overCapB = loadB >= b.cap ? 1 : 0
|
||||
if (overCapA !== overCapB) return overCapA - overCapB
|
||||
// Then pick the least loaded
|
||||
return loadA - loadB
|
||||
})
|
||||
|
||||
const best = sorted[0]
|
||||
overrides[assignment.id] = best.userId
|
||||
simLoad.set(best.userId, (simLoad.get(best.userId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
setDestOverrides(overrides)
|
||||
}
|
||||
|
||||
const transferMutation = trpc.assignment.transferAssignments.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
utils.analytics.getJurorWorkload.invalidate({ roundId })
|
||||
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
|
||||
utils.assignment.getReassignmentHistory.invalidate({ roundId })
|
||||
|
||||
const successCount = data.succeeded.length
|
||||
const failCount = data.failed.length
|
||||
if (failCount > 0) {
|
||||
toast.warning(`Transferred ${successCount} project(s). ${failCount} failed.`)
|
||||
} else {
|
||||
toast.success(`Transferred ${successCount} project(s) successfully.`)
|
||||
}
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Build the transfer plan: for each selected assignment, determine destination
|
||||
const transferPlan = useMemo(() => {
|
||||
if (!candidateData) return []
|
||||
const movable = candidateData.assignments.filter((a) => a.movable)
|
||||
return movable.map((assignment) => {
|
||||
const override = destOverrides[assignment.id]
|
||||
// Default: first eligible candidate
|
||||
const defaultDest = candidateData.candidates.find((c) =>
|
||||
c.eligibleProjectIds.includes(assignment.projectId)
|
||||
)
|
||||
const destId = override || defaultDest?.userId || ''
|
||||
const destName = candidateData.candidates.find((c) => c.userId === destId)?.name || ''
|
||||
return { assignmentId: assignment.id, projectTitle: assignment.projectTitle, destinationJurorId: destId, destName }
|
||||
}).filter((t) => t.destinationJurorId)
|
||||
}, [candidateData, destOverrides])
|
||||
|
||||
// Check if any destination is at or over cap
|
||||
const anyOverCap = useMemo(() => {
|
||||
if (!candidateData) return false
|
||||
const destCounts = new Map<string, number>()
|
||||
for (const t of transferPlan) {
|
||||
destCounts.set(t.destinationJurorId, (destCounts.get(t.destinationJurorId) ?? 0) + 1)
|
||||
}
|
||||
return candidateData.candidates.some((c) => {
|
||||
const extraLoad = destCounts.get(c.userId) ?? 0
|
||||
return c.currentLoad + extraLoad > c.cap
|
||||
})
|
||||
}, [candidateData, transferPlan])
|
||||
|
||||
const handleTransfer = () => {
|
||||
transferMutation.mutate({
|
||||
roundId,
|
||||
sourceJurorId: sourceJuror.id,
|
||||
transfers: transferPlan.map((t) => ({ assignmentId: t.assignmentId, destinationJurorId: t.destinationJurorId })),
|
||||
forceOverCap,
|
||||
})
|
||||
}
|
||||
|
||||
const isMovable = (a: any) => {
|
||||
const status = a.evaluation?.status
|
||||
return !status || status === 'NOT_STARTED' || status === 'DRAFT'
|
||||
}
|
||||
|
||||
const movableAssignments = jurorAssignments.filter(isMovable)
|
||||
const allMovableSelected = movableAssignments.length > 0 && movableAssignments.every((a: any) => selectedIds.has(a.id))
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Transfer Assignments from {sourceJuror.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 1 ? 'Select projects to transfer to other jurors.' : 'Choose destination jurors for each project.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-3">
|
||||
{loadingAssignments ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
) : jurorAssignments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">No assignments found.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<Checkbox
|
||||
checked={allMovableSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedIds(new Set(movableAssignments.map((a: any) => a.id)))
|
||||
} else {
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Select all movable ({movableAssignments.length})</span>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
||||
{jurorAssignments.map((a: any) => {
|
||||
const movable = isMovable(a)
|
||||
const status = a.evaluation?.status || 'No evaluation'
|
||||
return (
|
||||
<div key={a.id} className={cn('flex items-center gap-3 py-2 px-2 rounded-md', !movable && 'opacity-50')}>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(a.id)}
|
||||
disabled={!movable}
|
||||
onCheckedChange={(checked) => {
|
||||
const next = new Set(selectedIds)
|
||||
if (checked) next.add(a.id)
|
||||
else next.delete(a.id)
|
||||
setSelectedIds(next)
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{a.project?.title || 'Unknown'}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => { setStep(2); setDestOverrides({}) }}
|
||||
>
|
||||
Next ({selectedIds.size} selected)
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-3">
|
||||
{loadingCandidates ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
) : !candidateData || candidateData.candidates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">No eligible candidates found.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button variant="outline" size="sm" onClick={handleAutoAssign}>
|
||||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||
Auto-assign
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-[350px] overflow-y-auto">
|
||||
{candidateData.assignments.filter((a) => a.movable).map((assignment) => {
|
||||
const currentDest = destOverrides[assignment.id] ||
|
||||
candidateData.candidates.find((c) => c.eligibleProjectIds.includes(assignment.projectId))?.userId || ''
|
||||
return (
|
||||
<div key={assignment.id} className="flex items-center gap-3 py-2 px-2 border rounded-md">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{assignment.projectTitle}</p>
|
||||
<p className="text-xs text-muted-foreground">{assignment.evalStatus || 'No evaluation'}</p>
|
||||
</div>
|
||||
<Select
|
||||
value={currentDest}
|
||||
onValueChange={(v) => setDestOverrides((prev) => ({ ...prev, [assignment.id]: v }))}
|
||||
>
|
||||
<SelectTrigger className="w-[200px] h-8 text-xs">
|
||||
<SelectValue placeholder="Select juror" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{candidateData.candidates
|
||||
.filter((c) => c.eligibleProjectIds.includes(assignment.projectId))
|
||||
.map((c) => (
|
||||
<SelectItem key={c.userId} value={c.userId}>
|
||||
<span>{c.name}</span>
|
||||
<span className="text-muted-foreground ml-1">({c.currentLoad}/{c.cap})</span>
|
||||
{c.allCompleted && <span className="text-emerald-600 ml-1">Done</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{transferPlan.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Transfer {transferPlan.length} project(s) from {sourceJuror.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{anyOverCap && (
|
||||
<div className="flex items-center gap-2 p-2 border border-amber-200 bg-amber-50 rounded-md">
|
||||
<Checkbox
|
||||
checked={forceOverCap}
|
||||
onCheckedChange={(checked) => setForceOverCap(!!checked)}
|
||||
/>
|
||||
<span className="text-xs text-amber-800">Force over-cap: some destinations will exceed their assignment limit</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setStep(1)}>Back</Button>
|
||||
<Button
|
||||
disabled={transferPlan.length === 0 || transferMutation.isPending || (anyOverCap && !forceOverCap)}
|
||||
onClick={handleTransfer}
|
||||
>
|
||||
{transferMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : null}
|
||||
Transfer {transferPlan.length} project(s)
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2936,7 +2512,7 @@ function ReassignmentHistory({ roundId }: { roundId: string }) {
|
||||
Reassignment History
|
||||
<ChevronRight className={cn('h-4 w-4 ml-auto transition-transform', expanded && 'rotate-90')} />
|
||||
</CardTitle>
|
||||
<CardDescription>Juror dropout, COI, transfer, and cap redistribution audit trail</CardDescription>
|
||||
<CardDescription>Juror dropout and COI reassignment audit trail</CardDescription>
|
||||
</CardHeader>
|
||||
{expanded && (
|
||||
<CardContent>
|
||||
@@ -2955,7 +2531,7 @@ function ReassignmentHistory({ roundId }: { roundId: string }) {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={event.type === 'DROPOUT' ? 'destructive' : 'secondary'}>
|
||||
{event.type === 'DROPOUT' ? 'Juror Dropout' : event.type === 'COI' ? 'COI Reassignment' : event.type === 'TRANSFER' ? 'Assignment Transfer' : 'Cap Redistribution'}
|
||||
{event.type === 'DROPOUT' ? 'Juror Dropout' : 'COI Reassignment'}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">
|
||||
{event.droppedJuror.name}
|
||||
@@ -3046,19 +2622,19 @@ function ScoreDistribution({ roundId }: { roundId: string }) {
|
||||
No evaluations submitted yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex gap-1 h-32">
|
||||
<div className="flex items-end gap-1 h-32">
|
||||
{dist.globalDistribution.map((bucket) => {
|
||||
const heightPct = (bucket.count / maxCount) * 100
|
||||
return (
|
||||
<div key={bucket.score} className="flex-1 flex flex-col items-center gap-1 h-full">
|
||||
<div key={bucket.score} className="flex-1 flex flex-col items-center gap-1">
|
||||
<span className="text-[9px] text-muted-foreground">{bucket.count || ''}</span>
|
||||
<div className="w-full flex-1 relative">
|
||||
<div className="w-full relative rounded-t" style={{ height: `${Math.max(heightPct, 2)}%` }}>
|
||||
<div className={cn(
|
||||
'absolute inset-x-0 bottom-0 rounded-t transition-all',
|
||||
'absolute inset-0 rounded-t transition-all',
|
||||
bucket.score <= 3 ? 'bg-red-400' :
|
||||
bucket.score <= 6 ? 'bg-amber-400' :
|
||||
'bg-emerald-400',
|
||||
)} style={{ height: `${Math.max(heightPct, 4)}%` }} />
|
||||
)} />
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{bucket.score}</span>
|
||||
</div>
|
||||
|
||||
@@ -205,14 +205,14 @@ export default function RoundsPage() {
|
||||
}
|
||||
|
||||
const startEditSettings = () => {
|
||||
if (!comp) return
|
||||
if (!comp || !compDetail) return
|
||||
setEditingCompId(comp.id)
|
||||
setCompetitionEdits({
|
||||
name: comp.name,
|
||||
categoryMode: (comp as any).categoryMode,
|
||||
startupFinalistCount: (comp as any).startupFinalistCount,
|
||||
conceptFinalistCount: (comp as any).conceptFinalistCount,
|
||||
notifyOnDeadlineApproach: (comp as any).notifyOnDeadlineApproach,
|
||||
name: compDetail.name,
|
||||
categoryMode: compDetail.categoryMode,
|
||||
startupFinalistCount: compDetail.startupFinalistCount,
|
||||
conceptFinalistCount: compDetail.conceptFinalistCount,
|
||||
notifyOnDeadlineApproach: compDetail.notifyOnDeadlineApproach,
|
||||
})
|
||||
setSettingsOpen(true)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export default async function AdminLayout({
|
||||
|
||||
// Fetch all editions (programs) for the edition selector
|
||||
const editions = await prisma.program.findMany({
|
||||
where: { isTest: false },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
|
||||
180
src/app/(applicant)/applicant/competitions/[windowId]/page.tsx
Normal file
180
src/app/(applicant)/applicant/competitions/[windowId]/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client'
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { FileUploadSlot } from '@/components/applicant/file-upload-slot'
|
||||
import { ArrowLeft, Lock, Clock, Calendar, AlertCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function ApplicantSubmissionWindowPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const windowId = params.windowId as string
|
||||
|
||||
const [uploadedFiles, setUploadedFiles] = useState<Record<string, File>>({})
|
||||
|
||||
const { data: window, isLoading } = trpc.round.getById.useQuery(
|
||||
{ id: windowId },
|
||||
{ enabled: !!windowId }
|
||||
)
|
||||
|
||||
const { data: deadlineStatus } = trpc.round.checkDeadline.useQuery(
|
||||
{ windowId },
|
||||
{
|
||||
enabled: !!windowId,
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
}
|
||||
)
|
||||
|
||||
const handleUpload = (requirementId: string, file: File) => {
|
||||
setUploadedFiles(prev => ({ ...prev, [requirementId]: file }))
|
||||
toast.success(`File "${file.name}" selected for upload`)
|
||||
// In a real implementation, this would trigger file upload
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!window) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/applicant/competitions' as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Submission window not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isLocked = deadlineStatus?.status === 'LOCKED' || deadlineStatus?.status === 'CLOSED'
|
||||
const deadline = window.windowCloseAt
|
||||
? new Date(window.windowCloseAt)
|
||||
: null
|
||||
const timeRemaining = deadline ? deadline.getTime() - Date.now() : null
|
||||
const daysRemaining = timeRemaining ? Math.floor(timeRemaining / (1000 * 60 * 60 * 24)) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/applicant/competitions' as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{window.name}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Upload required documents for this submission window
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deadline card */}
|
||||
{deadline && (
|
||||
<Card className={isLocked ? 'border-red-200 bg-red-50/50' : 'border-l-4 border-l-amber-500'}>
|
||||
<CardContent className="flex items-start gap-3 p-4">
|
||||
{isLocked ? (
|
||||
<>
|
||||
<Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm text-red-900">Submission Window Closed</p>
|
||||
<p className="text-sm text-red-700 mt-1">
|
||||
This submission window closed on {deadline.toLocaleDateString()}. No further
|
||||
uploads are allowed.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clock className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">Deadline Countdown</p>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-2xl font-bold tabular-nums text-amber-600">
|
||||
{daysRemaining !== null ? daysRemaining : '—'}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
day{daysRemaining !== 1 ? 's' : ''} remaining
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Due: {deadline.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* File requirements */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>File Requirements</CardTitle>
|
||||
<CardDescription>
|
||||
Upload the required files below. {isLocked && 'Viewing only - window is closed.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* File requirements would be fetched separately in a real implementation */}
|
||||
{false ? (
|
||||
[].map((req: any) => (
|
||||
<FileUploadSlot
|
||||
key={req.id}
|
||||
requirement={{
|
||||
id: req.id,
|
||||
label: req.label,
|
||||
description: req.description,
|
||||
mimeTypes: req.mimeTypes || [],
|
||||
maxSizeMb: req.maxSizeMb,
|
||||
required: req.required || false,
|
||||
}}
|
||||
isLocked={isLocked}
|
||||
onUpload={(file) => handleUpload(req.id, file)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Calendar className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No file requirements defined for this window
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!isLocked && (
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline">Save Draft</Button>
|
||||
<Button className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
Submit All Files
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,13 +4,14 @@ import { useSession } from 'next-auth/react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline'
|
||||
import { ArrowLeft, FileText, Calendar } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function ApplicantCompetitionPage() {
|
||||
export default function ApplicantCompetitionsPage() {
|
||||
const { data: session } = useSession()
|
||||
const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
||||
enabled: !!session,
|
||||
@@ -25,7 +26,7 @@ export default function ApplicantCompetitionPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const hasProject = !!myProject?.project
|
||||
const competitionId = myProject?.project?.programId
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -44,7 +45,7 @@ export default function ApplicantCompetitionPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!hasProject ? (
|
||||
{!competitionId ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
@@ -58,7 +59,7 @@ export default function ApplicantCompetitionPage() {
|
||||
) : (
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<ApplicantCompetitionTimeline />
|
||||
<ApplicantCompetitionTimeline competitionId={competitionId} />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
@@ -1,147 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Star, MessageSquare } from 'lucide-react'
|
||||
|
||||
export default function ApplicantEvaluationsPage() {
|
||||
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
||||
<p className="text-muted-foreground">Anonymous evaluations from jury members</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="py-6">
|
||||
<Skeleton className="h-6 w-48 mb-4" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasEvaluations = rounds && rounds.length > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Anonymous evaluations from jury members
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!hasEvaluations ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Star className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No Evaluations Available</h3>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
Evaluations will appear here once jury review is complete and results are published.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{rounds.map((round) => (
|
||||
<Card key={round.roundId}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{round.roundName}</CardTitle>
|
||||
<Badge variant="secondary">
|
||||
{round.evaluationCount} evaluation{round.evaluationCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{round.evaluations.map((ev, idx) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="rounded-lg border p-4 space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">
|
||||
Evaluator #{idx + 1}
|
||||
</span>
|
||||
{ev.submittedAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(ev.submittedAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ev.globalScore !== null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4 text-yellow-500" />
|
||||
<span className="text-lg font-semibold">{ev.globalScore}</span>
|
||||
<span className="text-sm text-muted-foreground">/ 100</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ev.criterionScores && ev.criteria && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Criterion Scores</p>
|
||||
<div className="grid gap-2">
|
||||
{(() => {
|
||||
const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
|
||||
const scores = ev.criterionScores as Record<string, number>
|
||||
return criteria
|
||||
.filter((c) => c.id || c.label || c.name)
|
||||
.map((c, ci) => {
|
||||
const key = c.id || String(ci)
|
||||
const score = scores[key]
|
||||
return (
|
||||
<div key={ci} className="flex items-center justify-between text-sm">
|
||||
<span>{c.label || c.name || `Criterion ${ci + 1}`}</span>
|
||||
<span className="font-medium">
|
||||
{score !== undefined ? score : '—'}
|
||||
{c.maxScore ? ` / ${c.maxScore}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ev.feedbackText && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Written Feedback
|
||||
</div>
|
||||
<blockquote className="border-l-2 border-muted pl-4 text-sm italic text-muted-foreground">
|
||||
{ev.feedbackText}
|
||||
</blockquote>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Evaluator identities are kept confidential.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,26 +9,25 @@ import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { StatusTracker } from '@/components/shared/status-tracker'
|
||||
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import {
|
||||
FileText,
|
||||
Calendar,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Users,
|
||||
Crown,
|
||||
MessageSquare,
|
||||
Upload,
|
||||
ArrowRight,
|
||||
Star,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
||||
@@ -50,18 +49,6 @@ export default function ApplicantDashboardPage() {
|
||||
enabled: isAuthenticated,
|
||||
})
|
||||
|
||||
const { data: deadlines } = trpc.applicant.getUpcomingDeadlines.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
})
|
||||
|
||||
const { data: docCompleteness } = trpc.applicant.getDocumentCompleteness.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
})
|
||||
|
||||
const { data: evaluations } = trpc.applicant.getMyEvaluations.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
})
|
||||
|
||||
if (sessionStatus === 'loading' || isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -111,11 +98,10 @@ export default function ApplicantDashboardPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const { project, timeline, currentStatus, openRounds, hasPassedIntake } = data
|
||||
const { project, timeline, currentStatus, openRounds } = data
|
||||
const isDraft = !project.submittedAt
|
||||
const programYear = project.program?.year
|
||||
const programName = project.program?.name
|
||||
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -227,7 +213,7 @@ export default function ApplicantDashboardPage() {
|
||||
|
||||
{/* Quick actions */}
|
||||
<AnimatedCard index={1}>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Link href={"/applicant/documents" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
|
||||
<div className="rounded-xl bg-blue-500/10 p-2.5 transition-colors group-hover:bg-blue-500/20">
|
||||
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
@@ -254,108 +240,41 @@ export default function ApplicantDashboardPage() {
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
|
||||
{project.mentorAssignment && (
|
||||
<Link href={"/applicant/mentor" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-green-500/30 hover:bg-green-500/5">
|
||||
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
|
||||
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Mentor</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.mentorAssignment.mentor?.name || 'Assigned'}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
)}
|
||||
<Link href={"/applicant/mentor" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-green-500/30 hover:bg-green-500/5">
|
||||
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
|
||||
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Mentor</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.mentorAssignment?.mentor?.name || 'Not assigned'}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Document Completeness */}
|
||||
{docCompleteness && docCompleteness.length > 0 && (
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Document Progress
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{docCompleteness.map((dc) => (
|
||||
<div key={dc.roundId}>
|
||||
<div className="flex items-center justify-between text-sm mb-1.5">
|
||||
<span className="font-medium">{dc.roundName}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{dc.uploaded}/{dc.required} files
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${dc.required > 0 ? Math.round((dc.uploaded / dc.required) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Competition timeline or status tracker */}
|
||||
<AnimatedCard index={3}>
|
||||
{/* Status timeline */}
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{hasPassedIntake ? 'Competition Progress' : 'Status Timeline'}
|
||||
</CardTitle>
|
||||
<CardTitle>Status Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hasPassedIntake ? (
|
||||
<CompetitionTimelineSidebar />
|
||||
) : (
|
||||
<StatusTracker
|
||||
timeline={timeline}
|
||||
currentStatus={currentStatus || 'SUBMITTED'}
|
||||
/>
|
||||
)}
|
||||
<StatusTracker
|
||||
timeline={timeline}
|
||||
currentStatus={currentStatus || 'SUBMITTED'}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Jury Feedback Card */}
|
||||
{totalEvaluations > 0 && (
|
||||
<AnimatedCard index={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Star className="h-5 w-5" />
|
||||
Jury Feedback
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={"/applicant/evaluations" as Route}>
|
||||
View All
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalEvaluations} evaluation{totalEvaluations !== 1 ? 's' : ''} available from{' '}
|
||||
{evaluations?.length ?? 0} round{(evaluations?.length ?? 0) !== 1 ? 's' : ''}.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Team overview */}
|
||||
<AnimatedCard index={5}>
|
||||
<AnimatedCard index={3}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -407,32 +326,8 @@ export default function ApplicantDashboardPage() {
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Upcoming Deadlines */}
|
||||
{deadlines && deadlines.length > 0 && (
|
||||
<AnimatedCard index={6}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Upcoming Deadlines
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{deadlines.map((dl, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium truncate mr-2">{dl.roundName}</span>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{new Date(dl.windowCloseAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Key dates */}
|
||||
<AnimatedCard index={7}>
|
||||
<AnimatedCard index={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Key Dates</CardTitle>
|
||||
@@ -452,6 +347,12 @@ export default function ApplicantDashboardPage() {
|
||||
<span className="text-muted-foreground">Last Updated</span>
|
||||
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{openRounds.length > 0 && openRounds[0].windowCloseAt && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Deadline</span>
|
||||
<span>{new Date(openRounds[0].windowCloseAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
ExternalLink,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
const ResourceRenderer = dynamic(
|
||||
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
export default function ApplicantResourceDetailPage() {
|
||||
const params = useParams()
|
||||
const resourceId = params.id as string
|
||||
|
||||
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||
const logAccess = trpc.learningResource.logAccess.useMutation()
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// Log access on mount
|
||||
useEffect(() => {
|
||||
if (resourceId) {
|
||||
logAccess.mutate({ id: resourceId })
|
||||
}
|
||||
}, [resourceId])
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
|
||||
window.open(url, '_blank')
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<Skeleton className="h-10 w-2/3" />
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<Skeleton className="h-px w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !resource) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Resource not found</AlertTitle>
|
||||
<AlertDescription>
|
||||
This resource may have been removed or you don't have access.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/applicant/resources">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Resources
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/applicant/resources">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Resources
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.externalUrl && (
|
||||
<a href={resource.externalUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Link
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{resource.objectKey && (
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ResourceRenderer
|
||||
title={resource.title}
|
||||
description={resource.description}
|
||||
contentJson={resource.contentJson}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
FileText,
|
||||
Download,
|
||||
ExternalLink,
|
||||
BookOpen,
|
||||
} from 'lucide-react'
|
||||
|
||||
export default function ApplicantResourcesPage() {
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
||||
|
||||
const { data, isLoading } = trpc.learningResource.myResources.useQuery({})
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const handleDownload = async (resourceId: string) => {
|
||||
setDownloadingId(resourceId)
|
||||
try {
|
||||
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
|
||||
window.open(url, '_blank')
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error)
|
||||
} finally {
|
||||
setDownloadingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Resources</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Resources and materials for applicants
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const resources = data?.resources || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Resources</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Resources and materials for applicants
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{resources.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<BookOpen className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No resources available</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Check back later for learning materials
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{resources.map((resource) => {
|
||||
const isDownloading = downloadingId === resource.id
|
||||
const hasContent = !!resource.contentJson
|
||||
|
||||
return (
|
||||
<Card key={resource.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||
<FileText className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium">{resource.title}</h3>
|
||||
{resource.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{resource.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasContent && (
|
||||
<Link href={`/applicant/resources/${resource.id}` as Route}>
|
||||
<Button variant="outline" size="sm">
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
Read
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{resource.externalUrl && (
|
||||
<a
|
||||
href={resource.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{resource.objectKey && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(resource.id)}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{isDownloading ? 'Loading...' : 'Download'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -280,15 +280,6 @@ export default function ApplicantTeamPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 border p-3 text-sm">
|
||||
<p className="font-medium mb-1">What invited members can do:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>Upload documents for submission rounds</li>
|
||||
<li>View project status and competition progress</li>
|
||||
<li>Receive email notifications about round updates</li>
|
||||
</ul>
|
||||
<p className="mt-2 text-muted-foreground">Only the Team Lead can invite or remove members.</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
ExternalLink,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
const ResourceRenderer = dynamic(
|
||||
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
export default function JuryResourceDetailPage() {
|
||||
const params = useParams()
|
||||
const resourceId = params.id as string
|
||||
|
||||
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||
const logAccess = trpc.learningResource.logAccess.useMutation()
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// Log access on mount
|
||||
useEffect(() => {
|
||||
if (resourceId) {
|
||||
logAccess.mutate({ id: resourceId })
|
||||
}
|
||||
}, [resourceId])
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
|
||||
window.open(url, '_blank')
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<Skeleton className="h-10 w-2/3" />
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<Skeleton className="h-px w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !resource) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Resource not found</AlertTitle>
|
||||
<AlertDescription>
|
||||
This resource may have been removed or you don't have access.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/jury/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/jury/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.externalUrl && (
|
||||
<a href={resource.externalUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Link
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{resource.objectKey && (
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ResourceRenderer
|
||||
title={resource.title}
|
||||
description={resource.description}
|
||||
contentJson={resource.contentJson}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
FileText,
|
||||
Video,
|
||||
Link as LinkIcon,
|
||||
File,
|
||||
Download,
|
||||
ExternalLink,
|
||||
BookOpen,
|
||||
} from 'lucide-react'
|
||||
|
||||
const resourceTypeIcons = {
|
||||
PDF: FileText,
|
||||
VIDEO: Video,
|
||||
DOCUMENT: File,
|
||||
LINK: LinkIcon,
|
||||
OTHER: File,
|
||||
}
|
||||
|
||||
const cohortColors = {
|
||||
ALL: 'bg-gray-100 text-gray-800',
|
||||
SEMIFINALIST: 'bg-blue-100 text-blue-800',
|
||||
FINALIST: 'bg-purple-100 text-purple-800',
|
||||
}
|
||||
|
||||
export default function JuryLearningPage() {
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
||||
|
||||
@@ -62,6 +81,7 @@ export default function JuryLearningPage() {
|
||||
}
|
||||
|
||||
const resources = data?.resources || []
|
||||
const userCohortLevel = data?.userCohortLevel || 'ALL'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -70,6 +90,11 @@ export default function JuryLearningPage() {
|
||||
<p className="text-muted-foreground">
|
||||
Educational resources for jury members
|
||||
</p>
|
||||
{userCohortLevel !== 'ALL' && (
|
||||
<Badge className={cohortColors[userCohortLevel]} variant="outline">
|
||||
Your access level: {userCohortLevel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{resources.length === 0 ? (
|
||||
@@ -85,14 +110,14 @@ export default function JuryLearningPage() {
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{resources.map((resource) => {
|
||||
const Icon = resourceTypeIcons[resource.resourceType]
|
||||
const isDownloading = downloadingId === resource.id
|
||||
const hasContent = !!resource.contentJson
|
||||
|
||||
return (
|
||||
<Card key={resource.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||
<FileText className="h-5 w-5" />
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium">{resource.title}</h3>
|
||||
@@ -101,39 +126,36 @@ export default function JuryLearningPage() {
|
||||
{resource.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant="outline" className={cohortColors[resource.cohortLevel]}>
|
||||
{resource.cohortLevel}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{resource.resourceType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasContent && (
|
||||
<Link href={`/jury/learning/${resource.id}` as Route}>
|
||||
<Button variant="outline" size="sm">
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
Read
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{resource.externalUrl && (
|
||||
<div>
|
||||
{resource.externalUrl ? (
|
||||
<a
|
||||
href={resource.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
<Button>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{resource.objectKey && (
|
||||
) : resource.objectKey ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(resource.id)}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{isDownloading ? 'Loading...' : 'Download'}
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
ExternalLink,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
const ResourceRenderer = dynamic(
|
||||
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
export default function MentorResourceDetailPage() {
|
||||
const params = useParams()
|
||||
const resourceId = params.id as string
|
||||
|
||||
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||
const logAccess = trpc.learningResource.logAccess.useMutation()
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// Log access on mount
|
||||
useEffect(() => {
|
||||
if (resourceId) {
|
||||
logAccess.mutate({ id: resourceId })
|
||||
}
|
||||
}, [resourceId])
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
|
||||
window.open(url, '_blank')
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<Skeleton className="h-10 w-2/3" />
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<Skeleton className="h-px w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !resource) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Resource not found</AlertTitle>
|
||||
<AlertDescription>
|
||||
This resource may have been removed or you don't have access.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/mentor/resources">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Resources
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/mentor/resources">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Resources
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.externalUrl && (
|
||||
<a href={resource.externalUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Link
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{resource.objectKey && (
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ResourceRenderer
|
||||
title={resource.title}
|
||||
description={resource.description}
|
||||
contentJson={resource.contentJson}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
FileText,
|
||||
Video,
|
||||
Link as LinkIcon,
|
||||
File,
|
||||
Download,
|
||||
ExternalLink,
|
||||
BookOpen,
|
||||
} from 'lucide-react'
|
||||
|
||||
const resourceTypeIcons = {
|
||||
PDF: FileText,
|
||||
VIDEO: Video,
|
||||
DOCUMENT: File,
|
||||
LINK: LinkIcon,
|
||||
OTHER: File,
|
||||
}
|
||||
|
||||
const cohortColors: Record<string, string> = {
|
||||
ALL: 'bg-gray-100 text-gray-800',
|
||||
SEMIFINALIST: 'bg-blue-100 text-blue-800',
|
||||
FINALIST: 'bg-purple-100 text-purple-800',
|
||||
}
|
||||
|
||||
export default function MentorResourcesPage() {
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
||||
|
||||
@@ -85,14 +101,14 @@ export default function MentorResourcesPage() {
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{resources.map((resource) => {
|
||||
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
|
||||
const isDownloading = downloadingId === resource.id
|
||||
const hasContent = !!resource.contentJson
|
||||
|
||||
return (
|
||||
<Card key={resource.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||
<FileText className="h-5 w-5" />
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium">{resource.title}</h3>
|
||||
@@ -101,39 +117,36 @@ export default function MentorResourcesPage() {
|
||||
{resource.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant="outline" className={cohortColors[resource.cohortLevel] || cohortColors.ALL}>
|
||||
{resource.cohortLevel}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{resource.resourceType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasContent && (
|
||||
<Link href={`/mentor/resources/${resource.id}` as Route}>
|
||||
<Button variant="outline" size="sm">
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
Read
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{resource.externalUrl && (
|
||||
<div>
|
||||
{resource.externalUrl ? (
|
||||
<a
|
||||
href={resource.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
<Button>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{resource.objectKey && (
|
||||
) : resource.objectKey ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(resource.id)}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{isDownloading ? 'Loading...' : 'Download'}
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -13,8 +13,10 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const now = new Date()
|
||||
|
||||
// Delete projects where isDraft=true AND draftExpiresAt has passed
|
||||
// Exclude test projects — they are managed separately
|
||||
const result = await prisma.project.deleteMany({
|
||||
where: {
|
||||
isTest: false,
|
||||
isDraft: true,
|
||||
draftExpiresAt: {
|
||||
lt: now,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
import { Toaster } from 'sonner'
|
||||
import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -22,7 +23,10 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<Providers>{children}</Providers>
|
||||
<Providers>
|
||||
<ImpersonationBanner />
|
||||
{children}
|
||||
</Providers>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
|
||||
@@ -1,654 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Plus, Lock, Unlock, LockKeyhole, Loader2, Pencil, Trash2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
type SubmissionWindowManagerProps = {
|
||||
competitionId: string
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWindowManagerProps) {
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||
const [editingWindow, setEditingWindow] = useState<string | null>(null)
|
||||
const [deletingWindow, setDeletingWindow] = useState<string | null>(null)
|
||||
|
||||
// Create form state
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
roundNumber: 1,
|
||||
windowOpenAt: '',
|
||||
windowCloseAt: '',
|
||||
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
|
||||
graceHours: 0,
|
||||
lockOnClose: true,
|
||||
})
|
||||
|
||||
// Edit form state
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
roundNumber: 1,
|
||||
windowOpenAt: '',
|
||||
windowCloseAt: '',
|
||||
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
|
||||
graceHours: 0,
|
||||
lockOnClose: true,
|
||||
sortOrder: 1,
|
||||
})
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
|
||||
id: competitionId,
|
||||
})
|
||||
|
||||
const createWindowMutation = trpc.round.createSubmissionWindow.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.getById.invalidate({ id: competitionId })
|
||||
toast.success('Submission window created')
|
||||
setIsCreateOpen(false)
|
||||
// Reset form
|
||||
setCreateForm({
|
||||
name: '',
|
||||
slug: '',
|
||||
roundNumber: 1,
|
||||
windowOpenAt: '',
|
||||
windowCloseAt: '',
|
||||
deadlinePolicy: 'HARD_DEADLINE',
|
||||
graceHours: 0,
|
||||
lockOnClose: true,
|
||||
})
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateWindowMutation = trpc.round.updateSubmissionWindow.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.getById.invalidate({ id: competitionId })
|
||||
toast.success('Submission window updated')
|
||||
setEditingWindow(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteWindowMutation = trpc.round.deleteSubmissionWindow.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.getById.invalidate({ id: competitionId })
|
||||
toast.success('Submission window deleted')
|
||||
setDeletingWindow(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const openWindowMutation = trpc.round.openSubmissionWindow.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.getById.invalidate({ id: competitionId })
|
||||
toast.success('Window opened')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const closeWindowMutation = trpc.round.closeSubmissionWindow.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.getById.invalidate({ id: competitionId })
|
||||
toast.success('Window closed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const lockWindowMutation = trpc.round.lockSubmissionWindow.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.getById.invalidate({ id: competitionId })
|
||||
toast.success('Window locked')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleCreateNameChange = (value: string) => {
|
||||
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||||
setCreateForm({ ...createForm, name: value, slug: autoSlug })
|
||||
}
|
||||
|
||||
const handleEditNameChange = (value: string) => {
|
||||
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||||
setEditForm({ ...editForm, name: value, slug: autoSlug })
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!createForm.name || !createForm.slug) {
|
||||
toast.error('Name and slug are required')
|
||||
return
|
||||
}
|
||||
|
||||
createWindowMutation.mutate({
|
||||
competitionId,
|
||||
name: createForm.name,
|
||||
slug: createForm.slug,
|
||||
roundNumber: createForm.roundNumber,
|
||||
windowOpenAt: createForm.windowOpenAt ? new Date(createForm.windowOpenAt) : undefined,
|
||||
windowCloseAt: createForm.windowCloseAt ? new Date(createForm.windowCloseAt) : undefined,
|
||||
deadlinePolicy: createForm.deadlinePolicy,
|
||||
graceHours: createForm.deadlinePolicy === 'GRACE' ? createForm.graceHours : undefined,
|
||||
lockOnClose: createForm.lockOnClose,
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!editingWindow) return
|
||||
if (!editForm.name || !editForm.slug) {
|
||||
toast.error('Name and slug are required')
|
||||
return
|
||||
}
|
||||
|
||||
updateWindowMutation.mutate({
|
||||
id: editingWindow,
|
||||
name: editForm.name,
|
||||
slug: editForm.slug,
|
||||
roundNumber: editForm.roundNumber,
|
||||
windowOpenAt: editForm.windowOpenAt ? new Date(editForm.windowOpenAt) : null,
|
||||
windowCloseAt: editForm.windowCloseAt ? new Date(editForm.windowCloseAt) : null,
|
||||
deadlinePolicy: editForm.deadlinePolicy,
|
||||
graceHours: editForm.deadlinePolicy === 'GRACE' ? editForm.graceHours : null,
|
||||
lockOnClose: editForm.lockOnClose,
|
||||
sortOrder: editForm.sortOrder,
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deletingWindow) return
|
||||
deleteWindowMutation.mutate({ id: deletingWindow })
|
||||
}
|
||||
|
||||
const openEditDialog = (window: any) => {
|
||||
setEditForm({
|
||||
name: window.name,
|
||||
slug: window.slug,
|
||||
roundNumber: window.roundNumber,
|
||||
windowOpenAt: window.windowOpenAt ? new Date(window.windowOpenAt).toISOString().slice(0, 16) : '',
|
||||
windowCloseAt: window.windowCloseAt ? new Date(window.windowCloseAt).toISOString().slice(0, 16) : '',
|
||||
deadlinePolicy: window.deadlinePolicy ?? 'HARD_DEADLINE',
|
||||
graceHours: window.graceHours ?? 0,
|
||||
lockOnClose: window.lockOnClose ?? true,
|
||||
sortOrder: window.sortOrder ?? 1,
|
||||
})
|
||||
setEditingWindow(window.id)
|
||||
}
|
||||
|
||||
const formatDate = (date: Date | null | undefined) => {
|
||||
if (!date) return 'Not set'
|
||||
return format(new Date(date), 'MMM d, yyyy h:mm a')
|
||||
}
|
||||
|
||||
const windows = competition?.submissionWindows ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Submission Windows</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
File upload windows for this round
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="w-full sm:w-auto">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Create Window
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Submission Window</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-name">Window Name</Label>
|
||||
<Input
|
||||
id="create-name"
|
||||
placeholder="e.g., Round 1 Submissions"
|
||||
value={createForm.name}
|
||||
onChange={(e) => handleCreateNameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-slug">Slug</Label>
|
||||
<Input
|
||||
id="create-slug"
|
||||
placeholder="e.g., round-1-submissions"
|
||||
value={createForm.slug}
|
||||
onChange={(e) => setCreateForm({ ...createForm, slug: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-roundNumber">Round Number</Label>
|
||||
<Input
|
||||
id="create-roundNumber"
|
||||
type="number"
|
||||
min={1}
|
||||
value={createForm.roundNumber}
|
||||
onChange={(e) => setCreateForm({ ...createForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-windowOpenAt">Window Open At</Label>
|
||||
<Input
|
||||
id="create-windowOpenAt"
|
||||
type="datetime-local"
|
||||
value={createForm.windowOpenAt}
|
||||
onChange={(e) => setCreateForm({ ...createForm, windowOpenAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-windowCloseAt">Window Close At</Label>
|
||||
<Input
|
||||
id="create-windowCloseAt"
|
||||
type="datetime-local"
|
||||
value={createForm.windowCloseAt}
|
||||
onChange={(e) => setCreateForm({ ...createForm, windowCloseAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-deadlinePolicy">Deadline Policy</Label>
|
||||
<Select
|
||||
value={createForm.deadlinePolicy}
|
||||
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
|
||||
setCreateForm({ ...createForm, deadlinePolicy: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="create-deadlinePolicy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
|
||||
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
|
||||
<SelectItem value="GRACE">Grace Period</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{createForm.deadlinePolicy === 'GRACE' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-graceHours">Grace Hours</Label>
|
||||
<Input
|
||||
id="create-graceHours"
|
||||
type="number"
|
||||
min={0}
|
||||
value={createForm.graceHours}
|
||||
onChange={(e) => setCreateForm({ ...createForm, graceHours: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="create-lockOnClose"
|
||||
checked={createForm.lockOnClose}
|
||||
onCheckedChange={(checked) => setCreateForm({ ...createForm, lockOnClose: checked })}
|
||||
/>
|
||||
<Label htmlFor="create-lockOnClose" className="cursor-pointer">
|
||||
Lock window on close
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setIsCreateOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleCreate}
|
||||
disabled={createWindowMutation.isPending}
|
||||
>
|
||||
{createWindowMutation.isPending && (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
)}
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
Loading windows...
|
||||
</div>
|
||||
) : windows.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
No submission windows yet. Create one to enable file uploads.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{windows.map((window) => {
|
||||
const isPending = !window.windowOpenAt
|
||||
const isOpen = window.windowOpenAt && !window.windowCloseAt
|
||||
const isClosed = window.windowCloseAt && !window.isLocked
|
||||
const isLocked = window.isLocked
|
||||
|
||||
return (
|
||||
<div
|
||||
key={window.id}
|
||||
className="flex flex-col gap-3 border rounded-lg p-3"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-medium truncate">{window.name}</p>
|
||||
{isPending && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-gray-100 text-gray-700">
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
{isOpen && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-emerald-100 text-emerald-700">
|
||||
Open
|
||||
</Badge>
|
||||
)}
|
||||
{isClosed && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-blue-100 text-blue-700">
|
||||
Closed
|
||||
</Badge>
|
||||
)}
|
||||
{isLocked && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-red-100 text-red-700">
|
||||
<LockKeyhole className="h-2.5 w-2.5 mr-1" />
|
||||
Locked
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono mt-0.5">{window.slug}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<span>Round {window.roundNumber}</span>
|
||||
<span>•</span>
|
||||
<span>{window._count.fileRequirements} requirements</span>
|
||||
<span>•</span>
|
||||
<span>{window._count.projectFiles} files</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<span>Open: {formatDate(window.windowOpenAt)}</span>
|
||||
<span>•</span>
|
||||
<span>Close: {formatDate(window.windowCloseAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openEditDialog(window)}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDeletingWindow(window.id)}
|
||||
className="h-8 px-2 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
{isPending && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openWindowMutation.mutate({ windowId: window.id })}
|
||||
disabled={openWindowMutation.isPending}
|
||||
>
|
||||
{openWindowMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Unlock className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Open
|
||||
</Button>
|
||||
)}
|
||||
{isOpen && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => closeWindowMutation.mutate({ windowId: window.id })}
|
||||
disabled={closeWindowMutation.isPending}
|
||||
>
|
||||
{closeWindowMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Lock className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
{isClosed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => lockWindowMutation.mutate({ windowId: window.id })}
|
||||
disabled={lockWindowMutation.isPending}
|
||||
>
|
||||
{lockWindowMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<LockKeyhole className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Lock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={!!editingWindow} onOpenChange={(open) => !open && setEditingWindow(null)}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Submission Window</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-name">Window Name</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
placeholder="e.g., Round 1 Submissions"
|
||||
value={editForm.name}
|
||||
onChange={(e) => handleEditNameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-slug">Slug</Label>
|
||||
<Input
|
||||
id="edit-slug"
|
||||
placeholder="e.g., round-1-submissions"
|
||||
value={editForm.slug}
|
||||
onChange={(e) => setEditForm({ ...editForm, slug: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-roundNumber">Round Number</Label>
|
||||
<Input
|
||||
id="edit-roundNumber"
|
||||
type="number"
|
||||
min={1}
|
||||
value={editForm.roundNumber}
|
||||
onChange={(e) => setEditForm({ ...editForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-windowOpenAt">Window Open At</Label>
|
||||
<Input
|
||||
id="edit-windowOpenAt"
|
||||
type="datetime-local"
|
||||
value={editForm.windowOpenAt}
|
||||
onChange={(e) => setEditForm({ ...editForm, windowOpenAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-windowCloseAt">Window Close At</Label>
|
||||
<Input
|
||||
id="edit-windowCloseAt"
|
||||
type="datetime-local"
|
||||
value={editForm.windowCloseAt}
|
||||
onChange={(e) => setEditForm({ ...editForm, windowCloseAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-deadlinePolicy">Deadline Policy</Label>
|
||||
<Select
|
||||
value={editForm.deadlinePolicy}
|
||||
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
|
||||
setEditForm({ ...editForm, deadlinePolicy: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="edit-deadlinePolicy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
|
||||
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
|
||||
<SelectItem value="GRACE">Grace Period</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{editForm.deadlinePolicy === 'GRACE' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-graceHours">Grace Hours</Label>
|
||||
<Input
|
||||
id="edit-graceHours"
|
||||
type="number"
|
||||
min={0}
|
||||
value={editForm.graceHours}
|
||||
onChange={(e) => setEditForm({ ...editForm, graceHours: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="edit-lockOnClose"
|
||||
checked={editForm.lockOnClose}
|
||||
onCheckedChange={(checked) => setEditForm({ ...editForm, lockOnClose: checked })}
|
||||
/>
|
||||
<Label htmlFor="edit-lockOnClose" className="cursor-pointer">
|
||||
Lock window on close
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-sortOrder">Sort Order</Label>
|
||||
<Input
|
||||
id="edit-sortOrder"
|
||||
type="number"
|
||||
min={1}
|
||||
value={editForm.sortOrder}
|
||||
onChange={(e) => setEditForm({ ...editForm, sortOrder: parseInt(e.target.value, 10) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setEditingWindow(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleEdit}
|
||||
disabled={updateWindowMutation.isPending}
|
||||
>
|
||||
{updateWindowMutation.isPending && (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={!!deletingWindow} onOpenChange={(open) => !open && setDeletingWindow(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Submission Window</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this submission window? This action cannot be undone.
|
||||
{(windows.find(w => w.id === deletingWindow)?._count?.projectFiles ?? 0) > 0 && (
|
||||
<span className="block mt-2 text-destructive font-medium">
|
||||
Warning: This window has uploaded files and cannot be deleted until they are removed.
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeletingWindow(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteWindowMutation.isPending}
|
||||
>
|
||||
{deleteWindowMutation.isPending && (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
)}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,14 +25,6 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
|
||||
update('advancementConfig', { ...advancementConfig, [key]: value })
|
||||
}
|
||||
|
||||
const visConfig = (config.applicantVisibility as {
|
||||
enabled?: boolean; showGlobalScore?: boolean; showCriterionScores?: boolean; showFeedbackText?: boolean
|
||||
}) ?? {}
|
||||
|
||||
const updateVisibility = (key: string, value: unknown) => {
|
||||
update('applicantVisibility', { ...visConfig, [key]: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Scoring */}
|
||||
@@ -210,71 +202,6 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Applicant Feedback Visibility */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Applicant Feedback Visibility</CardTitle>
|
||||
<CardDescription>Control what evaluation data applicants can see after this round closes</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="applicantVisEnabled">Show Evaluations to Applicants</Label>
|
||||
<p className="text-xs text-muted-foreground">Master switch — when off, nothing is visible to applicants</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="applicantVisEnabled"
|
||||
checked={visConfig.enabled ?? false}
|
||||
onCheckedChange={(v) => updateVisibility('enabled', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{visConfig.enabled && (
|
||||
<div className="pl-6 border-l-2 border-muted space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="showGlobalScore">Show Global Score</Label>
|
||||
<p className="text-xs text-muted-foreground">Display the overall score for each evaluation</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="showGlobalScore"
|
||||
checked={visConfig.showGlobalScore ?? false}
|
||||
onCheckedChange={(v) => updateVisibility('showGlobalScore', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="showCriterionScores">Show Per-Criterion Scores</Label>
|
||||
<p className="text-xs text-muted-foreground">Display individual criterion scores and names</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="showCriterionScores"
|
||||
checked={visConfig.showCriterionScores ?? false}
|
||||
onCheckedChange={(v) => updateVisibility('showCriterionScores', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="showFeedbackText">Show Written Feedback</Label>
|
||||
<p className="text-xs text-muted-foreground">Display jury members' written comments</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="showFeedbackText"
|
||||
checked={visConfig.showFeedbackText ?? false}
|
||||
onCheckedChange={(v) => updateVisibility('showFeedbackText', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
|
||||
Evaluations are only visible to applicants after this round closes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Advancement */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -4,17 +4,36 @@ import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { CheckCircle2, Circle, Clock, XCircle, Trophy } from 'lucide-react'
|
||||
import { CheckCircle2, Circle, Clock } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const roundStatusDisplay: Record<string, { label: string; variant: 'default' | 'secondary' }> = {
|
||||
ROUND_DRAFT: { label: 'Upcoming', variant: 'secondary' },
|
||||
ROUND_ACTIVE: { label: 'In Progress', variant: 'default' },
|
||||
ROUND_CLOSED: { label: 'Completed', variant: 'default' },
|
||||
ROUND_ARCHIVED: { label: 'Completed', variant: 'default' },
|
||||
interface ApplicantCompetitionTimelineProps {
|
||||
competitionId: string
|
||||
}
|
||||
|
||||
export function ApplicantCompetitionTimeline() {
|
||||
const { data, isLoading } = trpc.applicant.getMyCompetitionTimeline.useQuery()
|
||||
const statusIcons: Record<string, React.ElementType> = {
|
||||
completed: CheckCircle2,
|
||||
current: Clock,
|
||||
upcoming: Circle,
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
completed: 'text-emerald-600',
|
||||
current: 'text-brand-blue',
|
||||
upcoming: 'text-muted-foreground',
|
||||
}
|
||||
|
||||
const statusBgColors: Record<string, string> = {
|
||||
completed: 'bg-emerald-50',
|
||||
current: 'bg-brand-blue/10',
|
||||
upcoming: 'bg-muted',
|
||||
}
|
||||
|
||||
export function ApplicantCompetitionTimeline({ competitionId }: ApplicantCompetitionTimelineProps) {
|
||||
const { data: competition, isLoading } = trpc.competition.getById.useQuery(
|
||||
{ id: competitionId },
|
||||
{ enabled: !!competitionId }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -33,7 +52,7 @@ export function ApplicantCompetitionTimeline() {
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.entries.length === 0) {
|
||||
if (!competition || !competition.rounds || competition.rounds.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -41,117 +60,77 @@ export function ApplicantCompetitionTimeline() {
|
||||
</CardHeader>
|
||||
<CardContent className="text-center py-8">
|
||||
<Circle className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No rounds available yet</p>
|
||||
<p className="text-sm text-muted-foreground">No rounds available</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const rounds = competition.rounds || []
|
||||
const currentRoundIndex = rounds.findIndex(r => r.status === 'ROUND_ACTIVE')
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Competition Timeline</CardTitle>
|
||||
{data.competitionName && (
|
||||
<p className="text-sm text-muted-foreground">{data.competitionName}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative space-y-6">
|
||||
{/* Vertical connecting line */}
|
||||
<div className="absolute left-5 top-5 bottom-5 w-0.5 bg-border" />
|
||||
|
||||
{data.entries.map((entry) => {
|
||||
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
|
||||
const isActive = entry.status === 'ROUND_ACTIVE'
|
||||
const isRejected = entry.projectState === 'REJECTED'
|
||||
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
|
||||
|
||||
// Determine icon
|
||||
let Icon = Circle
|
||||
let iconBg = 'bg-muted'
|
||||
let iconColor = 'text-muted-foreground'
|
||||
|
||||
if (isRejected) {
|
||||
Icon = XCircle
|
||||
iconBg = 'bg-red-50'
|
||||
iconColor = 'text-red-600'
|
||||
} else if (isGrandFinale && isCompleted) {
|
||||
Icon = Trophy
|
||||
iconBg = 'bg-yellow-50'
|
||||
iconColor = 'text-yellow-600'
|
||||
} else if (isCompleted) {
|
||||
Icon = CheckCircle2
|
||||
iconBg = 'bg-emerald-50'
|
||||
iconColor = 'text-emerald-600'
|
||||
} else if (isActive) {
|
||||
Icon = Clock
|
||||
iconBg = 'bg-brand-blue/10'
|
||||
iconColor = 'text-brand-blue'
|
||||
}
|
||||
|
||||
// Project state display
|
||||
let stateLabel: string | null = null
|
||||
if (entry.projectState === 'REJECTED') {
|
||||
stateLabel = 'Not Selected'
|
||||
} else if (entry.projectState === 'PASSED' || entry.projectState === 'COMPLETED') {
|
||||
stateLabel = 'Advanced'
|
||||
} else if (entry.projectState === 'IN_PROGRESS') {
|
||||
stateLabel = 'Under Review'
|
||||
} else if (entry.projectState === 'PENDING') {
|
||||
stateLabel = 'Pending'
|
||||
}
|
||||
|
||||
const statusInfo = roundStatusDisplay[entry.status] ?? { label: 'Upcoming', variant: 'secondary' as const }
|
||||
{rounds.map((round, index) => {
|
||||
const isActive = round.status === 'ROUND_ACTIVE'
|
||||
const isCompleted = index < currentRoundIndex || round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED'
|
||||
const isCurrent = index === currentRoundIndex || isActive
|
||||
const status = isCompleted ? 'completed' : isCurrent ? 'current' : 'upcoming'
|
||||
const Icon = statusIcons[status]
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="relative flex items-start gap-4">
|
||||
<div key={round.id} className="relative flex items-start gap-4">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full ${iconBg} shrink-0`}
|
||||
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full ${statusBgColors[status]} shrink-0`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 ${iconColor}`} />
|
||||
<Icon className={`h-5 w-5 ${statusColors[status]}`} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 pb-6">
|
||||
<div className="flex items-start justify-between flex-wrap gap-2 mb-2">
|
||||
<div>
|
||||
<h3 className="font-semibold">{entry.label}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{stateLabel && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
isRejected
|
||||
? 'border-red-200 text-red-700 bg-red-50'
|
||||
: entry.projectState === 'PASSED' || entry.projectState === 'COMPLETED'
|
||||
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{stateLabel}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant={statusInfo.variant}
|
||||
className={
|
||||
isCompleted
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
: isActive
|
||||
? 'bg-brand-blue text-white'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
<h3 className="font-semibold">{round.name}</h3>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
status === 'completed'
|
||||
? 'default'
|
||||
: status === 'current'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
className={
|
||||
status === 'completed'
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
: status === 'current'
|
||||
? 'bg-brand-blue text-white'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{status === 'completed' && 'Completed'}
|
||||
{status === 'current' && 'In Progress'}
|
||||
{status === 'upcoming' && 'Upcoming'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{entry.windowOpenAt && entry.windowCloseAt && (
|
||||
{round.windowOpenAt && round.windowCloseAt && (
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p>Opens: {new Date(entry.windowOpenAt).toLocaleDateString()}</p>
|
||||
<p>Closes: {new Date(entry.windowCloseAt).toLocaleDateString()}</p>
|
||||
<p>
|
||||
Opens: {new Date(round.windowOpenAt).toLocaleDateString()}
|
||||
</p>
|
||||
<p>
|
||||
Closes: {new Date(round.windowCloseAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -163,76 +142,3 @@ export function ApplicantCompetitionTimeline() {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact sidebar variant for the dashboard.
|
||||
* Shows dots + labels, no date details.
|
||||
*/
|
||||
export function CompetitionTimelineSidebar() {
|
||||
const { data, isLoading } = trpc.applicant.getMyCompetitionTimeline.useQuery()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-6" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.entries.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No rounds available</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{data.entries.map((entry, index) => {
|
||||
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
|
||||
const isActive = entry.status === 'ROUND_ACTIVE'
|
||||
const isRejected = entry.projectState === 'REJECTED'
|
||||
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
|
||||
const isLast = index === data.entries.length - 1
|
||||
|
||||
let dotColor = 'border-2 border-muted bg-background'
|
||||
if (isRejected) dotColor = 'bg-destructive'
|
||||
else if (isGrandFinale && isCompleted) dotColor = 'bg-yellow-500'
|
||||
else if (isCompleted) dotColor = 'bg-primary'
|
||||
else if (isActive) dotColor = 'bg-primary ring-2 ring-primary/30'
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="relative flex gap-3">
|
||||
{/* Connecting line */}
|
||||
{!isLast && (
|
||||
<div className="absolute left-[7px] top-[20px] h-full w-0.5 bg-muted" />
|
||||
)}
|
||||
|
||||
{/* Dot */}
|
||||
<div className={`relative z-10 mt-1.5 h-4 w-4 rounded-full shrink-0 ${dotColor}`} />
|
||||
|
||||
{/* Label */}
|
||||
<div className="flex-1 pb-4">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
isRejected
|
||||
? 'text-destructive'
|
||||
: isCompleted || isActive
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{entry.label}
|
||||
</p>
|
||||
{isRejected && (
|
||||
<p className="text-xs text-destructive">Not Selected</p>
|
||||
)}
|
||||
{isActive && (
|
||||
<p className="text-xs text-primary">In Progress</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -226,7 +226,8 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||
{navigation.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(item.href !== '/admin' && pathname.startsWith(item.href))
|
||||
(item.href !== '/admin' && pathname.startsWith(item.href)) ||
|
||||
(item.href === '/admin/rounds' && pathname.startsWith('/admin/competitions'))
|
||||
return (
|
||||
<div key={item.name}>
|
||||
<Link
|
||||
@@ -258,12 +259,24 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||
Administration
|
||||
</p>
|
||||
{dynamicAdminNav.map((item) => {
|
||||
const isDisabled = item.name === 'Apply Page' && !currentEdition?.id
|
||||
let isActive = pathname.startsWith(item.href)
|
||||
if (item.activeMatch) {
|
||||
isActive = pathname.includes(item.activeMatch)
|
||||
} else if (item.activeExclude && pathname.includes(item.activeExclude)) {
|
||||
isActive = false
|
||||
}
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<span
|
||||
key={item.name}
|
||||
className="group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium opacity-50 pointer-events-none text-muted-foreground"
|
||||
>
|
||||
<item.icon className="h-4 w-4 text-muted-foreground" />
|
||||
{item.name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Home, Users, FileText, MessageSquare, Trophy, Star, BookOpen } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Home, Users, FileText, MessageSquare, Layers } from 'lucide-react'
|
||||
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||
|
||||
interface ApplicantNavProps {
|
||||
@@ -9,22 +8,32 @@ interface ApplicantNavProps {
|
||||
}
|
||||
|
||||
export function ApplicantNav({ user }: ApplicantNavProps) {
|
||||
const { data: flags } = trpc.applicant.getNavFlags.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{ name: 'Dashboard', href: '/applicant', icon: Home },
|
||||
{ name: 'Team', href: '/applicant/team', icon: Users },
|
||||
{ name: 'Competition', href: '/applicant/competition', icon: Trophy },
|
||||
{ name: 'Documents', href: '/applicant/documents', icon: FileText },
|
||||
...(flags?.hasEvaluationRounds
|
||||
? [{ name: 'Evaluations', href: '/applicant/evaluations', icon: Star }]
|
||||
: []),
|
||||
...(flags?.hasMentor
|
||||
? [{ name: 'Mentoring', href: '/applicant/mentor', icon: MessageSquare }]
|
||||
: []),
|
||||
{ name: 'Resources', href: '/applicant/resources', icon: BookOpen },
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/applicant',
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
name: 'Team',
|
||||
href: '/applicant/team',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
name: 'Competitions',
|
||||
href: '/applicant/competitions',
|
||||
icon: Layers,
|
||||
},
|
||||
{
|
||||
name: 'Documents',
|
||||
href: '/applicant/documents',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
name: 'Mentoring',
|
||||
href: '/applicant/mentor',
|
||||
icon: MessageSquare,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -25,11 +25,13 @@ import {
|
||||
ShieldAlert,
|
||||
Webhook,
|
||||
MessageCircle,
|
||||
FlaskConical,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { AISettingsForm } from './ai-settings-form'
|
||||
import { AIUsageCard } from './ai-usage-card'
|
||||
import { TestEnvironmentPanel } from './test-environment-panel'
|
||||
import { BrandingSettingsForm } from './branding-settings-form'
|
||||
import { EmailSettingsForm } from './email-settings-form'
|
||||
import { StorageSettingsForm } from './storage-settings-form'
|
||||
@@ -226,6 +228,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
Webhooks
|
||||
</Link>
|
||||
)}
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="testenv" className="gap-2 shrink-0">
|
||||
<FlaskConical className="h-4 w-4" />
|
||||
Test Env
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<div className="lg:flex lg:gap-8">
|
||||
@@ -319,6 +327,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
Webhooks
|
||||
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
|
||||
</Link>
|
||||
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5 mt-1">
|
||||
<TabsTrigger value="testenv" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||
<FlaskConical className="h-4 w-4" />
|
||||
Test Env
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
@@ -513,6 +527,28 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="testenv" className="space-y-6">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FlaskConical className="h-5 w-5" />
|
||||
Test Environment
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Create a sandboxed test competition with dummy data for testing all roles and workflows.
|
||||
Fully isolated from production data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TestEnvironmentPanel />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>{/* end content area */}
|
||||
</div>{/* end lg:flex */}
|
||||
</Tabs>
|
||||
|
||||
297
src/components/settings/test-environment-panel.tsx
Normal file
297
src/components/settings/test-environment-panel.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
FlaskConical,
|
||||
Plus,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Users,
|
||||
UserCog,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import type { UserRole } from '@prisma/client'
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
JURY_MEMBER: 'Jury Member',
|
||||
APPLICANT: 'Applicant',
|
||||
MENTOR: 'Mentor',
|
||||
OBSERVER: 'Observer',
|
||||
AWARD_MASTER: 'Award Master',
|
||||
PROGRAM_ADMIN: 'Program Admin',
|
||||
}
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
JURY_MEMBER: 'bg-blue-100 text-blue-800',
|
||||
APPLICANT: 'bg-green-100 text-green-800',
|
||||
MENTOR: 'bg-purple-100 text-purple-800',
|
||||
OBSERVER: 'bg-orange-100 text-orange-800',
|
||||
AWARD_MASTER: 'bg-yellow-100 text-yellow-800',
|
||||
PROGRAM_ADMIN: 'bg-red-100 text-red-800',
|
||||
}
|
||||
|
||||
const ROLE_LANDING: Record<string, string> = {
|
||||
JURY_MEMBER: '/jury',
|
||||
APPLICANT: '/applicant',
|
||||
MENTOR: '/mentor',
|
||||
OBSERVER: '/observer',
|
||||
AWARD_MASTER: '/admin',
|
||||
PROGRAM_ADMIN: '/admin',
|
||||
}
|
||||
|
||||
export function TestEnvironmentPanel() {
|
||||
const { update } = useSession()
|
||||
const router = useRouter()
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: status, isLoading } = trpc.testEnvironment.status.useQuery()
|
||||
const createMutation = trpc.testEnvironment.create.useMutation({
|
||||
onSuccess: () => utils.testEnvironment.status.invalidate(),
|
||||
})
|
||||
const tearDownMutation = trpc.testEnvironment.tearDown.useMutation({
|
||||
onSuccess: () => utils.testEnvironment.status.invalidate(),
|
||||
})
|
||||
|
||||
const [confirmText, setConfirmText] = useState('')
|
||||
const [tearDownOpen, setTearDownOpen] = useState(false)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// No test environment — show creation card
|
||||
if (!status?.active) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border-2 border-dashed p-8 text-center">
|
||||
<FlaskConical className="mx-auto h-12 w-12 text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-semibold">No Test Environment</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground max-w-md mx-auto">
|
||||
Create a sandboxed test competition with dummy users, projects, jury assignments,
|
||||
and partial evaluations. All test data is fully isolated from production.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-6"
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating test environment...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Test Competition
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{createMutation.isError && (
|
||||
<p className="mt-3 text-sm text-destructive">
|
||||
{createMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Test environment is active
|
||||
const { competition, rounds, users, emailRedirect } = status
|
||||
|
||||
// Group users by role for impersonation cards
|
||||
const roleGroups = users.reduce(
|
||||
(acc, u) => {
|
||||
const role = u.role as string
|
||||
if (!acc[role]) acc[role] = []
|
||||
acc[role].push(u)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, typeof users>
|
||||
)
|
||||
|
||||
async function handleImpersonate(userId: string, role: UserRole) {
|
||||
await update({ impersonateUserId: userId })
|
||||
router.push((ROLE_LANDING[role] || '/admin') as any)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
function handleTearDown() {
|
||||
if (confirmText !== 'DELETE TEST') return
|
||||
tearDownMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
setTearDownOpen(false)
|
||||
setConfirmText('')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Test Active
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{competition.name}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={`/admin/competitions/${competition.id}`} target="_blank" rel="noopener">
|
||||
View Competition
|
||||
<ExternalLink className="ml-1.5 h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-2xl font-bold">{rounds.length}</p>
|
||||
<p className="text-xs text-muted-foreground">Rounds</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-2xl font-bold">{users.length}</p>
|
||||
<p className="text-xs text-muted-foreground">Test Users</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-2xl font-bold truncate text-sm font-mono">
|
||||
{emailRedirect || '—'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Email Redirect</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Impersonation section */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<UserCog className="h-4 w-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-semibold">Impersonate Test User</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{Object.entries(roleGroups).map(([role, roleUsers]) => (
|
||||
<Card key={role} className="overflow-hidden">
|
||||
<CardHeader className="py-2 px-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className={ROLE_COLORS[role] || ''}>
|
||||
{ROLE_LABELS[role] || role}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{roleUsers.length} user{roleUsers.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2 px-3 space-y-1.5">
|
||||
{roleUsers.slice(0, 3).map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => handleImpersonate(u.id, u.role as UserRole)}
|
||||
className="flex items-center justify-between w-full rounded-md px-2 py-1.5 text-sm hover:bg-muted transition-colors text-left"
|
||||
>
|
||||
<span className="truncate">{u.name || u.email}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0 ml-2">
|
||||
Impersonate
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{roleUsers.length > 3 && (
|
||||
<p className="text-xs text-muted-foreground px-2">
|
||||
+{roleUsers.length - 3} more (switch via banner)
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tear down */}
|
||||
<div className="border-t pt-4">
|
||||
<AlertDialog open={tearDownOpen} onOpenChange={setTearDownOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Tear Down Test Environment
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
Destroy Test Environment
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete ALL test data: users, projects, competitions,
|
||||
assignments, evaluations, and files. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<p className="text-sm font-medium">
|
||||
Type <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">DELETE TEST</code> to confirm:
|
||||
</p>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="DELETE TEST"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setConfirmText('')}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleTearDown}
|
||||
disabled={confirmText !== 'DELETE TEST' || tearDownMutation.isPending}
|
||||
>
|
||||
{tearDownMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Tearing down...
|
||||
</>
|
||||
) : (
|
||||
'Destroy Test Environment'
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCreateBlockNote } from '@blocknote/react'
|
||||
import { BlockNoteView } from '@blocknote/shadcn'
|
||||
import { BlockNoteView } from '@blocknote/mantine'
|
||||
import '@blocknote/core/fonts/inter.css'
|
||||
import '@blocknote/shadcn/style.css'
|
||||
import '@blocknote/mantine/style.css'
|
||||
|
||||
import type { PartialBlock } from '@blocknote/core'
|
||||
|
||||
|
||||
149
src/components/shared/impersonation-banner.tsx
Normal file
149
src/components/shared/impersonation-banner.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ChevronDown, LogOut, UserCog } from 'lucide-react'
|
||||
import type { UserRole } from '@prisma/client'
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
JURY_MEMBER: 'Jury Member',
|
||||
APPLICANT: 'Applicant',
|
||||
MENTOR: 'Mentor',
|
||||
OBSERVER: 'Observer',
|
||||
AWARD_MASTER: 'Award Master',
|
||||
PROGRAM_ADMIN: 'Program Admin',
|
||||
SUPER_ADMIN: 'Super Admin',
|
||||
}
|
||||
|
||||
const ROLE_LANDING: Record<string, string> = {
|
||||
JURY_MEMBER: '/jury',
|
||||
APPLICANT: '/applicant',
|
||||
MENTOR: '/mentor',
|
||||
OBSERVER: '/observer',
|
||||
AWARD_MASTER: '/admin',
|
||||
PROGRAM_ADMIN: '/admin',
|
||||
SUPER_ADMIN: '/admin',
|
||||
}
|
||||
|
||||
export function ImpersonationBanner() {
|
||||
const { data: session, update } = useSession()
|
||||
const router = useRouter()
|
||||
const [switching, setSwitching] = useState(false)
|
||||
|
||||
// Only fetch test users when impersonating (realRole check happens server-side)
|
||||
const { data: testEnv } = trpc.testEnvironment.status.useQuery(undefined, {
|
||||
enabled: !!session?.user?.isImpersonating,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
if (!session?.user?.isImpersonating) return null
|
||||
|
||||
const currentRole = session.user.role
|
||||
const currentName = session.user.impersonatedName || session.user.name || 'Unknown'
|
||||
|
||||
// Group available test users by role (exclude currently impersonated user)
|
||||
const availableUsers = testEnv?.active
|
||||
? testEnv.users.filter((u) => u.id !== session.user.id)
|
||||
: []
|
||||
|
||||
const roleGroups = availableUsers.reduce(
|
||||
(acc, u) => {
|
||||
const role = u.role as string
|
||||
if (!acc[role]) acc[role] = []
|
||||
acc[role].push(u)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, typeof availableUsers>
|
||||
)
|
||||
|
||||
async function handleSwitch(userId: string, role: UserRole) {
|
||||
setSwitching(true)
|
||||
await update({ impersonateUserId: userId })
|
||||
router.push((ROLE_LANDING[role] || '/admin') as any)
|
||||
router.refresh()
|
||||
setSwitching(false)
|
||||
}
|
||||
|
||||
async function handleStopImpersonation() {
|
||||
setSwitching(true)
|
||||
await update({ stopImpersonation: true })
|
||||
router.push('/admin/settings' as any)
|
||||
router.refresh()
|
||||
setSwitching(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 z-50 bg-amber-500 text-amber-950 shadow-md">
|
||||
<div className="mx-auto flex items-center justify-between px-4 py-1.5 text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCog className="h-4 w-4" />
|
||||
<span>
|
||||
Viewing as <strong>{currentName}</strong>{' '}
|
||||
<span className="rounded bg-amber-600/30 px-1.5 py-0.5 text-xs font-semibold">
|
||||
{ROLE_LABELS[currentRole] || currentRole}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Quick-switch dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 bg-amber-600/20 text-amber-950 hover:bg-amber-600/40"
|
||||
disabled={switching}
|
||||
>
|
||||
Switch Role
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{Object.entries(roleGroups).map(([role, users]) => (
|
||||
<div key={role}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
{ROLE_LABELS[role] || role}
|
||||
</DropdownMenuLabel>
|
||||
{users.map((u) => (
|
||||
<DropdownMenuItem
|
||||
key={u.id}
|
||||
onClick={() => handleSwitch(u.id, u.role as UserRole)}
|
||||
disabled={switching}
|
||||
>
|
||||
<span className="truncate">{u.name || u.email}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Return to admin */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 bg-amber-600/20 text-amber-950 hover:bg-amber-600/40"
|
||||
onClick={handleStopImpersonation}
|
||||
disabled={switching}
|
||||
>
|
||||
<LogOut className="h-3 w-3" />
|
||||
Return to Admin
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const BlockViewer = dynamic(
|
||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockViewer),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
interface ResourceRendererProps {
|
||||
title: string
|
||||
description?: string | null
|
||||
contentJson: unknown // BlockNote PartialBlock[] stored as JSON
|
||||
coverImageUrl?: string | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ResourceRenderer({
|
||||
title,
|
||||
description,
|
||||
contentJson,
|
||||
coverImageUrl,
|
||||
className,
|
||||
}: ResourceRendererProps) {
|
||||
const contentString =
|
||||
typeof contentJson === 'string' ? contentJson : JSON.stringify(contentJson)
|
||||
|
||||
return (
|
||||
<article className={`mx-auto max-w-3xl ${className ?? ''}`}>
|
||||
{/* Cover image */}
|
||||
{coverImageUrl && (
|
||||
<div className="mb-8 overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={coverImageUrl}
|
||||
alt=""
|
||||
className="h-auto w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="mt-3 text-lg text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<hr className="my-6 border-border" />
|
||||
|
||||
{/* Content */}
|
||||
{contentJson ? (
|
||||
<div className="prose-renderer">
|
||||
<BlockViewer content={contentString} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground italic">No content</p>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,11 @@ declare module 'next-auth' {
|
||||
name?: string | null
|
||||
role: UserRole
|
||||
mustSetPassword?: boolean
|
||||
// Impersonation fields
|
||||
isImpersonating?: boolean
|
||||
realUserId?: string
|
||||
realRole?: UserRole
|
||||
impersonatedName?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +29,12 @@ declare module '@auth/core/jwt' {
|
||||
id: string
|
||||
role: UserRole
|
||||
mustSetPassword?: boolean
|
||||
// Impersonation fields
|
||||
impersonatedUserId?: string
|
||||
impersonatedRole?: UserRole
|
||||
impersonatedName?: string | null
|
||||
realUserId?: string
|
||||
realRole?: UserRole
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
],
|
||||
callbacks: {
|
||||
...authConfig.callbacks,
|
||||
async jwt({ token, user, trigger }) {
|
||||
async jwt({ token, user, trigger, session: sessionUpdate }) {
|
||||
// Initial sign in
|
||||
if (user) {
|
||||
token.id = user.id as string
|
||||
@@ -198,15 +198,48 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
token.mustSetPassword = user.mustSetPassword
|
||||
}
|
||||
|
||||
// On session update, refresh from database
|
||||
// On session update
|
||||
if (trigger === 'update') {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: token.id as string },
|
||||
select: { role: true, mustSetPassword: true },
|
||||
})
|
||||
if (dbUser) {
|
||||
token.role = dbUser.role
|
||||
token.mustSetPassword = dbUser.mustSetPassword
|
||||
// Handle impersonation request
|
||||
if (sessionUpdate?.impersonateUserId) {
|
||||
const testUser = await prisma.user.findUnique({
|
||||
where: { id: sessionUpdate.impersonateUserId },
|
||||
select: { id: true, name: true, email: true, role: true, isTest: true },
|
||||
})
|
||||
// Only allow impersonating test users with @test.local emails
|
||||
if (testUser?.isTest && testUser.email.endsWith('@test.local')) {
|
||||
// Preserve original identity (only set once in case of quick-switch)
|
||||
if (!token.realUserId) {
|
||||
token.realUserId = token.id as string
|
||||
token.realRole = token.role as UserRole
|
||||
}
|
||||
token.id = testUser.id
|
||||
token.role = testUser.role
|
||||
token.impersonatedUserId = testUser.id
|
||||
token.impersonatedRole = testUser.role
|
||||
token.impersonatedName = testUser.name
|
||||
}
|
||||
}
|
||||
// Handle stop impersonation
|
||||
else if (sessionUpdate?.stopImpersonation && token.realUserId) {
|
||||
token.id = token.realUserId
|
||||
token.role = token.realRole!
|
||||
delete token.impersonatedUserId
|
||||
delete token.impersonatedRole
|
||||
delete token.impersonatedName
|
||||
delete token.realUserId
|
||||
delete token.realRole
|
||||
}
|
||||
// Normal session refresh (only when not impersonating)
|
||||
else if (!token.impersonatedUserId) {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: token.id as string },
|
||||
select: { role: true, mustSetPassword: true },
|
||||
})
|
||||
if (dbUser) {
|
||||
token.role = dbUser.role
|
||||
token.mustSetPassword = dbUser.mustSetPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +250,15 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
session.user.id = token.id as string
|
||||
session.user.role = token.role as UserRole
|
||||
session.user.mustSetPassword = token.mustSetPassword as boolean | undefined
|
||||
// Impersonation state
|
||||
session.user.isImpersonating = !!token.impersonatedUserId
|
||||
if (token.realUserId) {
|
||||
session.user.realUserId = token.realUserId as string
|
||||
session.user.realRole = token.realRole as UserRole
|
||||
}
|
||||
if (token.impersonatedName !== undefined) {
|
||||
session.user.impersonatedName = token.impersonatedName as string | null
|
||||
}
|
||||
}
|
||||
return session
|
||||
},
|
||||
|
||||
@@ -7,6 +7,32 @@ let cachedTransporter: Transporter | null = null
|
||||
let cachedConfigHash = ''
|
||||
let cachedFrom = ''
|
||||
|
||||
/**
|
||||
* Resolve test email recipients: @test.local emails are redirected
|
||||
* to the admin's email (from test_email_redirect setting) and
|
||||
* the subject is prefixed with [TEST]. Real emails are never affected.
|
||||
*/
|
||||
async function resolveTestEmailRecipient(
|
||||
to: string,
|
||||
subject: string
|
||||
): Promise<{ to: string; subject: string }> {
|
||||
if (!to.endsWith('@test.local')) {
|
||||
return { to, subject }
|
||||
}
|
||||
const redirect = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'test_email_redirect' },
|
||||
select: { value: true },
|
||||
})
|
||||
if (redirect?.value) {
|
||||
return {
|
||||
to: redirect.value,
|
||||
subject: `[TEST] ${subject}`,
|
||||
}
|
||||
}
|
||||
// No redirect configured — suppress the email entirely
|
||||
return { to: '', subject }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SMTP transporter using database settings with env var fallback.
|
||||
* Caches the transporter and rebuilds it when settings change.
|
||||
@@ -47,12 +73,31 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
|
||||
}
|
||||
|
||||
// Create new transporter
|
||||
cachedTransporter = nodemailer.createTransport({
|
||||
const rawTransporter = nodemailer.createTransport({
|
||||
host,
|
||||
port: parseInt(port),
|
||||
secure: port === '465',
|
||||
auth: { user, pass },
|
||||
})
|
||||
|
||||
// Wrap sendMail to auto-redirect @test.local emails
|
||||
const originalSendMail = rawTransporter.sendMail.bind(rawTransporter)
|
||||
rawTransporter.sendMail = async function (mailOptions: any) {
|
||||
if (mailOptions.to && typeof mailOptions.to === 'string') {
|
||||
const resolved = await resolveTestEmailRecipient(
|
||||
mailOptions.to,
|
||||
mailOptions.subject || ''
|
||||
)
|
||||
if (!resolved.to) {
|
||||
// Suppress email entirely (no redirect configured for test)
|
||||
return { messageId: 'suppressed-test-email' }
|
||||
}
|
||||
mailOptions = { ...mailOptions, to: resolved.to, subject: resolved.subject }
|
||||
}
|
||||
return originalSendMail(mailOptions)
|
||||
} as any
|
||||
|
||||
cachedTransporter = rawTransporter
|
||||
cachedConfigHash = configHash
|
||||
cachedFrom = from
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import { roundEngineRouter } from './roundEngine'
|
||||
import { roundAssignmentRouter } from './roundAssignment'
|
||||
import { deliberationRouter } from './deliberation'
|
||||
import { resultLockRouter } from './resultLock'
|
||||
import { testEnvironmentRouter } from './testEnvironment'
|
||||
|
||||
/**
|
||||
* Root tRPC router that combines all domain routers
|
||||
@@ -108,6 +109,8 @@ export const appRouter = router({
|
||||
roundAssignment: roundAssignmentRouter,
|
||||
deliberation: deliberationRouter,
|
||||
resultLock: resultLockRouter,
|
||||
// Test environment
|
||||
testEnvironment: testEnvironmentRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
|
||||
@@ -12,8 +12,8 @@ const editionOrRoundInput = z.object({
|
||||
})
|
||||
|
||||
function projectWhere(input: { roundId?: string; programId?: string }) {
|
||||
if (input.roundId) return { projectRoundStates: { some: { roundId: input.roundId } } }
|
||||
return { programId: input.programId! }
|
||||
if (input.roundId) return { isTest: false, projectRoundStates: { some: { roundId: input.roundId } } }
|
||||
return { isTest: false, programId: input.programId! }
|
||||
}
|
||||
|
||||
function assignmentWhere(input: { roundId?: string; programId?: string }) {
|
||||
@@ -263,7 +263,7 @@ export const analyticsRouter = router({
|
||||
if (round?.roundType === 'EVALUATION') {
|
||||
// For evaluation rounds, break down by evaluation status per project
|
||||
const projects = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
where: { roundId: input.roundId, project: { isTest: false } },
|
||||
select: {
|
||||
projectId: true,
|
||||
project: {
|
||||
@@ -309,7 +309,7 @@ export const analyticsRouter = router({
|
||||
// Non-evaluation rounds: use ProjectRoundState
|
||||
const states = await ctx.prisma.projectRoundState.groupBy({
|
||||
by: ['state'],
|
||||
where: { roundId: input.roundId },
|
||||
where: { roundId: input.roundId, project: { isTest: false } },
|
||||
_count: true,
|
||||
})
|
||||
return states.map((s) => ({
|
||||
@@ -469,8 +469,8 @@ export const analyticsRouter = router({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where = input.roundId
|
||||
? { assignments: { some: { roundId: input.roundId } } }
|
||||
: { programId: input.programId }
|
||||
? { isTest: false, assignments: { some: { roundId: input.roundId } } }
|
||||
: { isTest: false, programId: input.programId }
|
||||
|
||||
const distribution = await ctx.prisma.project.groupBy({
|
||||
by: ['country'],
|
||||
@@ -537,7 +537,7 @@ export const analyticsRouter = router({
|
||||
|
||||
// Count distinct projects per round via assignments
|
||||
const projectAssignments = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: { in: roundIds } },
|
||||
where: { roundId: { in: roundIds }, project: { isTest: false } },
|
||||
select: { roundId: true, projectId: true },
|
||||
distinct: ['roundId', 'projectId'],
|
||||
})
|
||||
@@ -714,12 +714,14 @@ export const analyticsRouter = router({
|
||||
const roundId = input?.roundId
|
||||
|
||||
const projectFilter = roundId
|
||||
? { projectRoundStates: { some: { roundId } } }
|
||||
: {}
|
||||
const assignmentFilter = roundId ? { roundId } : {}
|
||||
? { isTest: false, projectRoundStates: { some: { roundId } } }
|
||||
: { isTest: false }
|
||||
const assignmentFilter = roundId
|
||||
? { roundId }
|
||||
: { round: { competition: { isTest: false } } }
|
||||
const evalFilter = roundId
|
||||
? { assignment: { roundId }, status: 'SUBMITTED' as const }
|
||||
: { status: 'SUBMITTED' as const }
|
||||
: { assignment: { round: { competition: { isTest: false } } }, status: 'SUBMITTED' as const }
|
||||
|
||||
const [
|
||||
programCount,
|
||||
@@ -730,9 +732,9 @@ export const analyticsRouter = router({
|
||||
totalAssignments,
|
||||
evaluationScores,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.program.count(),
|
||||
ctx.prisma.program.count({ where: { isTest: false } }),
|
||||
ctx.prisma.round.findMany({
|
||||
where: { status: 'ROUND_ACTIVE' },
|
||||
where: { status: 'ROUND_ACTIVE', competition: { isTest: false } },
|
||||
select: { id: true, name: true },
|
||||
take: 5,
|
||||
}),
|
||||
@@ -743,7 +745,7 @@ export const analyticsRouter = router({
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
}).then((rows) => rows.length)
|
||||
: ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }),
|
||||
: ctx.prisma.user.count({ where: { isTest: false, role: 'JURY_MEMBER', status: 'ACTIVE' } }),
|
||||
ctx.prisma.evaluation.count({ where: evalFilter }),
|
||||
ctx.prisma.assignment.count({ where: assignmentFilter }),
|
||||
ctx.prisma.evaluation.findMany({
|
||||
@@ -988,7 +990,7 @@ export const analyticsRouter = router({
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {}
|
||||
const where: Record<string, unknown> = { isTest: false }
|
||||
|
||||
if (input.roundId) {
|
||||
where.projectRoundStates = { some: { roundId: input.roundId } }
|
||||
@@ -1151,15 +1153,15 @@ export const analyticsRouter = router({
|
||||
switch (roundType) {
|
||||
case 'INTAKE': {
|
||||
const [total, byState, byCategory] = await Promise.all([
|
||||
ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId, project: { isTest: false } } }),
|
||||
ctx.prisma.projectRoundState.groupBy({
|
||||
by: ['state'],
|
||||
where: { roundId: input.roundId },
|
||||
where: { roundId: input.roundId, project: { isTest: false } },
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.project.groupBy({
|
||||
by: ['competitionCategory'],
|
||||
where: { projectRoundStates: { some: { roundId: input.roundId } } },
|
||||
where: { isTest: false, projectRoundStates: { some: { roundId: input.roundId } } },
|
||||
_count: true,
|
||||
}),
|
||||
])
|
||||
@@ -1395,7 +1397,7 @@ export const analyticsRouter = router({
|
||||
// Get competition rounds for file grouping
|
||||
let competitionRounds: { id: string; name: string; roundType: string }[] = []
|
||||
const competition = await ctx.prisma.competition.findFirst({
|
||||
where: { programId: projectRaw.programId },
|
||||
where: { programId: projectRaw.programId, isTest: false },
|
||||
include: { rounds: { select: { id: true, name: true, roundType: true }, orderBy: { sortOrder: 'asc' } } },
|
||||
})
|
||||
if (competition) {
|
||||
@@ -1478,9 +1480,23 @@ export const analyticsRouter = router({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const limit = input?.limit ?? 10
|
||||
|
||||
// Exclude actions performed by test users
|
||||
const testUserIds = await ctx.prisma.user.findMany({
|
||||
where: { isTest: true },
|
||||
select: { id: true },
|
||||
}).then((users) => users.map((u) => u.id))
|
||||
|
||||
const entries = await ctx.prisma.decisionAuditLog.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
...(testUserIds.length > 0 && {
|
||||
where: {
|
||||
OR: [
|
||||
{ actorId: null },
|
||||
{ actorId: { notIn: testUserIds } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
select: {
|
||||
id: true,
|
||||
eventType: true,
|
||||
@@ -1496,7 +1512,7 @@ export const analyticsRouter = router({
|
||||
const actorIds = [...new Set(entries.map((e) => e.actorId).filter(Boolean))] as string[]
|
||||
const actors = actorIds.length > 0
|
||||
? await ctx.prisma.user.findMany({
|
||||
where: { id: { in: actorIds } },
|
||||
where: { id: { in: actorIds }, isTest: false },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: []
|
||||
|
||||
@@ -7,8 +7,6 @@ import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/em
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { createNotification } from '../services/in-app-notification'
|
||||
import { checkRequirementsAndTransition } from '../services/round-engine'
|
||||
import { EvaluationConfigSchema } from '@/types/competition-configs'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
// Bucket for applicant submissions
|
||||
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
|
||||
@@ -1280,12 +1278,6 @@ export const applicantRouter = router({
|
||||
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
|
||||
const isTeamLead = project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD'
|
||||
|
||||
// Check if project has passed intake
|
||||
const passedIntake = await ctx.prisma.projectRoundState.findFirst({
|
||||
where: { projectId: project.id, state: 'PASSED', round: { roundType: 'INTAKE' } },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
return {
|
||||
project: {
|
||||
...project,
|
||||
@@ -1295,461 +1287,6 @@ export const applicantRouter = router({
|
||||
openRounds,
|
||||
timeline,
|
||||
currentStatus,
|
||||
hasPassedIntake: !!passedIntake,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Lightweight flags for conditional nav rendering.
|
||||
*/
|
||||
getNavFlags: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (ctx.user.role !== 'APPLICANT') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
||||
}
|
||||
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
programId: true,
|
||||
mentorAssignment: { select: { id: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
return { hasMentor: false, hasEvaluationRounds: false }
|
||||
}
|
||||
|
||||
// Check if mentor is assigned
|
||||
const hasMentor = !!project.mentorAssignment
|
||||
|
||||
// Check if there are EVALUATION rounds (CLOSED/ARCHIVED) with applicantVisibility.enabled
|
||||
let hasEvaluationRounds = false
|
||||
if (project.programId) {
|
||||
const closedEvalRounds = await ctx.prisma.round.findMany({
|
||||
where: {
|
||||
competition: { programId: project.programId },
|
||||
roundType: 'EVALUATION',
|
||||
status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
|
||||
},
|
||||
select: { configJson: true },
|
||||
})
|
||||
hasEvaluationRounds = closedEvalRounds.some((r) => {
|
||||
const parsed = EvaluationConfigSchema.safeParse(r.configJson)
|
||||
return parsed.success && parsed.data.applicantVisibility.enabled
|
||||
})
|
||||
}
|
||||
|
||||
return { hasMentor, hasEvaluationRounds }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Filtered competition timeline showing only EVALUATION + Grand Finale.
|
||||
* Hides FILTERING/INTAKE/SUBMISSION/MENTORING from applicants.
|
||||
*/
|
||||
getMyCompetitionTimeline: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (ctx.user.role !== 'APPLICANT') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
||||
}
|
||||
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { id: true, programId: true },
|
||||
})
|
||||
|
||||
if (!project?.programId) {
|
||||
return { competitionName: null, entries: [] }
|
||||
}
|
||||
|
||||
// Find competition via programId (fixes the programId/competitionId bug)
|
||||
const competition = await ctx.prisma.competition.findFirst({
|
||||
where: { programId: project.programId },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
if (!competition) {
|
||||
return { competitionName: null, entries: [] }
|
||||
}
|
||||
|
||||
// Get all rounds ordered by sortOrder
|
||||
const rounds = await ctx.prisma.round.findMany({
|
||||
where: { competitionId: competition.id },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
roundType: true,
|
||||
status: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Get all ProjectRoundState for this project
|
||||
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { projectId: project.id },
|
||||
select: { roundId: true, state: true },
|
||||
})
|
||||
const stateMap = new Map(projectStates.map((ps) => [ps.roundId, ps.state]))
|
||||
|
||||
type TimelineEntry = {
|
||||
id: string
|
||||
label: string
|
||||
roundType: 'EVALUATION' | 'GRAND_FINALE'
|
||||
status: string
|
||||
windowOpenAt: Date | null
|
||||
windowCloseAt: Date | null
|
||||
projectState: string | null
|
||||
isSynthesizedRejection: boolean
|
||||
}
|
||||
|
||||
const entries: TimelineEntry[] = []
|
||||
|
||||
// Build lookup for filtering rounds and their next evaluation round
|
||||
const filteringRounds = rounds.filter((r) => r.roundType === 'FILTERING')
|
||||
const evalRounds = rounds.filter((r) => r.roundType === 'EVALUATION')
|
||||
const liveFinalRounds = rounds.filter((r) => r.roundType === 'LIVE_FINAL')
|
||||
const deliberationRounds = rounds.filter((r) => r.roundType === 'DELIBERATION')
|
||||
|
||||
// Process EVALUATION rounds
|
||||
for (const evalRound of evalRounds) {
|
||||
const actualState = stateMap.get(evalRound.id) ?? null
|
||||
|
||||
// Check if a FILTERING round before this eval round rejected the project
|
||||
let projectState = actualState
|
||||
let isSynthesizedRejection = false
|
||||
|
||||
// Find FILTERING rounds that come before this eval round in sortOrder
|
||||
const evalSortOrder = rounds.findIndex((r) => r.id === evalRound.id)
|
||||
const precedingFilterRounds = filteringRounds.filter((fr) => {
|
||||
const frIdx = rounds.findIndex((r) => r.id === fr.id)
|
||||
return frIdx < evalSortOrder
|
||||
})
|
||||
|
||||
for (const fr of precedingFilterRounds) {
|
||||
const filterState = stateMap.get(fr.id)
|
||||
if (filterState === 'REJECTED') {
|
||||
projectState = 'REJECTED'
|
||||
isSynthesizedRejection = true
|
||||
break
|
||||
}
|
||||
if ((filterState === 'IN_PROGRESS' || filterState === 'PENDING') && !actualState) {
|
||||
projectState = 'IN_PROGRESS'
|
||||
isSynthesizedRejection = true
|
||||
}
|
||||
}
|
||||
|
||||
entries.push({
|
||||
id: evalRound.id,
|
||||
label: evalRound.name,
|
||||
roundType: 'EVALUATION',
|
||||
status: evalRound.status,
|
||||
windowOpenAt: evalRound.windowOpenAt,
|
||||
windowCloseAt: evalRound.windowCloseAt,
|
||||
projectState,
|
||||
isSynthesizedRejection,
|
||||
})
|
||||
}
|
||||
|
||||
// Grand Finale: combine LIVE_FINAL + DELIBERATION
|
||||
if (liveFinalRounds.length > 0 || deliberationRounds.length > 0) {
|
||||
const grandFinaleRounds = [...liveFinalRounds, ...deliberationRounds]
|
||||
|
||||
// Project state: prefer LIVE_FINAL state, then DELIBERATION
|
||||
let gfState: string | null = null
|
||||
for (const lfr of liveFinalRounds) {
|
||||
const s = stateMap.get(lfr.id)
|
||||
if (s) { gfState = s; break }
|
||||
}
|
||||
if (!gfState) {
|
||||
for (const dr of deliberationRounds) {
|
||||
const s = stateMap.get(dr.id)
|
||||
if (s) { gfState = s; break }
|
||||
}
|
||||
}
|
||||
|
||||
// Status: most advanced status among grouped rounds
|
||||
const statusPriority: Record<string, number> = {
|
||||
ROUND_ARCHIVED: 3,
|
||||
ROUND_CLOSED: 2,
|
||||
ROUND_ACTIVE: 1,
|
||||
ROUND_DRAFT: 0,
|
||||
}
|
||||
let gfStatus = 'ROUND_DRAFT'
|
||||
for (const r of grandFinaleRounds) {
|
||||
if ((statusPriority[r.status] ?? 0) > (statusPriority[gfStatus] ?? 0)) {
|
||||
gfStatus = r.status
|
||||
}
|
||||
}
|
||||
|
||||
// Use earliest window open and latest window close
|
||||
const openDates = grandFinaleRounds.map((r) => r.windowOpenAt).filter(Boolean) as Date[]
|
||||
const closeDates = grandFinaleRounds.map((r) => r.windowCloseAt).filter(Boolean) as Date[]
|
||||
|
||||
// Check if a prior filtering rejection should propagate
|
||||
let isSynthesizedRejection = false
|
||||
const gfSortOrder = Math.min(
|
||||
...grandFinaleRounds.map((r) => rounds.findIndex((rr) => rr.id === r.id))
|
||||
)
|
||||
for (const fr of filteringRounds) {
|
||||
const frIdx = rounds.findIndex((r) => r.id === fr.id)
|
||||
if (frIdx < gfSortOrder && stateMap.get(fr.id) === 'REJECTED') {
|
||||
gfState = 'REJECTED'
|
||||
isSynthesizedRejection = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
entries.push({
|
||||
id: 'grand-finale',
|
||||
label: 'Grand Finale',
|
||||
roundType: 'GRAND_FINALE',
|
||||
status: gfStatus,
|
||||
windowOpenAt: openDates.length > 0 ? new Date(Math.min(...openDates.map((d) => d.getTime()))) : null,
|
||||
windowCloseAt: closeDates.length > 0 ? new Date(Math.max(...closeDates.map((d) => d.getTime()))) : null,
|
||||
projectState: gfState,
|
||||
isSynthesizedRejection,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle projects manually created at a non-intake round:
|
||||
// If a project has state in a later round but not earlier, mark prior rounds as PASSED.
|
||||
// Find the earliest visible entry (EVALUATION or GRAND_FINALE) that has a real state.
|
||||
const firstEntryWithState = entries.findIndex(
|
||||
(e) => e.projectState !== null && !e.isSynthesizedRejection
|
||||
)
|
||||
if (firstEntryWithState > 0) {
|
||||
// All entries before the first real state should show as PASSED (if the round is closed/archived)
|
||||
for (let i = 0; i < firstEntryWithState; i++) {
|
||||
const entry = entries[i]
|
||||
if (!entry.projectState) {
|
||||
const roundClosed = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
|
||||
if (roundClosed) {
|
||||
entry.projectState = 'PASSED'
|
||||
entry.isSynthesizedRejection = false // not a rejection, it's a synthesized pass
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the project was rejected in filtering and there are entries after,
|
||||
// null-out states for entries after the rejection point
|
||||
let foundRejection = false
|
||||
for (const entry of entries) {
|
||||
if (foundRejection) {
|
||||
entry.projectState = null
|
||||
}
|
||||
if (entry.projectState === 'REJECTED' && entry.isSynthesizedRejection) {
|
||||
foundRejection = true
|
||||
}
|
||||
}
|
||||
|
||||
return { competitionName: competition.name, entries }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get anonymous jury evaluations visible to the applicant.
|
||||
* Respects per-round applicantVisibility config. NEVER leaks juror identity.
|
||||
*/
|
||||
getMyEvaluations: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (ctx.user.role !== 'APPLICANT') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
||||
}
|
||||
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { id: true, programId: true },
|
||||
})
|
||||
|
||||
if (!project?.programId) return []
|
||||
|
||||
// Get closed/archived EVALUATION rounds for this competition
|
||||
const evalRounds = await ctx.prisma.round.findMany({
|
||||
where: {
|
||||
competition: { programId: project.programId },
|
||||
roundType: 'EVALUATION',
|
||||
status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
configJson: true,
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
const results: Array<{
|
||||
roundId: string
|
||||
roundName: string
|
||||
evaluationCount: number
|
||||
evaluations: Array<{
|
||||
id: string
|
||||
submittedAt: Date | null
|
||||
globalScore: number | null
|
||||
criterionScores: Prisma.JsonValue | null
|
||||
feedbackText: string | null
|
||||
criteria: Prisma.JsonValue | null
|
||||
}>
|
||||
}> = []
|
||||
|
||||
for (const round of evalRounds) {
|
||||
const parsed = EvaluationConfigSchema.safeParse(round.configJson)
|
||||
if (!parsed.success || !parsed.data.applicantVisibility.enabled) continue
|
||||
|
||||
const vis = parsed.data.applicantVisibility
|
||||
|
||||
// Get evaluations via assignments — NEVER select userId or user relation
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: {
|
||||
projectId: project.id,
|
||||
roundId: round.id,
|
||||
},
|
||||
status: { in: ['SUBMITTED', 'LOCKED'] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
submittedAt: true,
|
||||
globalScore: vis.showGlobalScore,
|
||||
criterionScoresJson: vis.showCriterionScores,
|
||||
feedbackText: vis.showFeedbackText,
|
||||
form: vis.showCriterionScores ? { select: { criteriaJson: true } } : false,
|
||||
},
|
||||
orderBy: { submittedAt: 'asc' },
|
||||
})
|
||||
|
||||
results.push({
|
||||
roundId: round.id,
|
||||
roundName: round.name,
|
||||
evaluationCount: evaluations.length,
|
||||
evaluations: evaluations.map((ev) => ({
|
||||
id: ev.id,
|
||||
submittedAt: ev.submittedAt,
|
||||
globalScore: vis.showGlobalScore ? (ev as { globalScore?: number | null }).globalScore ?? null : null,
|
||||
criterionScores: vis.showCriterionScores ? (ev as { criterionScoresJson?: Prisma.JsonValue }).criterionScoresJson ?? null : null,
|
||||
feedbackText: vis.showFeedbackText ? (ev as { feedbackText?: string | null }).feedbackText ?? null : null,
|
||||
criteria: vis.showCriterionScores ? ((ev as { form?: { criteriaJson: Prisma.JsonValue } | null }).form?.criteriaJson ?? null) : null,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}),
|
||||
|
||||
/**
|
||||
* Upcoming deadlines for dashboard card.
|
||||
*/
|
||||
getUpcomingDeadlines: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (ctx.user.role !== 'APPLICANT') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
||||
}
|
||||
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { programId: true },
|
||||
})
|
||||
|
||||
if (!project?.programId) return []
|
||||
|
||||
const now = new Date()
|
||||
const rounds = await ctx.prisma.round.findMany({
|
||||
where: {
|
||||
competition: { programId: project.programId },
|
||||
status: 'ROUND_ACTIVE',
|
||||
windowCloseAt: { gt: now },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
windowCloseAt: true,
|
||||
},
|
||||
orderBy: { windowCloseAt: 'asc' },
|
||||
})
|
||||
|
||||
return rounds.map((r) => ({
|
||||
roundName: r.name,
|
||||
windowCloseAt: r.windowCloseAt!,
|
||||
}))
|
||||
}),
|
||||
|
||||
/**
|
||||
* Document completeness progress for dashboard card.
|
||||
*/
|
||||
getDocumentCompleteness: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (ctx.user.role !== 'APPLICANT') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
||||
}
|
||||
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { id: true, programId: true },
|
||||
})
|
||||
|
||||
if (!project?.programId) return []
|
||||
|
||||
// Find active rounds with file requirements
|
||||
const rounds = await ctx.prisma.round.findMany({
|
||||
where: {
|
||||
competition: { programId: project.programId },
|
||||
status: 'ROUND_ACTIVE',
|
||||
fileRequirements: { some: {} },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
fileRequirements: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
const results: Array<{ roundId: string; roundName: string; required: number; uploaded: number }> = []
|
||||
|
||||
for (const round of rounds) {
|
||||
const requirementIds = round.fileRequirements.map((fr) => fr.id)
|
||||
if (requirementIds.length === 0) continue
|
||||
|
||||
const uploaded = await ctx.prisma.projectFile.count({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
requirementId: { in: requirementIds },
|
||||
},
|
||||
})
|
||||
|
||||
results.push({
|
||||
roundId: round.id,
|
||||
roundName: round.name,
|
||||
required: requirementIds.length,
|
||||
uploaded,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -105,7 +105,7 @@ export const applicationRouter = router({
|
||||
if (input.mode === 'edition') {
|
||||
// Edition-wide application mode
|
||||
const program = await ctx.prisma.program.findFirst({
|
||||
where: { slug: input.slug },
|
||||
where: { slug: input.slug, isTest: false },
|
||||
})
|
||||
|
||||
if (!program) {
|
||||
@@ -687,6 +687,7 @@ export const applicationRouter = router({
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
isDraft: true,
|
||||
isTest: false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -837,6 +838,7 @@ export const applicationRouter = router({
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
isDraft: true,
|
||||
isTest: false,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -560,6 +560,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
status: 'ACTIVE',
|
||||
isTest: false,
|
||||
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
||||
},
|
||||
select: {
|
||||
@@ -1255,6 +1256,7 @@ export const assignmentRouter = router({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
status: 'ACTIVE',
|
||||
isTest: false,
|
||||
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
||||
},
|
||||
select: {
|
||||
@@ -2069,762 +2071,6 @@ export const assignmentRouter = router({
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get transfer candidates: which of the source juror's assignments can be moved,
|
||||
* and which other jurors are eligible to receive them.
|
||||
*/
|
||||
getTransferCandidates: adminProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string(),
|
||||
sourceJurorId: z.string(),
|
||||
assignmentIds: z.array(z.string()),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { id: true, name: true, configJson: true, juryGroupId: true },
|
||||
})
|
||||
|
||||
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||
const fallbackCap =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
|
||||
// Fetch requested assignments — must belong to source juror
|
||||
const requestedAssignments = await ctx.prisma.assignment.findMany({
|
||||
where: {
|
||||
id: { in: input.assignmentIds },
|
||||
roundId: input.roundId,
|
||||
userId: input.sourceJurorId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
project: { select: { title: true } },
|
||||
evaluation: { select: { status: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Filter to movable only
|
||||
const assignments = requestedAssignments.map((a) => ({
|
||||
id: a.id,
|
||||
projectId: a.projectId,
|
||||
projectTitle: a.project.title,
|
||||
evalStatus: a.evaluation?.status ?? null,
|
||||
movable: !a.evaluation || MOVABLE_EVAL_STATUSES.includes(a.evaluation.status as typeof MOVABLE_EVAL_STATUSES[number]),
|
||||
}))
|
||||
|
||||
const movableProjectIds = assignments
|
||||
.filter((a) => a.movable)
|
||||
.map((a) => a.projectId)
|
||||
|
||||
// Build candidate juror pool — same pattern as reassignDroppedJurorAssignments
|
||||
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
||||
|
||||
if (round.juryGroupId) {
|
||||
const members = await ctx.prisma.juryGroupMember.findMany({
|
||||
where: { juryGroupId: round.juryGroupId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } },
|
||||
},
|
||||
})
|
||||
candidateJurors = members
|
||||
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== input.sourceJurorId)
|
||||
.map((m) => m.user)
|
||||
} else {
|
||||
const roundJurorIds = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
})
|
||||
const activeRoundJurorIds = roundJurorIds
|
||||
.map((a) => a.userId)
|
||||
.filter((id) => id !== input.sourceJurorId)
|
||||
|
||||
candidateJurors = activeRoundJurorIds.length > 0
|
||||
? await ctx.prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: activeRoundJurorIds },
|
||||
role: 'JURY_MEMBER',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||
})
|
||||
: []
|
||||
}
|
||||
|
||||
const candidateIds = candidateJurors.map((j) => j.id)
|
||||
|
||||
// Existing assignments, loads, COI pairs
|
||||
const existingAssignments = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
|
||||
const currentLoads = new Map<string, number>()
|
||||
for (const a of existingAssignments) {
|
||||
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
|
||||
|
||||
// Completed evaluations count per candidate
|
||||
const completedEvals = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId, userId: { in: candidateIds } },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
select: { assignment: { select: { userId: true } } },
|
||||
})
|
||||
const completedCounts = new Map<string, number>()
|
||||
for (const e of completedEvals) {
|
||||
const uid = e.assignment.userId
|
||||
completedCounts.set(uid, (completedCounts.get(uid) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
hasConflict: true,
|
||||
userId: { in: candidateIds },
|
||||
},
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
|
||||
|
||||
// Build candidate list with eligibility per project
|
||||
const candidates = candidateJurors.map((j) => {
|
||||
const load = currentLoads.get(j.id) ?? 0
|
||||
const cap = j.maxAssignments ?? fallbackCap
|
||||
const completed = completedCounts.get(j.id) ?? 0
|
||||
const allCompleted = load > 0 && completed === load
|
||||
|
||||
const eligibleProjectIds = movableProjectIds.filter((pid) =>
|
||||
!alreadyAssigned.has(`${j.id}:${pid}`) &&
|
||||
!coiPairs.has(`${j.id}:${pid}`) &&
|
||||
load < cap
|
||||
)
|
||||
|
||||
return {
|
||||
userId: j.id,
|
||||
name: j.name || j.email,
|
||||
email: j.email,
|
||||
currentLoad: load,
|
||||
cap,
|
||||
allCompleted,
|
||||
eligibleProjectIds,
|
||||
}
|
||||
})
|
||||
|
||||
// Sort: not-all-done first, then by lowest load
|
||||
candidates.sort((a, b) => {
|
||||
if (a.allCompleted !== b.allCompleted) return a.allCompleted ? 1 : -1
|
||||
return a.currentLoad - b.currentLoad
|
||||
})
|
||||
|
||||
return { assignments, candidates }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Transfer specific assignments from one juror to destination jurors.
|
||||
*/
|
||||
transferAssignments: adminProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string(),
|
||||
sourceJurorId: z.string(),
|
||||
transfers: z.array(z.object({
|
||||
assignmentId: z.string(),
|
||||
destinationJurorId: z.string(),
|
||||
})),
|
||||
forceOverCap: z.boolean().default(false),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { id: true, name: true, configJson: true, juryGroupId: true },
|
||||
})
|
||||
|
||||
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||
const fallbackCap =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
|
||||
// Verify all assignments belong to source juror and are movable
|
||||
const assignmentIds = input.transfers.map((t) => t.assignmentId)
|
||||
const sourceAssignments = await ctx.prisma.assignment.findMany({
|
||||
where: {
|
||||
id: { in: assignmentIds },
|
||||
roundId: input.roundId,
|
||||
userId: input.sourceJurorId,
|
||||
OR: [
|
||||
{ evaluation: null },
|
||||
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
juryGroupId: true,
|
||||
isRequired: true,
|
||||
project: { select: { title: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const sourceMap = new Map(sourceAssignments.map((a) => [a.id, a]))
|
||||
|
||||
// Build candidate pool data
|
||||
const destinationIds = [...new Set(input.transfers.map((t) => t.destinationJurorId))]
|
||||
const destinationUsers = await ctx.prisma.user.findMany({
|
||||
where: { id: { in: destinationIds } },
|
||||
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||
})
|
||||
const destUserMap = new Map(destinationUsers.map((u) => [u.id, u]))
|
||||
|
||||
const existingAssignments = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
|
||||
const currentLoads = new Map<string, number>()
|
||||
for (const a of existingAssignments) {
|
||||
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
hasConflict: true,
|
||||
userId: { in: destinationIds },
|
||||
},
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
|
||||
|
||||
// Validate each transfer
|
||||
type PlannedMove = {
|
||||
assignmentId: string
|
||||
projectId: string
|
||||
projectTitle: string
|
||||
destinationJurorId: string
|
||||
juryGroupId: string | null
|
||||
isRequired: boolean
|
||||
}
|
||||
const plannedMoves: PlannedMove[] = []
|
||||
const failed: { assignmentId: string; reason: string }[] = []
|
||||
|
||||
for (const transfer of input.transfers) {
|
||||
const assignment = sourceMap.get(transfer.assignmentId)
|
||||
if (!assignment) {
|
||||
failed.push({ assignmentId: transfer.assignmentId, reason: 'Assignment not found or not movable' })
|
||||
continue
|
||||
}
|
||||
|
||||
const destUser = destUserMap.get(transfer.destinationJurorId)
|
||||
if (!destUser) {
|
||||
failed.push({ assignmentId: transfer.assignmentId, reason: 'Destination juror not found' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (alreadyAssigned.has(`${transfer.destinationJurorId}:${assignment.projectId}`)) {
|
||||
failed.push({ assignmentId: transfer.assignmentId, reason: `${destUser.name || destUser.email} is already assigned to this project` })
|
||||
continue
|
||||
}
|
||||
|
||||
if (coiPairs.has(`${transfer.destinationJurorId}:${assignment.projectId}`)) {
|
||||
failed.push({ assignmentId: transfer.assignmentId, reason: `${destUser.name || destUser.email} has a COI with this project` })
|
||||
continue
|
||||
}
|
||||
|
||||
const destCap = destUser.maxAssignments ?? fallbackCap
|
||||
const destLoad = currentLoads.get(transfer.destinationJurorId) ?? 0
|
||||
if (destLoad >= destCap && !input.forceOverCap) {
|
||||
failed.push({ assignmentId: transfer.assignmentId, reason: `${destUser.name || destUser.email} is at cap (${destLoad}/${destCap})` })
|
||||
continue
|
||||
}
|
||||
|
||||
plannedMoves.push({
|
||||
assignmentId: assignment.id,
|
||||
projectId: assignment.projectId,
|
||||
projectTitle: assignment.project.title,
|
||||
destinationJurorId: transfer.destinationJurorId,
|
||||
juryGroupId: assignment.juryGroupId ?? round.juryGroupId,
|
||||
isRequired: assignment.isRequired,
|
||||
})
|
||||
|
||||
// Track updated load for subsequent transfers to same destination
|
||||
alreadyAssigned.add(`${transfer.destinationJurorId}:${assignment.projectId}`)
|
||||
currentLoads.set(transfer.destinationJurorId, destLoad + 1)
|
||||
}
|
||||
|
||||
// Execute in transaction with TOCTOU guard
|
||||
const actualMoves: (PlannedMove & { newAssignmentId: string })[] = []
|
||||
|
||||
if (plannedMoves.length > 0) {
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
for (const move of plannedMoves) {
|
||||
const deleted = await tx.assignment.deleteMany({
|
||||
where: {
|
||||
id: move.assignmentId,
|
||||
userId: input.sourceJurorId,
|
||||
OR: [
|
||||
{ evaluation: null },
|
||||
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
if (deleted.count === 0) {
|
||||
failed.push({ assignmentId: move.assignmentId, reason: 'Assignment was modified concurrently' })
|
||||
continue
|
||||
}
|
||||
|
||||
const created = await tx.assignment.create({
|
||||
data: {
|
||||
roundId: input.roundId,
|
||||
projectId: move.projectId,
|
||||
userId: move.destinationJurorId,
|
||||
juryGroupId: move.juryGroupId ?? undefined,
|
||||
isRequired: move.isRequired,
|
||||
method: 'MANUAL',
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
actualMoves.push({ ...move, newAssignmentId: created.id })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Notify destination jurors
|
||||
if (actualMoves.length > 0) {
|
||||
const destCounts: Record<string, number> = {}
|
||||
for (const move of actualMoves) {
|
||||
destCounts[move.destinationJurorId] = (destCounts[move.destinationJurorId] ?? 0) + 1
|
||||
}
|
||||
|
||||
await createBulkNotifications({
|
||||
userIds: Object.keys(destCounts),
|
||||
type: NotificationTypes.BATCH_ASSIGNED,
|
||||
title: 'Additional Projects Assigned',
|
||||
message: `You have received additional project assignments via transfer in ${round.name}.`,
|
||||
linkUrl: `/jury/competitions`,
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: { roundId: round.id, reason: 'assignment_transfer' },
|
||||
})
|
||||
|
||||
// Notify admins
|
||||
const sourceJuror = await ctx.prisma.user.findUnique({
|
||||
where: { id: input.sourceJurorId },
|
||||
select: { name: true, email: true },
|
||||
})
|
||||
const sourceName = sourceJuror?.name || sourceJuror?.email || 'Unknown'
|
||||
|
||||
const topReceivers = Object.entries(destCounts)
|
||||
.map(([jurorId, count]) => {
|
||||
const u = destUserMap.get(jurorId)
|
||||
return `${u?.name || u?.email || jurorId} (${count})`
|
||||
})
|
||||
.join(', ')
|
||||
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.EVALUATION_MILESTONE,
|
||||
title: 'Assignment Transfer',
|
||||
message: `Transferred ${actualMoves.length} project(s) from ${sourceName} to: ${topReceivers}.${failed.length > 0 ? ` ${failed.length} transfer(s) failed.` : ''}`,
|
||||
linkUrl: `/admin/rounds/${round.id}`,
|
||||
linkLabel: 'View Round',
|
||||
metadata: {
|
||||
roundId: round.id,
|
||||
sourceJurorId: input.sourceJurorId,
|
||||
movedCount: actualMoves.length,
|
||||
failedCount: failed.length,
|
||||
},
|
||||
})
|
||||
|
||||
// Audit
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'ASSIGNMENT_TRANSFER',
|
||||
entityType: 'Round',
|
||||
entityId: round.id,
|
||||
detailsJson: {
|
||||
sourceJurorId: input.sourceJurorId,
|
||||
sourceJurorName: sourceName,
|
||||
movedCount: actualMoves.length,
|
||||
failedCount: failed.length,
|
||||
moves: actualMoves.map((m) => ({
|
||||
projectId: m.projectId,
|
||||
projectTitle: m.projectTitle,
|
||||
newJurorId: m.destinationJurorId,
|
||||
newJurorName: destUserMap.get(m.destinationJurorId)?.name || destUserMap.get(m.destinationJurorId)?.email || m.destinationJurorId,
|
||||
})),
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
succeeded: actualMoves.map((m) => ({
|
||||
assignmentId: m.assignmentId,
|
||||
projectId: m.projectId,
|
||||
destinationJurorId: m.destinationJurorId,
|
||||
})),
|
||||
failed,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Preview the impact of lowering a juror's cap below their current load.
|
||||
*/
|
||||
getOverCapPreview: adminProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string(),
|
||||
jurorId: z.string(),
|
||||
newCap: z.number().int().min(1),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const total = await ctx.prisma.assignment.count({
|
||||
where: { roundId: input.roundId, userId: input.jurorId },
|
||||
})
|
||||
|
||||
const immovableCount = await ctx.prisma.assignment.count({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
userId: input.jurorId,
|
||||
evaluation: { status: { notIn: [...MOVABLE_EVAL_STATUSES] } },
|
||||
},
|
||||
})
|
||||
|
||||
const movableCount = total - immovableCount
|
||||
const overCapCount = Math.max(0, total - input.newCap)
|
||||
|
||||
return {
|
||||
total,
|
||||
overCapCount,
|
||||
movableOverCap: Math.min(overCapCount, movableCount),
|
||||
immovableOverCap: Math.max(0, overCapCount - movableCount),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Redistribute over-cap assignments after lowering a juror's cap.
|
||||
* Moves the newest/least-progressed movable assignments to other eligible jurors.
|
||||
*/
|
||||
redistributeOverCap: adminProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string(),
|
||||
jurorId: z.string(),
|
||||
newCap: z.number().int().min(1),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { id: true, name: true, configJson: true, juryGroupId: true },
|
||||
})
|
||||
|
||||
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||
const fallbackCap =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
|
||||
// Get juror's assignments sorted: null eval first, then DRAFT, newest first
|
||||
const jurorAssignments = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId, userId: input.jurorId },
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
juryGroupId: true,
|
||||
isRequired: true,
|
||||
createdAt: true,
|
||||
project: { select: { title: true } },
|
||||
evaluation: { select: { status: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
const overCapCount = Math.max(0, jurorAssignments.length - input.newCap)
|
||||
if (overCapCount === 0) {
|
||||
return { redistributed: 0, failed: 0, failedProjects: [] as string[], moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[] }
|
||||
}
|
||||
|
||||
// Separate movable and immovable, pick the newest movable ones for redistribution
|
||||
const movable = jurorAssignments.filter(
|
||||
(a) => !a.evaluation || MOVABLE_EVAL_STATUSES.includes(a.evaluation.status as typeof MOVABLE_EVAL_STATUSES[number])
|
||||
)
|
||||
|
||||
// Sort movable: null eval first, then DRAFT, then by createdAt descending (newest first to remove)
|
||||
movable.sort((a, b) => {
|
||||
const statusOrder = (s: string | null) => s === null ? 0 : s === 'NOT_STARTED' ? 1 : s === 'DRAFT' ? 2 : 3
|
||||
const diff = statusOrder(a.evaluation?.status ?? null) - statusOrder(b.evaluation?.status ?? null)
|
||||
if (diff !== 0) return diff
|
||||
return b.createdAt.getTime() - a.createdAt.getTime()
|
||||
})
|
||||
|
||||
const assignmentsToMove = movable.slice(0, overCapCount)
|
||||
|
||||
if (assignmentsToMove.length === 0) {
|
||||
return { redistributed: 0, failed: 0, failedProjects: [] as string[], moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[] }
|
||||
}
|
||||
|
||||
// Build candidate pool — same pattern as reassignDroppedJurorAssignments
|
||||
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
||||
|
||||
if (round.juryGroupId) {
|
||||
const members = await ctx.prisma.juryGroupMember.findMany({
|
||||
where: { juryGroupId: round.juryGroupId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } },
|
||||
},
|
||||
})
|
||||
candidateJurors = members
|
||||
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== input.jurorId)
|
||||
.map((m) => m.user)
|
||||
} else {
|
||||
const roundJurorIds = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
})
|
||||
const activeRoundJurorIds = roundJurorIds
|
||||
.map((a) => a.userId)
|
||||
.filter((id) => id !== input.jurorId)
|
||||
|
||||
candidateJurors = activeRoundJurorIds.length > 0
|
||||
? await ctx.prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: activeRoundJurorIds },
|
||||
role: 'JURY_MEMBER',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||
})
|
||||
: []
|
||||
}
|
||||
|
||||
if (candidateJurors.length === 0) {
|
||||
return {
|
||||
redistributed: 0,
|
||||
failed: assignmentsToMove.length,
|
||||
failedProjects: assignmentsToMove.map((a) => a.project.title),
|
||||
moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[],
|
||||
}
|
||||
}
|
||||
|
||||
const candidateIds = candidateJurors.map((j) => j.id)
|
||||
|
||||
const existingAssignments = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
|
||||
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
|
||||
const currentLoads = new Map<string, number>()
|
||||
for (const a of existingAssignments) {
|
||||
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
hasConflict: true,
|
||||
userId: { in: candidateIds },
|
||||
},
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
|
||||
|
||||
const caps = new Map<string, number>()
|
||||
for (const juror of candidateJurors) {
|
||||
caps.set(juror.id, juror.maxAssignments ?? fallbackCap)
|
||||
}
|
||||
|
||||
// Check which candidates have completed all their evaluations
|
||||
const completedEvals = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId, userId: { in: candidateIds } },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
select: { assignment: { select: { userId: true } } },
|
||||
})
|
||||
const completedCounts = new Map<string, number>()
|
||||
for (const e of completedEvals) {
|
||||
completedCounts.set(e.assignment.userId, (completedCounts.get(e.assignment.userId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))
|
||||
|
||||
type PlannedMove = {
|
||||
assignmentId: string
|
||||
projectId: string
|
||||
projectTitle: string
|
||||
newJurorId: string
|
||||
juryGroupId: string | null
|
||||
isRequired: boolean
|
||||
}
|
||||
const plannedMoves: PlannedMove[] = []
|
||||
const failedProjects: string[] = []
|
||||
|
||||
for (const assignment of assignmentsToMove) {
|
||||
const eligible = candidateIds
|
||||
.filter((jurorId) => !alreadyAssigned.has(`${jurorId}:${assignment.projectId}`))
|
||||
.filter((jurorId) => !coiPairs.has(`${jurorId}:${assignment.projectId}`))
|
||||
.filter((jurorId) => (currentLoads.get(jurorId) ?? 0) < (caps.get(jurorId) ?? fallbackCap))
|
||||
.sort((a, b) => {
|
||||
// Prefer jurors who haven't completed all their work
|
||||
const aLoad = currentLoads.get(a) ?? 0
|
||||
const bLoad = currentLoads.get(b) ?? 0
|
||||
const aComplete = aLoad > 0 && (completedCounts.get(a) ?? 0) === aLoad
|
||||
const bComplete = bLoad > 0 && (completedCounts.get(b) ?? 0) === bLoad
|
||||
if (aComplete !== bComplete) return aComplete ? 1 : -1
|
||||
const loadDiff = aLoad - bLoad
|
||||
if (loadDiff !== 0) return loadDiff
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
if (eligible.length === 0) {
|
||||
failedProjects.push(assignment.project.title)
|
||||
continue
|
||||
}
|
||||
|
||||
const selectedJurorId = eligible[0]
|
||||
plannedMoves.push({
|
||||
assignmentId: assignment.id,
|
||||
projectId: assignment.projectId,
|
||||
projectTitle: assignment.project.title,
|
||||
newJurorId: selectedJurorId,
|
||||
juryGroupId: assignment.juryGroupId ?? round.juryGroupId,
|
||||
isRequired: assignment.isRequired,
|
||||
})
|
||||
|
||||
alreadyAssigned.add(`${selectedJurorId}:${assignment.projectId}`)
|
||||
currentLoads.set(selectedJurorId, (currentLoads.get(selectedJurorId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
// Execute in transaction with TOCTOU guard
|
||||
const actualMoves: PlannedMove[] = []
|
||||
|
||||
if (plannedMoves.length > 0) {
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
for (const move of plannedMoves) {
|
||||
const deleted = await tx.assignment.deleteMany({
|
||||
where: {
|
||||
id: move.assignmentId,
|
||||
userId: input.jurorId,
|
||||
OR: [
|
||||
{ evaluation: null },
|
||||
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
if (deleted.count === 0) {
|
||||
failedProjects.push(move.projectTitle)
|
||||
continue
|
||||
}
|
||||
|
||||
await tx.assignment.create({
|
||||
data: {
|
||||
roundId: input.roundId,
|
||||
projectId: move.projectId,
|
||||
userId: move.newJurorId,
|
||||
juryGroupId: move.juryGroupId ?? undefined,
|
||||
isRequired: move.isRequired,
|
||||
method: 'MANUAL',
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
actualMoves.push(move)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Notify destination jurors
|
||||
if (actualMoves.length > 0) {
|
||||
const destCounts: Record<string, number> = {}
|
||||
for (const move of actualMoves) {
|
||||
destCounts[move.newJurorId] = (destCounts[move.newJurorId] ?? 0) + 1
|
||||
}
|
||||
|
||||
await createBulkNotifications({
|
||||
userIds: Object.keys(destCounts),
|
||||
type: NotificationTypes.BATCH_ASSIGNED,
|
||||
title: 'Additional Projects Assigned',
|
||||
message: `You have received additional project assignments due to a cap adjustment in ${round.name}.`,
|
||||
linkUrl: `/jury/competitions`,
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: { roundId: round.id, reason: 'cap_redistribute' },
|
||||
})
|
||||
|
||||
const juror = await ctx.prisma.user.findUnique({
|
||||
where: { id: input.jurorId },
|
||||
select: { name: true, email: true },
|
||||
})
|
||||
const jurorName = juror?.name || juror?.email || 'Unknown'
|
||||
|
||||
const topReceivers = Object.entries(destCounts)
|
||||
.map(([jurorId, count]) => {
|
||||
const u = candidateMeta.get(jurorId)
|
||||
return `${u?.name || u?.email || jurorId} (${count})`
|
||||
})
|
||||
.join(', ')
|
||||
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.EVALUATION_MILESTONE,
|
||||
title: 'Cap Redistribution',
|
||||
message: `Redistributed ${actualMoves.length} project(s) from ${jurorName} (cap lowered to ${input.newCap}) to: ${topReceivers}.${failedProjects.length > 0 ? ` ${failedProjects.length} project(s) could not be reassigned.` : ''}`,
|
||||
linkUrl: `/admin/rounds/${round.id}`,
|
||||
linkLabel: 'View Round',
|
||||
metadata: {
|
||||
roundId: round.id,
|
||||
jurorId: input.jurorId,
|
||||
newCap: input.newCap,
|
||||
movedCount: actualMoves.length,
|
||||
failedCount: failedProjects.length,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CAP_REDISTRIBUTE',
|
||||
entityType: 'Round',
|
||||
entityId: round.id,
|
||||
detailsJson: {
|
||||
jurorId: input.jurorId,
|
||||
jurorName,
|
||||
newCap: input.newCap,
|
||||
movedCount: actualMoves.length,
|
||||
failedCount: failedProjects.length,
|
||||
failedProjects,
|
||||
moves: actualMoves.map((m) => ({
|
||||
projectId: m.projectId,
|
||||
projectTitle: m.projectTitle,
|
||||
newJurorId: m.newJurorId,
|
||||
newJurorName: candidateMeta.get(m.newJurorId)?.name || candidateMeta.get(m.newJurorId)?.email || m.newJurorId,
|
||||
})),
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
redistributed: actualMoves.length,
|
||||
failed: failedProjects.length,
|
||||
failedProjects,
|
||||
moves: actualMoves.map((m) => ({
|
||||
projectId: m.projectId,
|
||||
projectTitle: m.projectTitle,
|
||||
newJurorId: m.newJurorId,
|
||||
newJurorName: candidateMeta.get(m.newJurorId)?.name || candidateMeta.get(m.newJurorId)?.email || m.newJurorId,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get reshuffle history for a round — shows all dropout/COI reassignment events
|
||||
* with per-project detail of where each project was moved to.
|
||||
@@ -2836,7 +2082,7 @@ export const assignmentRouter = router({
|
||||
const auditEntries = await ctx.prisma.auditLog.findMany({
|
||||
where: {
|
||||
entityType: { in: ['Round', 'Assignment'] },
|
||||
action: { in: ['JUROR_DROPOUT_RESHUFFLE', 'COI_REASSIGNMENT', 'ASSIGNMENT_TRANSFER', 'CAP_REDISTRIBUTE'] },
|
||||
action: { in: ['JUROR_DROPOUT_RESHUFFLE', 'COI_REASSIGNMENT'] },
|
||||
entityId: input.roundId,
|
||||
},
|
||||
orderBy: { timestamp: 'desc' },
|
||||
@@ -2880,7 +2126,7 @@ export const assignmentRouter = router({
|
||||
|
||||
type ReshuffleEvent = {
|
||||
id: string
|
||||
type: 'DROPOUT' | 'COI' | 'TRANSFER' | 'CAP_REDISTRIBUTE'
|
||||
type: 'DROPOUT' | 'COI'
|
||||
timestamp: Date
|
||||
performedBy: { name: string | null; email: string }
|
||||
droppedJuror: { id: string; name: string }
|
||||
@@ -2935,44 +2181,6 @@ export const assignmentRouter = router({
|
||||
failedProjects: (details.failedProjects as string[]) || [],
|
||||
moves: reconstructedMoves,
|
||||
})
|
||||
} else if (entry.action === 'ASSIGNMENT_TRANSFER') {
|
||||
const moves = (details.moves as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]) || []
|
||||
events.push({
|
||||
id: entry.id,
|
||||
type: 'TRANSFER',
|
||||
timestamp: entry.timestamp,
|
||||
performedBy: {
|
||||
name: entry.user?.name ?? null,
|
||||
email: entry.user?.email ?? '',
|
||||
},
|
||||
droppedJuror: {
|
||||
id: (details.sourceJurorId as string) || '',
|
||||
name: (details.sourceJurorName as string) || 'Unknown',
|
||||
},
|
||||
movedCount: (details.movedCount as number) || 0,
|
||||
failedCount: (details.failedCount as number) || 0,
|
||||
failedProjects: (details.failedProjects as string[]) || [],
|
||||
moves,
|
||||
})
|
||||
} else if (entry.action === 'CAP_REDISTRIBUTE') {
|
||||
const moves = (details.moves as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]) || []
|
||||
events.push({
|
||||
id: entry.id,
|
||||
type: 'CAP_REDISTRIBUTE',
|
||||
timestamp: entry.timestamp,
|
||||
performedBy: {
|
||||
name: entry.user?.name ?? null,
|
||||
email: entry.user?.email ?? '',
|
||||
},
|
||||
droppedJuror: {
|
||||
id: (details.jurorId as string) || '',
|
||||
name: (details.jurorName as string) || 'Unknown',
|
||||
},
|
||||
movedCount: (details.movedCount as number) || 0,
|
||||
failedCount: (details.failedCount as number) || 0,
|
||||
failedProjects: (details.failedProjects as string[]) || [],
|
||||
moves,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ export const competitionRouter = router({
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.competition.findMany({
|
||||
where: { programId: input.programId },
|
||||
where: { programId: input.programId, isTest: false },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: {
|
||||
@@ -254,7 +254,7 @@ export const competitionRouter = router({
|
||||
const competitionIds = [...new Set(memberships.map((m) => m.juryGroup.competitionId))]
|
||||
if (competitionIds.length === 0) return []
|
||||
return ctx.prisma.competition.findMany({
|
||||
where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' } },
|
||||
where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' }, isTest: false },
|
||||
include: {
|
||||
rounds: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
|
||||
@@ -172,18 +172,19 @@ export const dashboardRouter = router({
|
||||
|
||||
// 7. Project count
|
||||
ctx.prisma.project.count({
|
||||
where: { programId: editionId },
|
||||
where: { programId: editionId, isTest: false },
|
||||
}),
|
||||
|
||||
// 8. New projects this week
|
||||
ctx.prisma.project.count({
|
||||
where: { programId: editionId, createdAt: { gte: sevenDaysAgo } },
|
||||
where: { programId: editionId, isTest: false, createdAt: { gte: sevenDaysAgo } },
|
||||
}),
|
||||
|
||||
// 9. Total jurors
|
||||
ctx.prisma.user.count({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
isTest: false,
|
||||
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
|
||||
assignments: { some: { round: { competition: { programId: editionId } } } },
|
||||
},
|
||||
@@ -193,6 +194,7 @@ export const dashboardRouter = router({
|
||||
ctx.prisma.user.count({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
isTest: false,
|
||||
status: 'ACTIVE',
|
||||
assignments: { some: { round: { competition: { programId: editionId } } } },
|
||||
},
|
||||
@@ -212,7 +214,7 @@ export const dashboardRouter = router({
|
||||
|
||||
// 13. Latest projects
|
||||
ctx.prisma.project.findMany({
|
||||
where: { programId: editionId },
|
||||
where: { programId: editionId, isTest: false },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 8,
|
||||
select: {
|
||||
@@ -232,20 +234,20 @@ export const dashboardRouter = router({
|
||||
// 14. Category breakdown
|
||||
ctx.prisma.project.groupBy({
|
||||
by: ['competitionCategory'],
|
||||
where: { programId: editionId },
|
||||
where: { programId: editionId, isTest: false },
|
||||
_count: true,
|
||||
}),
|
||||
|
||||
// 15. Ocean issue breakdown
|
||||
ctx.prisma.project.groupBy({
|
||||
by: ['oceanIssue'],
|
||||
where: { programId: editionId },
|
||||
where: { programId: editionId, isTest: false },
|
||||
_count: true,
|
||||
}),
|
||||
|
||||
// 16. Recent activity
|
||||
// 16. Recent activity (exclude test user actions)
|
||||
ctx.prisma.auditLog.findMany({
|
||||
where: { timestamp: { gte: sevenDaysAgo } },
|
||||
where: { timestamp: { gte: sevenDaysAgo }, user: { isTest: false } },
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: 8,
|
||||
select: {
|
||||
|
||||
@@ -105,6 +105,7 @@ export const exportRouter = router({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
isTest: false,
|
||||
assignments: { some: { roundId: input.roundId } },
|
||||
},
|
||||
include: {
|
||||
@@ -355,7 +356,7 @@ export const exportRouter = router({
|
||||
}
|
||||
|
||||
const logs = await ctx.prisma.auditLog.findMany({
|
||||
where,
|
||||
where: { ...where, user: { isTest: false } },
|
||||
orderBy: { timestamp: 'desc' },
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
@@ -431,7 +432,7 @@ export const exportRouter = router({
|
||||
if (includeSection('summary')) {
|
||||
const [projectCount, assignmentCount, evaluationCount, jurorCount] = await Promise.all([
|
||||
ctx.prisma.project.count({
|
||||
where: { assignments: { some: { roundId: input.roundId } } },
|
||||
where: { isTest: false, assignments: { some: { roundId: input.roundId } } },
|
||||
}),
|
||||
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.evaluation.count({
|
||||
@@ -486,7 +487,7 @@ export const exportRouter = router({
|
||||
// Rankings
|
||||
if (includeSection('rankings')) {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { assignments: { some: { roundId: input.roundId } } },
|
||||
where: { isTest: false, assignments: { some: { roundId: input.roundId } } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
|
||||
@@ -994,6 +994,7 @@ export const fileRouter = router({
|
||||
// Build project filter
|
||||
const projectWhere: Record<string, unknown> = {
|
||||
programId: window.competition.programId,
|
||||
isTest: false,
|
||||
}
|
||||
if (input.search) {
|
||||
projectWhere.OR = [
|
||||
@@ -1303,6 +1304,7 @@ export const fileRouter = router({
|
||||
// Build project filter
|
||||
const projectWhere: Record<string, unknown> = {
|
||||
programId: round.competition.programId,
|
||||
isTest: false,
|
||||
}
|
||||
if (input.search) {
|
||||
projectWhere.OR = [
|
||||
|
||||
@@ -115,6 +115,7 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
||||
roundId,
|
||||
exitedAt: null,
|
||||
state: { in: ['PENDING', 'IN_PROGRESS'] },
|
||||
project: { isTest: false },
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import {
|
||||
router,
|
||||
protectedProcedure,
|
||||
@@ -12,69 +11,6 @@ import { logAudit } from '../utils/audit'
|
||||
// Bucket for learning resources
|
||||
export const LEARNING_BUCKET = 'mopc-learning'
|
||||
|
||||
// Access rule schema for fine-grained access control
|
||||
const accessRuleSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('everyone') }),
|
||||
z.object({ type: z.literal('roles'), roles: z.array(z.string()) }),
|
||||
z.object({ type: z.literal('jury_group'), juryGroupIds: z.array(z.string()) }),
|
||||
z.object({ type: z.literal('round'), roundIds: z.array(z.string()) }),
|
||||
])
|
||||
|
||||
type AccessRule = z.infer<typeof accessRuleSchema>
|
||||
|
||||
/**
|
||||
* Evaluate whether a user can access a resource based on its accessJson rules.
|
||||
* null/empty = everyone. Rules are OR-combined (match ANY rule = access).
|
||||
*/
|
||||
async function canUserAccessResource(
|
||||
prisma: { juryGroupMember: { findFirst: Function }; assignment: { findFirst: Function } },
|
||||
userId: string,
|
||||
userRole: string,
|
||||
accessJson: unknown,
|
||||
): Promise<boolean> {
|
||||
// null or empty = everyone
|
||||
if (!accessJson) return true
|
||||
|
||||
let rules: AccessRule[]
|
||||
try {
|
||||
const parsed = accessJson as unknown[]
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) return true
|
||||
rules = parsed as AccessRule[]
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.type === 'everyone') return true
|
||||
|
||||
if (rule.type === 'roles') {
|
||||
if (rule.roles.includes(userRole)) return true
|
||||
}
|
||||
|
||||
if (rule.type === 'jury_group') {
|
||||
const membership = await prisma.juryGroupMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
juryGroupId: { in: rule.juryGroupIds },
|
||||
},
|
||||
})
|
||||
if (membership) return true
|
||||
}
|
||||
|
||||
if (rule.type === 'round') {
|
||||
const assignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
roundId: { in: rule.roundIds },
|
||||
},
|
||||
})
|
||||
if (assignment) return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const learningResourceRouter = router({
|
||||
/**
|
||||
* List all resources (admin view)
|
||||
@@ -83,9 +19,11 @@ export const learningResourceRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string().optional(),
|
||||
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(),
|
||||
cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).optional(),
|
||||
isPublished: z.boolean().optional(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
perPage: z.number().int().min(1).max(100).default(50),
|
||||
perPage: z.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
@@ -94,6 +32,12 @@ export const learningResourceRouter = router({
|
||||
if (input.programId !== undefined) {
|
||||
where.programId = input.programId
|
||||
}
|
||||
if (input.resourceType) {
|
||||
where.resourceType = input.resourceType
|
||||
}
|
||||
if (input.cohortLevel) {
|
||||
where.cohortLevel = input.cohortLevel
|
||||
}
|
||||
if (input.isPublished !== undefined) {
|
||||
where.isPublished = input.isPublished
|
||||
}
|
||||
@@ -123,41 +67,71 @@ export const learningResourceRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get resources accessible to the current user (jury/mentor/observer view)
|
||||
* Get resources accessible to the current user (jury view)
|
||||
*/
|
||||
myResources: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string().optional(),
|
||||
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Determine user's cohort level based on their assignments
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: { userId: ctx.user.id },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Determine highest cohort level
|
||||
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
|
||||
for (const assignment of assignments) {
|
||||
const projectStatus = assignment.project.status
|
||||
if (projectStatus === 'FINALIST') {
|
||||
userCohortLevel = 'FINALIST'
|
||||
break
|
||||
}
|
||||
if (projectStatus === 'SEMIFINALIST') {
|
||||
userCohortLevel = 'SEMIFINALIST'
|
||||
}
|
||||
}
|
||||
|
||||
// Build query based on cohort level
|
||||
const cohortLevels = ['ALL']
|
||||
if (userCohortLevel === 'SEMIFINALIST' || userCohortLevel === 'FINALIST') {
|
||||
cohortLevels.push('SEMIFINALIST')
|
||||
}
|
||||
if (userCohortLevel === 'FINALIST') {
|
||||
cohortLevels.push('FINALIST')
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
isPublished: true,
|
||||
cohortLevel: { in: cohortLevels },
|
||||
}
|
||||
|
||||
if (input.programId) {
|
||||
where.OR = [{ programId: input.programId }, { programId: null }]
|
||||
}
|
||||
if (input.resourceType) {
|
||||
where.resourceType = input.resourceType
|
||||
}
|
||||
|
||||
const resources = await ctx.prisma.learningResource.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
|
||||
// Filter by access rules in application code (small dataset)
|
||||
const accessible = []
|
||||
for (const resource of resources) {
|
||||
const allowed = await canUserAccessResource(
|
||||
ctx.prisma,
|
||||
ctx.user.id,
|
||||
ctx.user.role,
|
||||
resource.accessJson,
|
||||
)
|
||||
if (allowed) accessible.push(resource)
|
||||
return {
|
||||
resources,
|
||||
userCohortLevel,
|
||||
}
|
||||
|
||||
return { resources: accessible }
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -175,8 +149,7 @@ export const learningResourceRouter = router({
|
||||
})
|
||||
|
||||
// Check access for non-admins
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
if (!isAdmin) {
|
||||
if (ctx.user.role === 'JURY_MEMBER') {
|
||||
if (!resource.isPublished) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
@@ -184,13 +157,39 @@ export const learningResourceRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
const allowed = await canUserAccessResource(
|
||||
ctx.prisma,
|
||||
ctx.user.id,
|
||||
ctx.user.role,
|
||||
resource.accessJson,
|
||||
)
|
||||
if (!allowed) {
|
||||
// Check cohort level access
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: { userId: ctx.user.id },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
|
||||
for (const assignment of assignments) {
|
||||
const projectStatus = assignment.project.status
|
||||
if (projectStatus === 'FINALIST') {
|
||||
userCohortLevel = 'FINALIST'
|
||||
break
|
||||
}
|
||||
if (projectStatus === 'SEMIFINALIST') {
|
||||
userCohortLevel = 'SEMIFINALIST'
|
||||
}
|
||||
}
|
||||
|
||||
const accessibleLevels = ['ALL']
|
||||
if (userCohortLevel === 'SEMIFINALIST' || userCohortLevel === 'FINALIST') {
|
||||
accessibleLevels.push('SEMIFINALIST')
|
||||
}
|
||||
if (userCohortLevel === 'FINALIST') {
|
||||
accessibleLevels.push('FINALIST')
|
||||
}
|
||||
|
||||
if (!accessibleLevels.includes(resource.cohortLevel)) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have access to this resource',
|
||||
@@ -203,6 +202,7 @@ export const learningResourceRouter = router({
|
||||
|
||||
/**
|
||||
* Get download URL for a resource file
|
||||
* Checks cohort level access for non-admin users
|
||||
*/
|
||||
getDownloadUrl: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
@@ -228,13 +228,39 @@ export const learningResourceRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
const allowed = await canUserAccessResource(
|
||||
ctx.prisma,
|
||||
ctx.user.id,
|
||||
ctx.user.role,
|
||||
resource.accessJson,
|
||||
)
|
||||
if (!allowed) {
|
||||
// Check cohort level access
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: { userId: ctx.user.id },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
|
||||
for (const assignment of assignments) {
|
||||
const projectStatus = assignment.project.status
|
||||
if (projectStatus === 'FINALIST') {
|
||||
userCohortLevel = 'FINALIST'
|
||||
break
|
||||
}
|
||||
if (projectStatus === 'SEMIFINALIST') {
|
||||
userCohortLevel = 'SEMIFINALIST'
|
||||
}
|
||||
}
|
||||
|
||||
const accessibleLevels = ['ALL']
|
||||
if (userCohortLevel === 'SEMIFINALIST' || userCohortLevel === 'FINALIST') {
|
||||
accessibleLevels.push('SEMIFINALIST')
|
||||
}
|
||||
if (userCohortLevel === 'FINALIST') {
|
||||
accessibleLevels.push('FINALIST')
|
||||
}
|
||||
|
||||
if (!accessibleLevels.includes(resource.cohortLevel)) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have access to this resource',
|
||||
@@ -255,22 +281,6 @@ export const learningResourceRouter = router({
|
||||
return { url }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Log access when user opens a resource detail page
|
||||
*/
|
||||
logAccess: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.resourceAccess.create({
|
||||
data: {
|
||||
resourceId: input.id,
|
||||
userId: ctx.user.id,
|
||||
ipAddress: ctx.ip,
|
||||
},
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a new resource (admin only)
|
||||
*/
|
||||
@@ -281,9 +291,9 @@ export const learningResourceRouter = router({
|
||||
title: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
contentJson: z.any().optional(), // BlockNote document structure
|
||||
accessJson: accessRuleSchema.array().nullable().optional(),
|
||||
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']),
|
||||
cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).default('ALL'),
|
||||
externalUrl: z.string().url().optional(),
|
||||
coverImageKey: z.string().optional(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
isPublished: z.boolean().default(false),
|
||||
// File info (set after upload)
|
||||
@@ -295,12 +305,9 @@ export const learningResourceRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { accessJson, ...rest } = input
|
||||
|
||||
const resource = await ctx.prisma.learningResource.create({
|
||||
data: {
|
||||
...rest,
|
||||
accessJson: accessJson === null ? Prisma.JsonNull : accessJson ?? undefined,
|
||||
...input,
|
||||
createdById: ctx.user.id,
|
||||
},
|
||||
})
|
||||
@@ -312,7 +319,7 @@ export const learningResourceRouter = router({
|
||||
action: 'CREATE',
|
||||
entityType: 'LearningResource',
|
||||
entityId: resource.id,
|
||||
detailsJson: { title: input.title },
|
||||
detailsJson: { title: input.title, resourceType: input.resourceType },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
@@ -328,12 +335,11 @@ export const learningResourceRouter = router({
|
||||
z.object({
|
||||
id: z.string(),
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
description: z.string().optional(),
|
||||
contentJson: z.any().optional(), // BlockNote document structure
|
||||
accessJson: accessRuleSchema.array().nullable().optional(),
|
||||
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(),
|
||||
cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).optional(),
|
||||
externalUrl: z.string().url().optional().nullable(),
|
||||
coverImageKey: z.string().optional().nullable(),
|
||||
programId: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isPublished: z.boolean().optional(),
|
||||
// File info (set after upload)
|
||||
@@ -345,15 +351,7 @@ export const learningResourceRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, accessJson, ...rest } = input
|
||||
|
||||
// Prisma requires Prisma.JsonNull for nullable JSON fields instead of raw null
|
||||
const data = {
|
||||
...rest,
|
||||
...(accessJson !== undefined && {
|
||||
accessJson: accessJson === null ? Prisma.JsonNull : accessJson,
|
||||
}),
|
||||
}
|
||||
const { id, ...data } = input
|
||||
|
||||
const resource = await ctx.prisma.learningResource.update({
|
||||
where: { id },
|
||||
@@ -367,7 +365,7 @@ export const learningResourceRouter = router({
|
||||
action: 'UPDATE',
|
||||
entityType: 'LearningResource',
|
||||
entityId: id,
|
||||
detailsJson: rest,
|
||||
detailsJson: data,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
@@ -420,6 +420,7 @@ export const mentorRouter = router({
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
programId: input.programId,
|
||||
isTest: false,
|
||||
mentorAssignment: null,
|
||||
wantsMentorship: true,
|
||||
},
|
||||
|
||||
@@ -402,7 +402,7 @@ async function resolveRecipients(
|
||||
const role = filter?.role as string
|
||||
if (!role) return []
|
||||
const users = await prisma.user.findMany({
|
||||
where: { role: role as any, status: 'ACTIVE' },
|
||||
where: { role: role as any, status: 'ACTIVE', isTest: false },
|
||||
select: { id: true },
|
||||
})
|
||||
return users.map((u) => u.id)
|
||||
@@ -412,7 +412,7 @@ async function resolveRecipients(
|
||||
const targetRoundId = roundId || (filter?.roundId as string)
|
||||
if (!targetRoundId) return []
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: { roundId: targetRoundId },
|
||||
where: { roundId: targetRoundId, user: { isTest: false } },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
})
|
||||
@@ -423,7 +423,7 @@ async function resolveRecipients(
|
||||
const programId = filter?.programId as string
|
||||
if (!programId) return []
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { programId },
|
||||
where: { programId, isTest: false },
|
||||
select: { submittedByUserId: true },
|
||||
})
|
||||
const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[])
|
||||
@@ -432,7 +432,7 @@ async function resolveRecipients(
|
||||
|
||||
case 'ALL': {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
where: { status: 'ACTIVE', isTest: false },
|
||||
select: { id: true },
|
||||
})
|
||||
return users.map((u) => u.id)
|
||||
|
||||
@@ -22,7 +22,7 @@ export const programRouter = router({
|
||||
const includeStages = input?.includeStages || false
|
||||
|
||||
const programs = await ctx.prisma.program.findMany({
|
||||
where: input?.status ? { status: input.status } : undefined,
|
||||
where: input?.status ? { isTest: false, status: input.status } : { isTest: false },
|
||||
orderBy: { year: 'desc' },
|
||||
include: includeStages
|
||||
? {
|
||||
|
||||
@@ -103,6 +103,7 @@ export const projectPoolRouter = router({
|
||||
|
||||
// Build where clause
|
||||
const where: Record<string, unknown> = {
|
||||
isTest: false,
|
||||
programId,
|
||||
}
|
||||
|
||||
@@ -317,6 +318,7 @@ export const projectPoolRouter = router({
|
||||
|
||||
// Find projects to assign
|
||||
const where: Record<string, unknown> = {
|
||||
isTest: false,
|
||||
programId,
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,9 @@ export const projectRouter = router({
|
||||
const skip = (page - 1) * perPage
|
||||
|
||||
// Build where clause
|
||||
const where: Record<string, unknown> = {}
|
||||
const where: Record<string, unknown> = {
|
||||
isTest: false,
|
||||
}
|
||||
|
||||
// Filter by program
|
||||
if (programId) where.programId = programId
|
||||
@@ -219,7 +221,9 @@ export const projectRouter = router({
|
||||
wantsMentorship, hasFiles, hasAssignments,
|
||||
} = input
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
const where: Record<string, unknown> = {
|
||||
isTest: false,
|
||||
}
|
||||
|
||||
if (programId) where.programId = programId
|
||||
if (roundId) {
|
||||
@@ -357,19 +361,19 @@ export const projectRouter = router({
|
||||
.query(async ({ ctx }) => {
|
||||
const [countries, categories, issues] = await Promise.all([
|
||||
ctx.prisma.project.findMany({
|
||||
where: { country: { not: null } },
|
||||
where: { isTest: false, country: { not: null } },
|
||||
select: { country: true },
|
||||
distinct: ['country'],
|
||||
orderBy: { country: 'asc' },
|
||||
}),
|
||||
ctx.prisma.project.groupBy({
|
||||
by: ['competitionCategory'],
|
||||
where: { competitionCategory: { not: null } },
|
||||
where: { isTest: false, competitionCategory: { not: null } },
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.project.groupBy({
|
||||
by: ['oceanIssue'],
|
||||
where: { oceanIssue: { not: null } },
|
||||
where: { isTest: false, oceanIssue: { not: null } },
|
||||
_count: true,
|
||||
}),
|
||||
])
|
||||
@@ -838,7 +842,7 @@ export const projectRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: input.ids } },
|
||||
where: { id: { in: input.ids }, isTest: false },
|
||||
select: { id: true, title: true, status: true },
|
||||
})
|
||||
|
||||
@@ -948,11 +952,13 @@ export const projectRouter = router({
|
||||
programId: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {}
|
||||
const where: Record<string, unknown> = {
|
||||
isTest: false,
|
||||
}
|
||||
if (input.programId) where.programId = input.programId
|
||||
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: Object.keys(where).length > 0 ? where : undefined,
|
||||
where,
|
||||
select: { tags: true },
|
||||
})
|
||||
|
||||
@@ -984,6 +990,7 @@ export const projectRouter = router({
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
id: { in: input.ids },
|
||||
isTest: false,
|
||||
},
|
||||
select: { id: true, title: true },
|
||||
})
|
||||
@@ -1102,6 +1109,7 @@ export const projectRouter = router({
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
programId,
|
||||
isTest: false,
|
||||
projectRoundStates: { none: {} }, // Projects not assigned to any round
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ export const specialAwardRouter = router({
|
||||
let { competition } = award
|
||||
if (!competition && award.programId) {
|
||||
const comp = await ctx.prisma.competition.findFirst({
|
||||
where: { programId: award.programId },
|
||||
where: { programId: award.programId, isTest: false },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' as const } } },
|
||||
})
|
||||
@@ -141,7 +141,7 @@ export const specialAwardRouter = router({
|
||||
let competitionId = input.competitionId
|
||||
if (!competitionId) {
|
||||
const comp = await ctx.prisma.competition.findFirst({
|
||||
where: { programId: input.programId },
|
||||
where: { programId: input.programId, isTest: false },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
@@ -217,7 +217,7 @@ export const specialAwardRouter = router({
|
||||
})
|
||||
if (existing && !existing.competitionId) {
|
||||
const comp = await ctx.prisma.competition.findFirst({
|
||||
where: { programId: existing.programId },
|
||||
where: { programId: existing.programId, isTest: false },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
@@ -404,7 +404,7 @@ export const specialAwardRouter = router({
|
||||
const { awardId, eligibleOnly, page, perPage } = input
|
||||
const skip = (page - 1) * perPage
|
||||
|
||||
const where: Record<string, unknown> = { awardId }
|
||||
const where: Record<string, unknown> = { awardId, project: { isTest: false } }
|
||||
if (eligibleOnly) where.eligible = true
|
||||
|
||||
const [eligibilities, total] = await Promise.all([
|
||||
|
||||
@@ -53,7 +53,7 @@ async function runTaggingJob(jobId: string, userId: string) {
|
||||
if (!job.programId) {
|
||||
throw new Error('Job must have a programId')
|
||||
}
|
||||
const whereClause = { programId: job.programId }
|
||||
const whereClause = { programId: job.programId, isTest: false }
|
||||
|
||||
const allProjects = await prisma.project.findMany({
|
||||
where: whereClause,
|
||||
@@ -196,11 +196,13 @@ export const tagRouter = router({
|
||||
const userCount = await ctx.prisma.user.count({
|
||||
where: {
|
||||
expertiseTags: { has: tag.name },
|
||||
isTest: false,
|
||||
},
|
||||
})
|
||||
const projectCount = await ctx.prisma.project.count({
|
||||
where: {
|
||||
tags: { has: tag.name },
|
||||
isTest: false,
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -228,10 +230,10 @@ export const tagRouter = router({
|
||||
// Get usage counts
|
||||
const [userCount, projectCount] = await Promise.all([
|
||||
ctx.prisma.user.count({
|
||||
where: { expertiseTags: { has: tag.name } },
|
||||
where: { expertiseTags: { has: tag.name }, isTest: false },
|
||||
}),
|
||||
ctx.prisma.project.count({
|
||||
where: { tags: { has: tag.name } },
|
||||
where: { tags: { has: tag.name }, isTest: false },
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -354,7 +356,7 @@ export const tagRouter = router({
|
||||
|
||||
// Update users
|
||||
const usersWithTag = await ctx.prisma.user.findMany({
|
||||
where: { expertiseTags: { has: oldTag.name } },
|
||||
where: { expertiseTags: { has: oldTag.name }, isTest: false },
|
||||
select: { id: true, expertiseTags: true },
|
||||
})
|
||||
|
||||
@@ -371,7 +373,7 @@ export const tagRouter = router({
|
||||
|
||||
// Update projects
|
||||
const projectsWithTag = await ctx.prisma.project.findMany({
|
||||
where: { tags: { has: oldTag.name } },
|
||||
where: { tags: { has: oldTag.name }, isTest: false },
|
||||
select: { id: true, tags: true },
|
||||
})
|
||||
|
||||
@@ -412,9 +414,9 @@ export const tagRouter = router({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
// Remove tag from all users
|
||||
// Remove tag from all users (excluding test users)
|
||||
const usersWithTag = await ctx.prisma.user.findMany({
|
||||
where: { expertiseTags: { has: tag.name } },
|
||||
where: { expertiseTags: { has: tag.name }, isTest: false },
|
||||
select: { id: true, expertiseTags: true },
|
||||
})
|
||||
|
||||
@@ -427,9 +429,9 @@ export const tagRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Remove tag from all projects
|
||||
// Remove tag from all projects (excluding test projects)
|
||||
const projectsWithTag = await ctx.prisma.project.findMany({
|
||||
where: { tags: { has: tag.name } },
|
||||
where: { tags: { has: tag.name }, isTest: false },
|
||||
select: { id: true, tags: true },
|
||||
})
|
||||
|
||||
|
||||
92
src/server/routers/testEnvironment.ts
Normal file
92
src/server/routers/testEnvironment.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { router, superAdminProcedure, protectedProcedure } from '../trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { createTestEnvironment, tearDownTestEnvironment } from '../services/test-environment'
|
||||
|
||||
export const testEnvironmentRouter = router({
|
||||
/**
|
||||
* Get the current test environment status.
|
||||
* Uses a custom auth check: allows access if realRole OR role is SUPER_ADMIN.
|
||||
* This enables the impersonation banner to fetch test users while impersonating.
|
||||
*/
|
||||
status: protectedProcedure.query(async ({ ctx }) => {
|
||||
// Allow access if the user's actual role (or impersonated-from role) is SUPER_ADMIN
|
||||
const effectiveRole = (ctx.session?.user as any)?.realRole || ctx.user.role
|
||||
if (effectiveRole !== 'SUPER_ADMIN') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Super admin access required' })
|
||||
}
|
||||
|
||||
const competition = await ctx.prisma.competition.findFirst({
|
||||
where: { isTest: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
program: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
rounds: {
|
||||
select: { id: true, name: true, roundType: true, status: true, sortOrder: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!competition) {
|
||||
return { active: false as const }
|
||||
}
|
||||
|
||||
// Get test users grouped by role
|
||||
const testUsers = await ctx.prisma.user.findMany({
|
||||
where: { isTest: true },
|
||||
select: { id: true, name: true, email: true, role: true },
|
||||
orderBy: [{ role: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
|
||||
// Get email redirect setting
|
||||
const emailRedirect = await ctx.prisma.systemSettings.findUnique({
|
||||
where: { key: 'test_email_redirect' },
|
||||
select: { value: true },
|
||||
})
|
||||
|
||||
return {
|
||||
active: true as const,
|
||||
competition: {
|
||||
id: competition.id,
|
||||
name: competition.name,
|
||||
status: competition.status,
|
||||
createdAt: competition.createdAt,
|
||||
programId: competition.program.id,
|
||||
programName: competition.program.name,
|
||||
},
|
||||
rounds: competition.rounds,
|
||||
users: testUsers,
|
||||
emailRedirect: emailRedirect?.value || null,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a test environment. Idempotent — tears down existing first.
|
||||
*/
|
||||
create: superAdminProcedure.mutation(async ({ ctx }) => {
|
||||
const result = await createTestEnvironment(ctx.prisma, ctx.user.email || '')
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Tear down the test environment.
|
||||
*/
|
||||
tearDown: superAdminProcedure.mutation(async ({ ctx }) => {
|
||||
const program = await ctx.prisma.program.findFirst({
|
||||
where: { isTest: true },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!program) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'No test environment found' })
|
||||
}
|
||||
|
||||
await tearDownTestEnvironment(ctx.prisma, program.id)
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
@@ -249,7 +249,9 @@ export const userRouter = router({
|
||||
const { role, roles, status, search, page, perPage } = input
|
||||
const skip = (page - 1) * perPage
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
const where: Record<string, unknown> = {
|
||||
isTest: false,
|
||||
}
|
||||
|
||||
if (roles && roles.length > 0) {
|
||||
where.role = { in: roles }
|
||||
@@ -316,6 +318,7 @@ export const userRouter = router({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {
|
||||
isTest: false,
|
||||
status: { in: ['NONE', 'INVITED'] },
|
||||
}
|
||||
|
||||
@@ -929,6 +932,7 @@ export const userRouter = router({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {
|
||||
isTest: false,
|
||||
role: 'JURY_MEMBER',
|
||||
status: 'ACTIVE',
|
||||
}
|
||||
|
||||
@@ -290,6 +290,7 @@ export async function generateSummary({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
isTest: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -297,6 +298,10 @@ export async function generateSummary({
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
||||
}
|
||||
|
||||
if (project.isTest) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot generate AI summaries for test projects' })
|
||||
}
|
||||
|
||||
// Fetch submitted evaluations for this project in this round
|
||||
const evaluations = await prisma.evaluation.findMany({
|
||||
where: {
|
||||
|
||||
@@ -103,6 +103,7 @@ async function generateCategoryShortlist(
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
competitionCategory: category,
|
||||
isTest: false,
|
||||
assignments: { some: { roundId } },
|
||||
},
|
||||
include: {
|
||||
|
||||
@@ -473,6 +473,10 @@ export async function tagProject(
|
||||
throw new Error(`Project not found: ${projectId}`)
|
||||
}
|
||||
|
||||
if ((project as any).isTest) {
|
||||
throw new Error(`Cannot run AI tagging on test project: ${projectId}`)
|
||||
}
|
||||
|
||||
// Get available tags
|
||||
const availableTags = await getAvailableTags()
|
||||
if (availableTags.length === 0) {
|
||||
@@ -574,7 +578,7 @@ export async function tagProjectsBatch(
|
||||
|
||||
// Fetch full project data for all projects at once (single DB query)
|
||||
const fullProjects = await prisma.project.findMany({
|
||||
where: { id: { in: projects.map((p) => p.id) } },
|
||||
where: { id: { in: projects.map((p) => p.id) }, isTest: false },
|
||||
include: {
|
||||
projectTags: true,
|
||||
files: { select: { fileType: true } },
|
||||
@@ -712,6 +716,10 @@ export async function getTagSuggestions(
|
||||
throw new Error(`Project not found: ${projectId}`)
|
||||
}
|
||||
|
||||
if ((project as any).isTest) {
|
||||
throw new Error(`Cannot run AI tagging on test project: ${projectId}`)
|
||||
}
|
||||
|
||||
// Get available tags
|
||||
const availableTags = await getAvailableTags()
|
||||
if (availableTags.length === 0) {
|
||||
|
||||
@@ -88,6 +88,7 @@ export async function processEligibilityJob(
|
||||
where: {
|
||||
id: { in: passedIds },
|
||||
programId: award.programId,
|
||||
isTest: false,
|
||||
},
|
||||
select: projectSelect,
|
||||
})
|
||||
@@ -99,6 +100,7 @@ export async function processEligibilityJob(
|
||||
projects = await prisma.project.findMany({
|
||||
where: {
|
||||
programId: award.programId,
|
||||
isTest: false,
|
||||
status: { in: [...statusFilter] },
|
||||
},
|
||||
select: projectSelect,
|
||||
|
||||
@@ -320,7 +320,7 @@ export async function analyzeAllUnanalyzed(): Promise<{
|
||||
total: number
|
||||
}> {
|
||||
const files = await prisma.projectFile.findMany({
|
||||
where: { analyzedAt: null },
|
||||
where: { analyzedAt: null, project: { isTest: false } },
|
||||
select: {
|
||||
id: true,
|
||||
objectKey: true,
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function processDigests(
|
||||
// Find users who opted in for this digest frequency
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
isTest: false,
|
||||
digestFrequency: type,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function sendManualReminders(roundId: string): Promise<ReminderResu
|
||||
if (usersToNotify.length === 0) return { sent, errors }
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: usersToNotify } },
|
||||
where: { id: { in: usersToNotify }, isTest: false },
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
|
||||
@@ -133,6 +133,7 @@ export async function processEvaluationReminders(roundId?: string): Promise<Remi
|
||||
status: 'ROUND_ACTIVE' as const,
|
||||
windowCloseAt: { gt: now },
|
||||
windowOpenAt: { lte: now },
|
||||
competition: { isTest: false },
|
||||
...(roundId && { id: roundId }),
|
||||
},
|
||||
select: {
|
||||
@@ -213,7 +214,7 @@ async function sendRemindersForRound(
|
||||
|
||||
// Get user details and their pending counts
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: usersToNotify } },
|
||||
where: { id: { in: usersToNotify }, isTest: false },
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
|
||||
|
||||
@@ -297,6 +297,7 @@ export async function notifyAdmins(params: {
|
||||
where: {
|
||||
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
|
||||
status: 'ACTIVE',
|
||||
isTest: false,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
@@ -232,6 +232,7 @@ export async function getAIMentorSuggestionsBatch(
|
||||
{ role: 'JURY_MEMBER' },
|
||||
],
|
||||
status: 'ACTIVE',
|
||||
isTest: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -458,6 +459,7 @@ export async function getRoundRobinMentor(
|
||||
{ role: 'JURY_MEMBER' },
|
||||
],
|
||||
status: 'ACTIVE',
|
||||
isTest: false,
|
||||
id: { notIn: excludeMentorIds },
|
||||
},
|
||||
select: {
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function sendNotification(
|
||||
): Promise<NotificationResult> {
|
||||
// Get user with notification preferences
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
where: { id: userId, isTest: false },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
|
||||
@@ -94,7 +94,7 @@ export async function previewRoundAssignment(
|
||||
|
||||
// Load jury group members
|
||||
const members = await db.juryGroupMember.findMany({
|
||||
where: { juryGroupId: ctx.juryGroup.id },
|
||||
where: { juryGroupId: ctx.juryGroup.id, user: { isTest: false } },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, role: true, bio: true, expertiseTags: true, country: true, availabilityJson: true } },
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function processScheduledRounds(): Promise<{
|
||||
|
||||
// Find a SUPER_ADMIN to use as the actor for audit logging
|
||||
const systemActor = await prisma.user.findFirst({
|
||||
where: { role: 'SUPER_ADMIN' },
|
||||
where: { role: 'SUPER_ADMIN', isTest: false },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function processScheduledRounds(): Promise<{
|
||||
where: {
|
||||
status: 'ROUND_DRAFT',
|
||||
windowOpenAt: { lte: now },
|
||||
competition: { status: { not: 'ARCHIVED' } },
|
||||
competition: { status: { not: 'ARCHIVED' }, isTest: false },
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
@@ -49,6 +49,7 @@ export async function processScheduledRounds(): Promise<{
|
||||
where: {
|
||||
status: 'ROUND_ACTIVE',
|
||||
windowCloseAt: { lte: now },
|
||||
competition: { isTest: false },
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
@@ -362,6 +362,7 @@ export async function getSmartSuggestions(options: {
|
||||
where: {
|
||||
role,
|
||||
status: 'ACTIVE',
|
||||
isTest: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -683,6 +684,7 @@ export async function getMentorSuggestionsForProject(
|
||||
where: {
|
||||
role: 'MENTOR',
|
||||
status: 'ACTIVE',
|
||||
isTest: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
676
src/server/services/test-environment.ts
Normal file
676
src/server/services/test-environment.ts
Normal file
@@ -0,0 +1,676 @@
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
|
||||
// ─── Test Data Constants ─────────────────────────────────────────────────────
|
||||
|
||||
const TEST_JURY = [
|
||||
{ name: 'Sophie Laurent', email: 'sophie.laurent@test.local', country: 'France', expertiseTags: ['Marine Biology', 'Conservation'] },
|
||||
{ name: 'Marco Bianchi', email: 'marco.bianchi@test.local', country: 'Italy', expertiseTags: ['Sustainable Fishing', 'Policy'] },
|
||||
{ name: 'Elena Petrova', email: 'elena.petrova@test.local', country: 'Germany', expertiseTags: ['Ocean Technology', 'Engineering'] },
|
||||
{ name: 'James Chen', email: 'james.chen@test.local', country: 'Singapore', expertiseTags: ['Blue Economy', 'Innovation'] },
|
||||
{ name: 'Aisha Diallo', email: 'aisha.diallo@test.local', country: 'Senegal', expertiseTags: ['Community Development', 'Education'] },
|
||||
{ name: 'Carlos Rivera', email: 'carlos.rivera@test.local', country: 'Spain', expertiseTags: ['Climate Science', 'Renewable Energy'] },
|
||||
]
|
||||
|
||||
const TEST_APPLICANTS_OWNERS = [
|
||||
{ name: 'Léa Moreau', email: 'lea.moreau@test.local', country: 'France' },
|
||||
{ name: 'Henrik Johansson', email: 'henrik.johansson@test.local', country: 'Sweden' },
|
||||
{ name: 'Fatima Al-Rashid', email: 'fatima.alrashid@test.local', country: 'UAE' },
|
||||
{ name: 'Yuki Tanaka', email: 'yuki.tanaka@test.local', country: 'Japan' },
|
||||
{ name: 'Priya Sharma', email: 'priya.sharma@test.local', country: 'India' },
|
||||
{ name: 'Lucas Oliveira', email: 'lucas.oliveira@test.local', country: 'Brazil' },
|
||||
{ name: 'Nadia Kowalski', email: 'nadia.kowalski@test.local', country: 'Poland' },
|
||||
{ name: 'Samuel Okonkwo', email: 'samuel.okonkwo@test.local', country: 'Nigeria' },
|
||||
{ name: 'Ingrid Hansen', email: 'ingrid.hansen@test.local', country: 'Norway' },
|
||||
{ name: 'Diego Fernández', email: 'diego.fernandez@test.local', country: 'Mexico' },
|
||||
{ name: 'Amira Benali', email: 'amira.benali@test.local', country: 'Morocco' },
|
||||
{ name: 'Thomas Müller', email: 'thomas.muller@test.local', country: 'Germany' },
|
||||
]
|
||||
|
||||
const TEST_APPLICANTS_TEAMMATES = [
|
||||
{ name: 'Marie Dubois', email: 'marie.dubois@test.local', country: 'France' },
|
||||
{ name: 'Kenji Watanabe', email: 'kenji.watanabe@test.local', country: 'Japan' },
|
||||
{ name: 'Ana Costa', email: 'ana.costa@test.local', country: 'Brazil' },
|
||||
{ name: 'Erik Lindqvist', email: 'erik.lindqvist@test.local', country: 'Sweden' },
|
||||
]
|
||||
|
||||
const TEST_OTHER_ROLES = [
|
||||
{ name: 'Dr. Catherine Blanc', email: 'catherine.blanc@test.local', role: 'MENTOR' as const, country: 'Monaco' },
|
||||
{ name: 'Oliver Schmidt', email: 'oliver.schmidt@test.local', role: 'OBSERVER' as const, country: 'Switzerland' },
|
||||
{ name: 'Isabella Romano', email: 'isabella.romano@test.local', role: 'AWARD_MASTER' as const, country: 'Italy' },
|
||||
{ name: 'Philippe Durand', email: 'philippe.durand@test.local', role: 'PROGRAM_ADMIN' as const, country: 'Monaco' },
|
||||
]
|
||||
|
||||
const TEST_PROJECTS = [
|
||||
{
|
||||
title: 'OceanGuard: AI-Powered Marine Debris Detection',
|
||||
teamName: 'OceanGuard Technologies',
|
||||
description: 'Using satellite imagery and deep learning to identify and track marine debris across the Mediterranean. Our proprietary algorithm achieves 94% accuracy in detecting microplastic concentration zones, enabling targeted cleanup operations.',
|
||||
country: 'France',
|
||||
category: 'STARTUP' as const,
|
||||
oceanIssue: 'POLLUTION_REDUCTION' as const,
|
||||
},
|
||||
{
|
||||
title: 'Blue Carbon Ventures: Seagrass Restoration at Scale',
|
||||
teamName: 'Blue Carbon Ventures',
|
||||
description: 'Developing cost-effective seagrass meadow restoration techniques for Mediterranean coastal zones. Our approach combines drone-based seed dispersal with AI monitoring to restore 500+ hectares of seagrass by 2030.',
|
||||
country: 'Italy',
|
||||
category: 'STARTUP' as const,
|
||||
oceanIssue: 'BLUE_CARBON' as const,
|
||||
},
|
||||
{
|
||||
title: 'Reef Resilience: Coral Thermal Adaptation Program',
|
||||
teamName: 'Reef Resilience Lab',
|
||||
description: 'Selective breeding of thermally tolerant coral genotypes combined with biofilm-enhanced substrate technology to accelerate reef recovery in warming waters. Currently operating pilot programs in 3 Mediterranean sites.',
|
||||
country: 'Monaco',
|
||||
category: 'STARTUP' as const,
|
||||
oceanIssue: 'HABITAT_RESTORATION' as const,
|
||||
},
|
||||
{
|
||||
title: 'WaveHarvest: Ocean Energy for Coastal Communities',
|
||||
teamName: 'WaveHarvest Energy',
|
||||
description: 'Modular wave energy converters designed for small island nations and remote coastal communities. Our patented oscillating water column system delivers reliable 50kW power at 1/3 the cost of competing technologies.',
|
||||
country: 'Norway',
|
||||
category: 'STARTUP' as const,
|
||||
oceanIssue: 'CLIMATE_MITIGATION' as const,
|
||||
},
|
||||
{
|
||||
title: 'SailCargo: Zero-Emission Maritime Logistics',
|
||||
teamName: 'SailCargo Collective',
|
||||
description: 'Reviving wind-powered shipping for short-sea routes in the Mediterranean using modernized sailing vessel designs with solar-electric auxiliary propulsion. Connecting ports along the French and Italian Riviera.',
|
||||
country: 'Spain',
|
||||
category: 'STARTUP' as const,
|
||||
oceanIssue: 'SUSTAINABLE_SHIPPING' as const,
|
||||
},
|
||||
{
|
||||
title: 'Neptune Analytics: Smart Fisheries Management',
|
||||
teamName: 'Neptune Analytics',
|
||||
description: 'IoT sensor network and machine learning platform for real-time fisheries monitoring. Tracks fish stock health, migration patterns, and fishing vessel compliance to support science-based quota management.',
|
||||
country: 'Portugal',
|
||||
category: 'STARTUP' as const,
|
||||
oceanIssue: 'SUSTAINABLE_FISHING' as const,
|
||||
},
|
||||
{
|
||||
title: 'AquaLens: Underwater Environmental Monitoring',
|
||||
teamName: 'AquaLens Research',
|
||||
description: 'A network of autonomous underwater drones equipped with hyperspectral cameras for continuous marine ecosystem monitoring. Real-time data on water quality, biodiversity indices, and pollutant levels.',
|
||||
country: 'Germany',
|
||||
category: 'BUSINESS_CONCEPT' as const,
|
||||
oceanIssue: 'TECHNOLOGY_INNOVATION' as const,
|
||||
},
|
||||
{
|
||||
title: 'TidalConnect: Ocean Literacy Education Platform',
|
||||
teamName: 'TidalConnect Foundation',
|
||||
description: 'Interactive mobile platform bringing ocean science education to underserved coastal communities in West Africa. Features gamified learning modules, local language support, and citizen science data collection.',
|
||||
country: 'Senegal',
|
||||
category: 'BUSINESS_CONCEPT' as const,
|
||||
oceanIssue: 'COMMUNITY_CAPACITY' as const,
|
||||
},
|
||||
{
|
||||
title: 'BioFouling Solutions: Eco-Friendly Marine Coatings',
|
||||
teamName: 'BioFouling Solutions',
|
||||
description: 'Biomimetic antifouling coatings inspired by shark skin microstructure. Our non-toxic coating reduces fuel consumption by 12% while eliminating the release of harmful biocides into marine environments.',
|
||||
country: 'Sweden',
|
||||
category: 'BUSINESS_CONCEPT' as const,
|
||||
oceanIssue: 'POLLUTION_REDUCTION' as const,
|
||||
},
|
||||
{
|
||||
title: 'Kelp Climate: Industrial-Scale Kelp Farming',
|
||||
teamName: 'Kelp Climate Co.',
|
||||
description: 'Offshore macroalgae cultivation combining carbon sequestration with sustainable biomaterials production. Our proprietary deep-water cultivation system supports 10x faster growth rates than nearshore farms.',
|
||||
country: 'Japan',
|
||||
category: 'BUSINESS_CONCEPT' as const,
|
||||
oceanIssue: 'BLUE_CARBON' as const,
|
||||
},
|
||||
{
|
||||
title: 'MarineTrack: Vessel Emission Compliance System',
|
||||
teamName: 'MarineTrack Systems',
|
||||
description: 'Real-time satellite and AIS-based monitoring platform for enforcing IMO 2030 emission standards. Automatically detects scrubber discharge violations and sulfur emission exceedances across Mediterranean shipping lanes.',
|
||||
country: 'Greece',
|
||||
category: 'STARTUP' as const,
|
||||
oceanIssue: 'SUSTAINABLE_SHIPPING' as const,
|
||||
},
|
||||
{
|
||||
title: 'Mangrove Guardians: Community-Led Restoration',
|
||||
teamName: 'Mangrove Guardians Network',
|
||||
description: 'Empowering coastal fishing communities in Southeast Asia to restore and manage mangrove ecosystems through microfinance-linked conservation incentives and satellite-verified carbon credits.',
|
||||
country: 'India',
|
||||
category: 'BUSINESS_CONCEPT' as const,
|
||||
oceanIssue: 'HABITAT_RESTORATION' as const,
|
||||
},
|
||||
]
|
||||
|
||||
const ROUND_DEFINITIONS = [
|
||||
{ name: 'Intake', slug: 'intake', roundType: 'INTAKE' as const, sortOrder: 0 },
|
||||
{ name: 'Eligibility Screening', slug: 'filtering', roundType: 'FILTERING' as const, sortOrder: 1 },
|
||||
{ name: 'Expert Evaluation', slug: 'evaluation', roundType: 'EVALUATION' as const, sortOrder: 2 },
|
||||
{ name: 'Document Submission', slug: 'submission', roundType: 'SUBMISSION' as const, sortOrder: 3 },
|
||||
{ name: 'Mentoring Phase', slug: 'mentoring', roundType: 'MENTORING' as const, sortOrder: 4 },
|
||||
{ name: 'Live Finals', slug: 'live-final', roundType: 'LIVE_FINAL' as const, sortOrder: 5 },
|
||||
{ name: 'Final Deliberation', slug: 'deliberation', roundType: 'DELIBERATION' as const, sortOrder: 6 },
|
||||
]
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TestEnvironmentResult {
|
||||
programId: string
|
||||
competitionId: string
|
||||
users: Array<{ id: string; name: string; email: string; role: string }>
|
||||
projectCount: number
|
||||
roundCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a complete test environment with realistic data.
|
||||
* Idempotent — tears down existing test env first.
|
||||
*/
|
||||
export async function createTestEnvironment(
|
||||
prisma: PrismaClient,
|
||||
adminEmail: string
|
||||
): Promise<TestEnvironmentResult> {
|
||||
// Tear down existing if any
|
||||
const existing = await prisma.competition.findFirst({ where: { isTest: true } })
|
||||
if (existing) {
|
||||
const existingProgram = await prisma.program.findFirst({ where: { isTest: true } })
|
||||
if (existingProgram) {
|
||||
await tearDownTestEnvironment(prisma, existingProgram.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Email redirect setting
|
||||
await prisma.systemSettings.upsert({
|
||||
where: { key: 'test_email_redirect' },
|
||||
update: { value: adminEmail },
|
||||
create: { key: 'test_email_redirect', value: adminEmail, category: 'DEFAULTS' },
|
||||
})
|
||||
|
||||
// 2. Program
|
||||
const program = await prisma.program.create({
|
||||
data: {
|
||||
name: '[TEST] Test Environment 2026',
|
||||
year: 2026,
|
||||
status: 'ACTIVE',
|
||||
description: 'Test environment for role impersonation and feature testing',
|
||||
isTest: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 3. Create test users
|
||||
const createdUsers: Array<{ id: string; name: string; email: string; role: string }> = []
|
||||
|
||||
// Jury members
|
||||
const juryUsers = await Promise.all(
|
||||
TEST_JURY.map((j) =>
|
||||
prisma.user.create({
|
||||
data: {
|
||||
name: j.name,
|
||||
email: j.email,
|
||||
role: 'JURY_MEMBER',
|
||||
status: 'ACTIVE',
|
||||
country: j.country,
|
||||
expertiseTags: j.expertiseTags,
|
||||
isTest: true,
|
||||
mustSetPassword: false,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
juryUsers.forEach((u) => createdUsers.push({ id: u.id, name: u.name!, email: u.email, role: u.role }))
|
||||
|
||||
// Applicant owners
|
||||
const ownerUsers = await Promise.all(
|
||||
TEST_APPLICANTS_OWNERS.map((a) =>
|
||||
prisma.user.create({
|
||||
data: {
|
||||
name: a.name,
|
||||
email: a.email,
|
||||
role: 'APPLICANT',
|
||||
status: 'ACTIVE',
|
||||
country: a.country,
|
||||
isTest: true,
|
||||
mustSetPassword: false,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
ownerUsers.forEach((u) => createdUsers.push({ id: u.id, name: u.name!, email: u.email, role: u.role }))
|
||||
|
||||
// Applicant teammates
|
||||
const teammateUsers = await Promise.all(
|
||||
TEST_APPLICANTS_TEAMMATES.map((a) =>
|
||||
prisma.user.create({
|
||||
data: {
|
||||
name: a.name,
|
||||
email: a.email,
|
||||
role: 'APPLICANT',
|
||||
status: 'ACTIVE',
|
||||
country: a.country,
|
||||
isTest: true,
|
||||
mustSetPassword: false,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
teammateUsers.forEach((u) => createdUsers.push({ id: u.id, name: u.name!, email: u.email, role: u.role }))
|
||||
|
||||
// Other roles
|
||||
const otherUsers = await Promise.all(
|
||||
TEST_OTHER_ROLES.map((r) =>
|
||||
prisma.user.create({
|
||||
data: {
|
||||
name: r.name,
|
||||
email: r.email,
|
||||
role: r.role,
|
||||
status: 'ACTIVE',
|
||||
country: r.country,
|
||||
isTest: true,
|
||||
mustSetPassword: false,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
otherUsers.forEach((u) => createdUsers.push({ id: u.id, name: u.name!, email: u.email, role: u.role }))
|
||||
|
||||
// 4. Create projects (one per owner)
|
||||
const projects = await Promise.all(
|
||||
TEST_PROJECTS.map((p, i) =>
|
||||
prisma.project.create({
|
||||
data: {
|
||||
programId: program.id,
|
||||
title: p.title,
|
||||
teamName: p.teamName,
|
||||
description: p.description,
|
||||
country: p.country,
|
||||
competitionCategory: p.category,
|
||||
oceanIssue: p.oceanIssue,
|
||||
status: 'SUBMITTED',
|
||||
submissionSource: 'MANUAL',
|
||||
submittedAt: new Date(),
|
||||
submittedByUserId: ownerUsers[i].id,
|
||||
isTest: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// 5. Add teammates to projects 0, 1, and 3
|
||||
const teammateAssignments = [
|
||||
{ projectIdx: 0, teammateIdx: 0 },
|
||||
{ projectIdx: 0, teammateIdx: 1 },
|
||||
{ projectIdx: 1, teammateIdx: 2 },
|
||||
{ projectIdx: 3, teammateIdx: 3 },
|
||||
]
|
||||
await Promise.all(
|
||||
teammateAssignments.map((ta) =>
|
||||
prisma.teamMember.create({
|
||||
data: {
|
||||
projectId: projects[ta.projectIdx].id,
|
||||
userId: teammateUsers[ta.teammateIdx].id,
|
||||
role: 'MEMBER',
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Also add owners as team members (OWNER role)
|
||||
await Promise.all(
|
||||
projects.map((proj, i) =>
|
||||
prisma.teamMember.create({
|
||||
data: {
|
||||
projectId: proj.id,
|
||||
userId: ownerUsers[i].id,
|
||||
role: 'LEAD',
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// 6. Competition
|
||||
const competition = await prisma.competition.create({
|
||||
data: {
|
||||
programId: program.id,
|
||||
name: 'Test Competition 2026',
|
||||
slug: `test-env-${Date.now()}`,
|
||||
status: 'ACTIVE',
|
||||
isTest: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 7. Jury Group
|
||||
const observerUser = otherUsers.find((u) => u.role === 'OBSERVER')
|
||||
const juryGroup = await prisma.juryGroup.create({
|
||||
data: {
|
||||
competitionId: competition.id,
|
||||
name: 'Test Jury Panel',
|
||||
slug: 'test-jury-panel',
|
||||
},
|
||||
})
|
||||
|
||||
// Add jury members to group
|
||||
await Promise.all(
|
||||
juryUsers.map((ju) =>
|
||||
prisma.juryGroupMember.create({
|
||||
data: {
|
||||
juryGroupId: juryGroup.id,
|
||||
userId: ju.id,
|
||||
role: 'MEMBER',
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Add observer to jury group
|
||||
if (observerUser) {
|
||||
await prisma.juryGroupMember.create({
|
||||
data: {
|
||||
juryGroupId: juryGroup.id,
|
||||
userId: observerUser.id,
|
||||
role: 'OBSERVER',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 8. Rounds (INTAKE=ACTIVE, rest=DRAFT)
|
||||
const rounds = await Promise.all(
|
||||
ROUND_DEFINITIONS.map((rd) =>
|
||||
prisma.round.create({
|
||||
data: {
|
||||
competitionId: competition.id,
|
||||
name: rd.name,
|
||||
slug: rd.slug,
|
||||
roundType: rd.roundType,
|
||||
status: rd.sortOrder === 0 ? 'ROUND_ACTIVE' : 'ROUND_DRAFT',
|
||||
sortOrder: rd.sortOrder,
|
||||
juryGroupId: rd.roundType === 'EVALUATION' ? juryGroup.id : undefined,
|
||||
windowOpenAt: rd.sortOrder === 0 ? new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) : undefined,
|
||||
windowCloseAt: rd.sortOrder === 0 ? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) : undefined,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const intakeRound = rounds[0]
|
||||
const evaluationRound = rounds[2]
|
||||
|
||||
// 9. Evaluation form on the evaluation round
|
||||
const evalForm = await prisma.evaluationForm.create({
|
||||
data: {
|
||||
roundId: evaluationRound.id,
|
||||
version: 1,
|
||||
isActive: true,
|
||||
criteriaJson: [
|
||||
{ id: 'innovation', label: 'Innovation & Originality', description: 'Novelty of approach and potential impact', scale: '1-10', weight: 30, required: true },
|
||||
{ id: 'feasibility', label: 'Technical Feasibility', description: 'Viability of implementation and scalability', scale: '1-10', weight: 40, required: true },
|
||||
{ id: 'ocean_impact', label: 'Ocean Impact Potential', description: 'Direct benefit to marine ecosystems', scale: '1-10', weight: 30, required: true },
|
||||
],
|
||||
scalesJson: { '1-10': { min: 1, max: 10, labels: { 1: 'Poor', 5: 'Average', 10: 'Excellent' } } },
|
||||
},
|
||||
})
|
||||
|
||||
// 10. ProjectRoundState for all projects in INTAKE round (PENDING)
|
||||
await Promise.all(
|
||||
projects.map((proj) =>
|
||||
prisma.projectRoundState.create({
|
||||
data: {
|
||||
projectId: proj.id,
|
||||
roundId: intakeRound.id,
|
||||
state: 'PENDING',
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// 11. Assignments — 5 projects per jury member in evaluation round (round-robin)
|
||||
const assignmentsPerJury = 5
|
||||
const assignmentData: Array<{ userId: string; projectId: string; roundId: string; juryGroupId: string }> = []
|
||||
|
||||
for (let j = 0; j < juryUsers.length; j++) {
|
||||
for (let a = 0; a < assignmentsPerJury; a++) {
|
||||
const projectIdx = (j * 2 + a) % projects.length
|
||||
const key = `${juryUsers[j].id}-${projects[projectIdx].id}`
|
||||
// Avoid duplicate assignments
|
||||
if (!assignmentData.some((ad) => ad.userId === juryUsers[j].id && ad.projectId === projects[projectIdx].id)) {
|
||||
assignmentData.push({
|
||||
userId: juryUsers[j].id,
|
||||
projectId: projects[projectIdx].id,
|
||||
roundId: evaluationRound.id,
|
||||
juryGroupId: juryGroup.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const assignments = await Promise.all(
|
||||
assignmentData.map((ad) =>
|
||||
prisma.assignment.create({
|
||||
data: {
|
||||
userId: ad.userId,
|
||||
projectId: ad.projectId,
|
||||
roundId: ad.roundId,
|
||||
juryGroupId: ad.juryGroupId,
|
||||
method: 'MANUAL',
|
||||
isCompleted: false,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// 12. Partial evaluations — first 2 jury members score their first 3-4 assignments
|
||||
const evalJurors = juryUsers.slice(0, 2)
|
||||
for (const juror of evalJurors) {
|
||||
const jurorAssignments = assignments.filter((a) => a.userId === juror.id).slice(0, 3 + Math.round(Math.random()))
|
||||
|
||||
for (const assignment of jurorAssignments) {
|
||||
const innovationScore = 5 + Math.floor(Math.random() * 5) // 5-9
|
||||
const feasibilityScore = 4 + Math.floor(Math.random() * 5) // 4-8
|
||||
const impactScore = 5 + Math.floor(Math.random() * 4) // 5-8
|
||||
const globalScore = Math.round((innovationScore * 0.3 + feasibilityScore * 0.4 + impactScore * 0.3))
|
||||
|
||||
await prisma.evaluation.create({
|
||||
data: {
|
||||
assignmentId: assignment.id,
|
||||
formId: evalForm.id,
|
||||
status: 'SUBMITTED',
|
||||
criterionScoresJson: {
|
||||
innovation: innovationScore,
|
||||
feasibility: feasibilityScore,
|
||||
ocean_impact: impactScore,
|
||||
},
|
||||
globalScore,
|
||||
binaryDecision: globalScore >= 6,
|
||||
feedbackText: `Strong project with notable ${innovationScore >= 7 ? 'innovation' : 'potential'}. ${feasibilityScore >= 7 ? 'Highly feasible approach.' : 'Implementation timeline needs refinement.'}`,
|
||||
submittedAt: new Date(Date.now() - Math.floor(Math.random() * 3 * 24 * 60 * 60 * 1000)),
|
||||
},
|
||||
})
|
||||
|
||||
// Mark assignment as completed
|
||||
await prisma.assignment.update({
|
||||
where: { id: assignment.id },
|
||||
data: { isCompleted: true },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 13. Advancement rules linking consecutive rounds
|
||||
for (let i = 0; i < rounds.length - 1; i++) {
|
||||
await prisma.advancementRule.create({
|
||||
data: {
|
||||
roundId: rounds[i].id,
|
||||
ruleType: 'ADMIN_SELECTION',
|
||||
configJson: {},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
programId: program.id,
|
||||
competitionId: competition.id,
|
||||
users: createdUsers,
|
||||
projectCount: projects.length,
|
||||
roundCount: rounds.length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the test environment — delete all test data.
|
||||
* Follows reverse-dependency order to avoid FK constraint errors.
|
||||
*/
|
||||
export async function tearDownTestEnvironment(
|
||||
prisma: PrismaClient,
|
||||
programId: string
|
||||
): Promise<void> {
|
||||
// Verify this is actually a test program
|
||||
const program = await prisma.program.findUnique({
|
||||
where: { id: programId },
|
||||
select: { isTest: true },
|
||||
})
|
||||
if (!program?.isTest) {
|
||||
throw new Error('Cannot tear down a non-test program')
|
||||
}
|
||||
|
||||
// Get all test competition IDs
|
||||
const competitions = await prisma.competition.findMany({
|
||||
where: { programId, isTest: true },
|
||||
select: { id: true },
|
||||
})
|
||||
const competitionIds = competitions.map((c) => c.id)
|
||||
|
||||
// Get all round IDs
|
||||
const rounds = await prisma.round.findMany({
|
||||
where: { competitionId: { in: competitionIds } },
|
||||
select: { id: true },
|
||||
})
|
||||
const roundIds = rounds.map((r) => r.id)
|
||||
|
||||
// Get all test project IDs
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { programId, isTest: true },
|
||||
select: { id: true },
|
||||
})
|
||||
const projectIds = projects.map((p) => p.id)
|
||||
|
||||
// Get all test user IDs
|
||||
const users = await prisma.user.findMany({
|
||||
where: { isTest: true },
|
||||
select: { id: true },
|
||||
})
|
||||
const userIds = users.map((u) => u.id)
|
||||
|
||||
// Delete in reverse-dependency order
|
||||
|
||||
// Deliberation data
|
||||
if (roundIds.length > 0) {
|
||||
await prisma.deliberationVote.deleteMany({ where: { session: { roundId: { in: roundIds } } } })
|
||||
await prisma.deliberationResult.deleteMany({ where: { session: { roundId: { in: roundIds } } } })
|
||||
await prisma.deliberationParticipant.deleteMany({ where: { session: { roundId: { in: roundIds } } } })
|
||||
await prisma.deliberationSession.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||
}
|
||||
|
||||
// Decision/Audit
|
||||
if (competitionIds.length > 0) {
|
||||
await prisma.resultLock.deleteMany({ where: { competitionId: { in: competitionIds } } })
|
||||
}
|
||||
if (roundIds.length > 0) {
|
||||
await prisma.decisionAuditLog.deleteMany({
|
||||
where: { entityType: 'Round', entityId: { in: roundIds } },
|
||||
})
|
||||
}
|
||||
|
||||
// Evaluations → Assignments
|
||||
if (roundIds.length > 0) {
|
||||
await prisma.evaluation.deleteMany({ where: { assignment: { roundId: { in: roundIds } } } })
|
||||
await prisma.conflictOfInterest.deleteMany({ where: { assignment: { roundId: { in: roundIds } } } })
|
||||
await prisma.assignmentException.deleteMany({ where: { assignment: { roundId: { in: roundIds } } } })
|
||||
await prisma.assignment.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||
}
|
||||
|
||||
// Project round states
|
||||
if (roundIds.length > 0) {
|
||||
await prisma.projectRoundState.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||
}
|
||||
|
||||
// Filtering
|
||||
if (roundIds.length > 0) {
|
||||
await prisma.filteringResult.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||
await prisma.filteringRule.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||
}
|
||||
|
||||
// Evaluation forms & advancement rules
|
||||
if (roundIds.length > 0) {
|
||||
await prisma.evaluationForm.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||
await prisma.advancementRule.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||
}
|
||||
|
||||
// Live voting
|
||||
if (roundIds.length > 0) {
|
||||
await prisma.liveVote.deleteMany({ where: { session: { roundId: { in: roundIds } } } })
|
||||
await prisma.liveVotingSession.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||
}
|
||||
|
||||
// Assignment intents and policies
|
||||
if (roundIds.length > 0) {
|
||||
await prisma.assignmentIntent.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||
}
|
||||
|
||||
// Reminder logs
|
||||
if (roundIds.length > 0) {
|
||||
await prisma.reminderLog.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||
}
|
||||
|
||||
// Jury groups
|
||||
if (competitionIds.length > 0) {
|
||||
await prisma.juryGroupMember.deleteMany({ where: { juryGroup: { competitionId: { in: competitionIds } } } })
|
||||
await prisma.juryGroup.deleteMany({ where: { competitionId: { in: competitionIds } } })
|
||||
}
|
||||
|
||||
// Submission windows
|
||||
if (competitionIds.length > 0) {
|
||||
await prisma.roundSubmissionVisibility.deleteMany({ where: { submissionWindow: { competitionId: { in: competitionIds } } } })
|
||||
await prisma.submissionWindow.deleteMany({ where: { competitionId: { in: competitionIds } } })
|
||||
}
|
||||
|
||||
// Rounds
|
||||
if (competitionIds.length > 0) {
|
||||
await prisma.round.deleteMany({ where: { competitionId: { in: competitionIds } } })
|
||||
}
|
||||
|
||||
// Project-related data
|
||||
if (projectIds.length > 0) {
|
||||
await prisma.evaluationSummary.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||
await prisma.evaluationDiscussion.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||
await prisma.awardEligibility.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||
await prisma.awardVote.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||
await prisma.projectTag.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||
await prisma.projectStatusHistory.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||
await prisma.mentorMessage.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||
await prisma.mentorAssignment.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||
await prisma.cohortProject.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||
await prisma.teamMember.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||
await prisma.projectFile.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||
}
|
||||
|
||||
// Special awards
|
||||
if (competitionIds.length > 0) {
|
||||
await prisma.specialAward.deleteMany({ where: { competitionId: { in: competitionIds } } })
|
||||
}
|
||||
|
||||
// Competitions
|
||||
await prisma.competition.deleteMany({ where: { programId, isTest: true } })
|
||||
|
||||
// Projects
|
||||
await prisma.project.deleteMany({ where: { programId, isTest: true } })
|
||||
|
||||
// Audit logs from test users
|
||||
if (userIds.length > 0) {
|
||||
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } })
|
||||
await prisma.notificationLog.deleteMany({ where: { userId: { in: userIds } } })
|
||||
}
|
||||
|
||||
// Users
|
||||
await prisma.user.deleteMany({ where: { isTest: true } })
|
||||
|
||||
// Program
|
||||
await prisma.program.deleteMany({ where: { id: programId, isTest: true } })
|
||||
|
||||
// Clean up email redirect setting
|
||||
await prisma.systemSettings.deleteMany({ where: { key: 'test_email_redirect' } })
|
||||
}
|
||||
@@ -109,20 +109,6 @@ export const EvaluationConfigSchema = z.object({
|
||||
generateAiShortlist: z.boolean().default(false),
|
||||
aiParseFiles: z.boolean().default(false),
|
||||
|
||||
applicantVisibility: z
|
||||
.object({
|
||||
enabled: z.boolean().default(false),
|
||||
showGlobalScore: z.boolean().default(false),
|
||||
showCriterionScores: z.boolean().default(false),
|
||||
showFeedbackText: z.boolean().default(false),
|
||||
})
|
||||
.default({
|
||||
enabled: false,
|
||||
showGlobalScore: false,
|
||||
showCriterionScores: false,
|
||||
showFeedbackText: false,
|
||||
}),
|
||||
|
||||
advancementMode: z
|
||||
.enum(['auto_top_n', 'admin_selection', 'ai_recommended'])
|
||||
.default('admin_selection'),
|
||||
|
||||
Reference in New Issue
Block a user