Compare commits
4 Commits
with-test
...
09cc49d920
| Author | SHA1 | Date | |
|---|---|---|---|
| 09cc49d920 | |||
| 351d8144d9 | |||
| 5a609457c2 | |||
| ee2f10e080 |
@@ -40,12 +40,12 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/applicant/pipeline',
|
source: '/applicant/pipeline',
|
||||||
destination: '/applicant/competitions',
|
destination: '/applicant/competition',
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/applicant/pipeline/:path*',
|
source: '/applicant/pipeline/:path*',
|
||||||
destination: '/applicant/competitions',
|
destination: '/applicant/competition',
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
334
package-lock.json
generated
334
package-lock.json
generated
@@ -11,14 +11,12 @@
|
|||||||
"@anthropic-ai/sdk": "^0.78.0",
|
"@anthropic-ai/sdk": "^0.78.0",
|
||||||
"@auth/prisma-adapter": "^2.7.4",
|
"@auth/prisma-adapter": "^2.7.4",
|
||||||
"@blocknote/core": "^0.46.2",
|
"@blocknote/core": "^0.46.2",
|
||||||
"@blocknote/mantine": "^0.46.2",
|
|
||||||
"@blocknote/react": "^0.46.2",
|
"@blocknote/react": "^0.46.2",
|
||||||
|
"@blocknote/shadcn": "^0.46.2",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@mantine/core": "^8.3.13",
|
|
||||||
"@mantine/hooks": "^8.3.13",
|
|
||||||
"@notionhq/client": "^2.3.0",
|
"@notionhq/client": "^2.3.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
@@ -246,24 +244,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": {
|
"node_modules/@blocknote/react": {
|
||||||
"version": "0.46.2",
|
"version": "0.46.2",
|
||||||
"resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.46.2.tgz",
|
"resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.46.2.tgz",
|
||||||
@@ -296,6 +276,55 @@
|
|||||||
"integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==",
|
"integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@date-fns/tz": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
|
||||||
@@ -1652,34 +1681,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@napi-rs/canvas": {
|
||||||
"version": "0.1.80",
|
"version": "0.1.80",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
||||||
@@ -6010,6 +6011,42 @@
|
|||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
@@ -6091,6 +6128,18 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/bcryptjs": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
@@ -6145,6 +6194,39 @@
|
|||||||
"integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==",
|
"integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/buffer-crc32": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
|
||||||
@@ -7020,6 +7102,12 @@
|
|||||||
"fast-check": "^3.23.1"
|
"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": {
|
"node_modules/emoji-mart": {
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
|
||||||
@@ -7290,6 +7378,15 @@
|
|||||||
"@esbuild/win32-x64": "0.27.2"
|
"@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": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
@@ -8015,6 +8112,19 @@
|
|||||||
"node": ">= 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": {
|
"node_modules/framer-motion": {
|
||||||
"version": "11.18.2",
|
"version": "11.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
|
||||||
@@ -11100,6 +11210,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/nodemailer": {
|
||||||
"version": "7.0.13",
|
"version": "7.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||||
@@ -11628,6 +11744,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/preact": {
|
||||||
"version": "10.24.3",
|
"version": "10.24.3",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
||||||
@@ -12250,16 +12372,6 @@
|
|||||||
"react-dom": "^19.0.0"
|
"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": {
|
"node_modules/react-phone-number-input": {
|
||||||
"version": "3.4.14",
|
"version": "3.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.14.tgz",
|
"resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.14.tgz",
|
||||||
@@ -12361,23 +12473,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": {
|
"node_modules/react-transition-group": {
|
||||||
"version": "4.4.5",
|
"version": "4.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||||
@@ -13729,18 +13824,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/typed-array-buffer": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
||||||
@@ -14020,6 +14103,36 @@
|
|||||||
"@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
|
"@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": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
@@ -14051,20 +14164,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": {
|
"node_modules/use-debounce": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.0.tgz",
|
||||||
@@ -14077,37 +14176,6 @@
|
|||||||
"react": "*"
|
"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": {
|
"node_modules/use-sidecar": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||||
|
|||||||
@@ -24,14 +24,12 @@
|
|||||||
"@anthropic-ai/sdk": "^0.78.0",
|
"@anthropic-ai/sdk": "^0.78.0",
|
||||||
"@auth/prisma-adapter": "^2.7.4",
|
"@auth/prisma-adapter": "^2.7.4",
|
||||||
"@blocknote/core": "^0.46.2",
|
"@blocknote/core": "^0.46.2",
|
||||||
"@blocknote/mantine": "^0.46.2",
|
|
||||||
"@blocknote/react": "^0.46.2",
|
"@blocknote/react": "^0.46.2",
|
||||||
|
"@blocknote/shadcn": "^0.46.2",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@mantine/core": "^8.3.13",
|
|
||||||
"@mantine/hooks": "^8.3.13",
|
|
||||||
"@notionhq/client": "^2.3.0",
|
"@notionhq/client": "^2.3.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
-- 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");
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- 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,19 +115,6 @@ enum NotificationChannel {
|
|||||||
NONE
|
NONE
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ResourceType {
|
|
||||||
PDF
|
|
||||||
VIDEO
|
|
||||||
DOCUMENT
|
|
||||||
LINK
|
|
||||||
OTHER
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CohortLevel {
|
|
||||||
ALL
|
|
||||||
SEMIFINALIST
|
|
||||||
FINALIST
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PartnerVisibility {
|
enum PartnerVisibility {
|
||||||
ADMIN_ONLY
|
ADMIN_ONLY
|
||||||
@@ -350,9 +337,6 @@ model User {
|
|||||||
preferredWorkload Int?
|
preferredWorkload Int?
|
||||||
availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string }
|
availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string }
|
||||||
|
|
||||||
// Test environment isolation
|
|
||||||
isTest Boolean @default(false)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
lastLoginAt DateTime?
|
lastLoginAt DateTime?
|
||||||
@@ -497,9 +481,6 @@ model Program {
|
|||||||
description String?
|
description String?
|
||||||
settingsJson Json? @db.JsonB
|
settingsJson Json? @db.JsonB
|
||||||
|
|
||||||
// Test environment isolation
|
|
||||||
isTest Boolean @default(false)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -624,9 +605,6 @@ model Project {
|
|||||||
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
|
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
|
||||||
externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc.
|
externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc.
|
||||||
|
|
||||||
// Test environment isolation
|
|
||||||
isTest Boolean @default(false)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1019,13 +997,12 @@ model NotificationEmailSetting {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
model LearningResource {
|
model LearningResource {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
programId String? // null = global resource
|
programId String? // null = global resource
|
||||||
title String
|
title String
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
contentJson Json? @db.JsonB // BlockNote document structure
|
contentJson Json? @db.JsonB // BlockNote document structure
|
||||||
resourceType ResourceType
|
accessJson Json? @db.JsonB // Fine-grained access rules
|
||||||
cohortLevel CohortLevel @default(ALL)
|
|
||||||
|
|
||||||
// File storage (for uploaded resources)
|
// File storage (for uploaded resources)
|
||||||
fileName String?
|
fileName String?
|
||||||
@@ -1034,6 +1011,9 @@ model LearningResource {
|
|||||||
bucket String?
|
bucket String?
|
||||||
objectKey String?
|
objectKey String?
|
||||||
|
|
||||||
|
// Cover image (stored in MinIO)
|
||||||
|
coverImageKey String?
|
||||||
|
|
||||||
// External link
|
// External link
|
||||||
externalUrl String?
|
externalUrl String?
|
||||||
|
|
||||||
@@ -1050,7 +1030,6 @@ model LearningResource {
|
|||||||
accessLogs ResourceAccess[]
|
accessLogs ResourceAccess[]
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([cohortLevel])
|
|
||||||
@@index([isPublished])
|
@@index([isPublished])
|
||||||
@@index([sortOrder])
|
@@index([sortOrder])
|
||||||
}
|
}
|
||||||
@@ -2099,9 +2078,6 @@ model Competition {
|
|||||||
notifyOnDeadlineApproach Boolean @default(true)
|
notifyOnDeadlineApproach Boolean @default(true)
|
||||||
deadlineReminderDays Int[] @default([7, 3, 1])
|
deadlineReminderDays Int[] @default([7, 3, 1])
|
||||||
|
|
||||||
// Test environment isolation
|
|
||||||
isTest Boolean @default(false)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -2116,7 +2092,6 @@ model Competition {
|
|||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([isTest])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Round {
|
model Round {
|
||||||
|
|||||||
@@ -68,19 +68,8 @@ export default function AssignmentsDashboardPage() {
|
|||||||
|
|
||||||
if (!competition) {
|
if (!competition) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
||||||
<Card>
|
<p>Competition not found</p>
|
||||||
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,34 +13,16 @@ import type { Route } from 'next';
|
|||||||
export default function AwardsPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
|
export default function AwardsPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
|
||||||
const params = use(paramsPromise);
|
const params = use(paramsPromise);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery({
|
const { data: competition } = trpc.competition.getById.useQuery({
|
||||||
id: params.competitionId
|
id: params.competitionId
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: awards, isLoading, isError: isAwardsError } = trpc.specialAward.list.useQuery({
|
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({
|
||||||
programId: competition?.programId
|
programId: competition?.programId
|
||||||
}, {
|
}, {
|
||||||
enabled: !!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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -43,13 +43,13 @@ export default function DeliberationListPage({
|
|||||||
participantUserIds: [] as string[]
|
participantUserIds: [] as string[]
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: sessions = [], isLoading, isError: isSessionsError } = trpc.deliberation.listSessions.useQuery(
|
const { data: sessions = [], isLoading } = trpc.deliberation.listSessions.useQuery(
|
||||||
{ competitionId: params.competitionId },
|
{ competitionId: params.competitionId },
|
||||||
{ enabled: !!params.competitionId }
|
{ enabled: !!params.competitionId }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get rounds for this competition
|
// Get rounds for this competition
|
||||||
const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery(
|
const { data: competition } = trpc.competition.getById.useQuery(
|
||||||
{ id: params.competitionId },
|
{ id: params.competitionId },
|
||||||
{ enabled: !!params.competitionId }
|
{ enabled: !!params.competitionId }
|
||||||
);
|
);
|
||||||
@@ -121,24 +121,6 @@ export default function DeliberationListPage({
|
|||||||
return <Badge variant={variants[status] || 'outline'}>{labels[status] || status}</Badge>;
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-4 sm:p-6">
|
<div className="space-y-6 p-4 sm:p-6">
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Radio,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
|
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
|
||||||
|
|
||||||
@@ -436,19 +435,6 @@ export default function CompetitionDetailPage() {
|
|||||||
<span className="truncate">{round.juryGroup.name}</span>
|
<span className="truncate">{round.juryGroup.name}</span>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
@@ -8,15 +8,11 @@ import { trpc } from '@/lib/trpc/client'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
Card,
|
import { Separator } from '@/components/ui/separator'
|
||||||
CardContent,
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
CardDescription,
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -24,6 +20,14 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -35,46 +39,62 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Save,
|
Save,
|
||||||
Loader2,
|
Loader2,
|
||||||
FileText,
|
Settings,
|
||||||
Video,
|
|
||||||
Link as LinkIcon,
|
|
||||||
File,
|
|
||||||
Trash2,
|
|
||||||
Eye,
|
Eye,
|
||||||
|
Trash2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Dynamically import BlockEditor to avoid SSR issues
|
// Dynamically import editors to avoid SSR issues
|
||||||
const BlockEditor = dynamic(
|
const BlockEditor = dynamic(
|
||||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
<div className="mx-auto max-w-3xl min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const resourceTypeOptions = [
|
const ResourceRenderer = dynamic(
|
||||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
{
|
||||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
ssr: false,
|
||||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
loading: () => (
|
||||||
{ value: 'OTHER', label: 'Other', icon: File },
|
<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 cohortOptions = [
|
type AccessRule =
|
||||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
| { type: 'everyone' }
|
||||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
| { type: 'roles'; roles: string[] }
|
||||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
| { 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: [] }
|
||||||
|
}
|
||||||
|
|
||||||
export default function EditLearningResourcePage() {
|
export default function EditLearningResourcePage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -89,11 +109,14 @@ export default function EditLearningResourcePage() {
|
|||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [contentJson, setContentJson] = useState<string>('')
|
const [contentJson, setContentJson] = useState<string>('')
|
||||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
|
||||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
|
||||||
const [externalUrl, setExternalUrl] = useState('')
|
const [externalUrl, setExternalUrl] = useState('')
|
||||||
const [isPublished, setIsPublished] = useState(false)
|
const [isPublished, setIsPublished] = useState(false)
|
||||||
const [programId, setProgramId] = useState<string | null>(null)
|
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
|
// API
|
||||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||||
@@ -115,11 +138,13 @@ export default function EditLearningResourcePage() {
|
|||||||
setTitle(resource.title)
|
setTitle(resource.title)
|
||||||
setDescription(resource.description || '')
|
setDescription(resource.description || '')
|
||||||
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
|
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
|
||||||
setResourceType(resource.resourceType)
|
|
||||||
setCohortLevel(resource.cohortLevel)
|
|
||||||
setExternalUrl(resource.externalUrl || '')
|
setExternalUrl(resource.externalUrl || '')
|
||||||
setIsPublished(resource.isPublished)
|
setIsPublished(resource.isPublished)
|
||||||
setProgramId(resource.programId)
|
setProgramId(resource.programId)
|
||||||
|
|
||||||
|
const { mode, roles } = parseAccessJson(resource.accessJson)
|
||||||
|
setAccessMode(mode)
|
||||||
|
setSelectedRoles(roles)
|
||||||
}
|
}
|
||||||
}, [resource])
|
}, [resource])
|
||||||
|
|
||||||
@@ -134,74 +159,88 @@ export default function EditLearningResourcePage() {
|
|||||||
await fetch(url, {
|
await fetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: file,
|
body: file,
|
||||||
headers: {
|
headers: { 'Content-Type': file.type },
|
||||||
'Content-Type': file.type,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to upload file')
|
toast.error('Failed to upload file')
|
||||||
throw error
|
throw new Error('Upload failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
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 () => {
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
toast.error('Please enter a title')
|
toast.error('Please enter a title')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceType === 'LINK' && !externalUrl) {
|
|
||||||
toast.error('Please enter an external URL')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateResource.mutateAsync({
|
await updateResource.mutateAsync({
|
||||||
id: resourceId,
|
id: resourceId,
|
||||||
|
programId,
|
||||||
title,
|
title,
|
||||||
description: description || undefined,
|
description: description || null,
|
||||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
accessJson: buildAccessJson(),
|
||||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
|
||||||
externalUrl: externalUrl || null,
|
externalUrl: externalUrl || null,
|
||||||
isPublished,
|
isPublished,
|
||||||
})
|
})
|
||||||
|
|
||||||
toast.success('Resource updated successfully')
|
toast.success('Resource updated')
|
||||||
router.push('/admin/learning')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
|
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
|
||||||
}
|
}
|
||||||
}
|
}, [title, description, contentJson, externalUrl, isPublished, programId, accessMode, selectedRoles, resourceId])
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteResource.mutateAsync({ id: resourceId })
|
await deleteResource.mutateAsync({ id: resourceId })
|
||||||
toast.success('Resource deleted successfully')
|
toast.success('Resource deleted')
|
||||||
router.push('/admin/learning')
|
router.push('/admin/learning')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex min-h-screen flex-col">
|
||||||
<div className="flex items-center gap-4">
|
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2">
|
||||||
<Skeleton className="h-9 w-40" />
|
<Skeleton className="h-8 w-20" />
|
||||||
</div>
|
<div className="flex gap-2">
|
||||||
<Skeleton className="h-8 w-64" />
|
<Skeleton className="h-8 w-20" />
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<Skeleton className="h-8 w-20" />
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<Skeleton className="h-8 w-16" />
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
<Skeleton className="h-96 w-full" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6">
|
</div>
|
||||||
<Skeleton className="h-48 w-full" />
|
<div className="flex-1 px-4 py-8">
|
||||||
<Skeleton className="h-32 w-full" />
|
<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-96 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,7 +249,7 @@ export default function EditLearningResourcePage() {
|
|||||||
|
|
||||||
if (error || !resource) {
|
if (error || !resource) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 p-6">
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Resource not found</AlertTitle>
|
<AlertTitle>Resource not found</AlertTitle>
|
||||||
@@ -229,253 +268,250 @@ export default function EditLearningResourcePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* Header */}
|
{/* Sticky toolbar */}
|
||||||
<div className="flex items-center gap-4">
|
<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" asChild className="-ml-4">
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href="/admin/learning">
|
<Link href="/admin/learning">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Learning Hub
|
Back
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-center gap-2">
|
||||||
<div>
|
<Button
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Edit Resource</h1>
|
variant={previewing ? 'default' : 'outline'}
|
||||||
<p className="text-muted-foreground">
|
size="sm"
|
||||||
Update this learning resource
|
onClick={() => setPreviewing(!previewing)}
|
||||||
</p>
|
>
|
||||||
</div>
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
<AlertDialog>
|
{previewing ? 'Edit' : 'Preview'}
|
||||||
<AlertDialogTrigger asChild>
|
</Button>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<Sheet>
|
||||||
{/* Main content */}
|
<SheetTrigger asChild>
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<Button variant="outline" size="sm">
|
||||||
{/* Basic Info */}
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
<Card>
|
Settings
|
||||||
<CardHeader>
|
</Button>
|
||||||
<CardTitle>Resource Details</CardTitle>
|
</SheetTrigger>
|
||||||
<CardDescription>
|
<SheetContent className="overflow-y-auto">
|
||||||
Basic information about this resource
|
<SheetHeader>
|
||||||
</CardDescription>
|
<SheetTitle>Resource Settings</SheetTitle>
|
||||||
</CardHeader>
|
<SheetDescription>
|
||||||
<CardContent className="space-y-4">
|
Configure publishing, access, and metadata
|
||||||
<div className="space-y-2">
|
</SheetDescription>
|
||||||
<Label htmlFor="title">Title *</Label>
|
</SheetHeader>
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="e.g., Ocean Conservation Best Practices"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="mt-6 space-y-6">
|
||||||
<Label htmlFor="description">Short Description</Label>
|
{/* Publish toggle */}
|
||||||
<Textarea
|
<div className="flex items-center justify-between">
|
||||||
id="description"
|
<div>
|
||||||
value={description}
|
<Label>Published</Label>
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
<p className="text-sm text-muted-foreground">
|
||||||
placeholder="Brief description of this resource"
|
Make visible to users
|
||||||
rows={2}
|
</p>
|
||||||
maxLength={500}
|
</div>
|
||||||
/>
|
<Switch
|
||||||
</div>
|
checked={isPublished}
|
||||||
|
onCheckedChange={setIsPublished}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<Separator />
|
||||||
|
|
||||||
|
{/* Program */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type">Resource Type</Label>
|
<Label>Program</Label>
|
||||||
<Select value={resourceType} onValueChange={setResourceType}>
|
<Select
|
||||||
<SelectTrigger id="type">
|
value={programId || 'global'}
|
||||||
<SelectValue />
|
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select program" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{resourceTypeOptions.map((option) => (
|
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
{programs?.map((program) => (
|
||||||
<div className="flex items-center gap-2">
|
<SelectItem key={program.id} value={program.id}>
|
||||||
<option.icon className="h-4 w-4" />
|
{program.year} Edition
|
||||||
{option.label}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Separator />
|
||||||
<Label htmlFor="cohort">Access Level</Label>
|
|
||||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
{/* Access Rules */}
|
||||||
<SelectTrigger id="cohort">
|
<div className="space-y-3">
|
||||||
|
<Label>Access Rules</Label>
|
||||||
|
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
|
||||||
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{cohortOptions.map((option) => (
|
<SelectItem value="everyone">Everyone</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem value="roles">By Role</SelectItem>
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{resourceType === 'LINK' && (
|
{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>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* External URL */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="url">External URL *</Label>
|
<Label>External URL</Label>
|
||||||
<Input
|
<Input
|
||||||
id="url"
|
|
||||||
type="url"
|
type="url"
|
||||||
value={externalUrl}
|
value={externalUrl}
|
||||||
onChange={(e) => setExternalUrl(e.target.value)}
|
onChange={(e) => setExternalUrl(e.target.value)}
|
||||||
placeholder="https://example.com/resource"
|
placeholder="https://example.com/resource"
|
||||||
/>
|
/>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">
|
||||||
)}
|
Optional link to an external resource
|
||||||
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
|
||||||
id="published"
|
|
||||||
checked={isPublished}
|
|
||||||
onCheckedChange={setIsPublished}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Separator />
|
||||||
<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 */}
|
{/* Statistics */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<Card>
|
<div className="space-y-2">
|
||||||
<CardHeader>
|
<Label>Statistics</Label>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="grid grid-cols-2 gap-4 rounded-lg border p-3">
|
||||||
<Eye className="h-5 w-5" />
|
<div>
|
||||||
Statistics
|
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
||||||
</CardTitle>
|
<p className="text-xs text-muted-foreground">Total views</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
||||||
<div>
|
<p className="text-xs text-muted-foreground">Unique users</p>
|
||||||
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Total views</p>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">Unique users</p>
|
|
||||||
</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>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</SheetContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
@@ -8,15 +8,9 @@ import { trpc } from '@/lib/trpc/client'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
Card,
|
import { Separator } from '@/components/ui/separator'
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -24,33 +18,57 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ArrowLeft, Save, Loader2, FileText, Video, Link as LinkIcon, File } from 'lucide-react'
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Save,
|
||||||
|
Loader2,
|
||||||
|
Settings,
|
||||||
|
Eye,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Dynamically import BlockEditor to avoid SSR issues
|
// Dynamically import editors to avoid SSR issues
|
||||||
const BlockEditor = dynamic(
|
const BlockEditor = dynamic(
|
||||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
<div className="mx-auto max-w-3xl min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const resourceTypeOptions = [
|
const ResourceRenderer = dynamic(
|
||||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
{
|
||||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
ssr: false,
|
||||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
loading: () => (
|
||||||
{ value: 'OTHER', label: 'Other', icon: File },
|
<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 cohortOptions = [
|
type AccessRule =
|
||||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
| { type: 'everyone' }
|
||||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
| { type: 'roles'; roles: string[] }
|
||||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
| { type: 'jury_group'; juryGroupIds: string[] }
|
||||||
]
|
| { type: 'round'; roundIds: string[] }
|
||||||
|
|
||||||
export default function NewLearningResourcePage() {
|
export default function NewLearningResourcePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -59,14 +77,17 @@ export default function NewLearningResourcePage() {
|
|||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [contentJson, setContentJson] = useState<string>('')
|
const [contentJson, setContentJson] = useState<string>('')
|
||||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
|
||||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
|
||||||
const [externalUrl, setExternalUrl] = useState('')
|
const [externalUrl, setExternalUrl] = useState('')
|
||||||
const [isPublished, setIsPublished] = useState(false)
|
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
|
// API
|
||||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||||
const [programId, setProgramId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const createResource = trpc.learningResource.create.useMutation({
|
const createResource = trpc.learningResource.create.useMutation({
|
||||||
@@ -82,43 +103,41 @@ export default function NewLearningResourcePage() {
|
|||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Upload to MinIO
|
|
||||||
await fetch(url, {
|
await fetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: file,
|
body: file,
|
||||||
headers: {
|
headers: { 'Content-Type': file.type },
|
||||||
'Content-Type': file.type,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Return the MinIO URL
|
|
||||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to upload file')
|
toast.error('Failed to upload file')
|
||||||
throw error
|
throw new Error('Upload failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
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 () => {
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
toast.error('Please enter a title')
|
toast.error('Please enter a title')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceType === 'LINK' && !externalUrl) {
|
|
||||||
toast.error('Please enter an external URL')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createResource.mutateAsync({
|
await createResource.mutateAsync({
|
||||||
programId,
|
programId,
|
||||||
title,
|
title,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
accessJson: buildAccessJson(),
|
||||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
|
||||||
externalUrl: externalUrl || undefined,
|
externalUrl: externalUrl || undefined,
|
||||||
isPublished,
|
isPublished,
|
||||||
})
|
})
|
||||||
@@ -128,200 +147,205 @@ export default function NewLearningResourcePage() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* Header */}
|
{/* Sticky toolbar */}
|
||||||
<div className="flex items-center gap-4">
|
<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" asChild className="-ml-4">
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href="/admin/learning">
|
<Link href="/admin/learning">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Learning Hub
|
Back
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Add Resource</h1>
|
<Button
|
||||||
<p className="text-muted-foreground">
|
variant={previewing ? 'default' : 'outline'}
|
||||||
Create a new learning resource for jury members
|
size="sm"
|
||||||
</p>
|
onClick={() => setPreviewing(!previewing)}
|
||||||
</div>
|
>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
{previewing ? 'Edit' : 'Preview'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<Sheet>
|
||||||
{/* Main content */}
|
<SheetTrigger asChild>
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<Button variant="outline" size="sm">
|
||||||
{/* Basic Info */}
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
<Card>
|
Settings
|
||||||
<CardHeader>
|
</Button>
|
||||||
<CardTitle>Resource Details</CardTitle>
|
</SheetTrigger>
|
||||||
<CardDescription>
|
<SheetContent className="overflow-y-auto">
|
||||||
Basic information about this resource
|
<SheetHeader>
|
||||||
</CardDescription>
|
<SheetTitle>Resource Settings</SheetTitle>
|
||||||
</CardHeader>
|
<SheetDescription>
|
||||||
<CardContent className="space-y-4">
|
Configure publishing, access, and metadata
|
||||||
<div className="space-y-2">
|
</SheetDescription>
|
||||||
<Label htmlFor="title">Title *</Label>
|
</SheetHeader>
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="e.g., Ocean Conservation Best Practices"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="mt-6 space-y-6">
|
||||||
<Label htmlFor="description">Short Description</Label>
|
{/* Publish toggle */}
|
||||||
<Textarea
|
<div className="flex items-center justify-between">
|
||||||
id="description"
|
<div>
|
||||||
value={description}
|
<Label>Published</Label>
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
<p className="text-sm text-muted-foreground">
|
||||||
placeholder="Brief description of this resource"
|
Make visible to users
|
||||||
rows={2}
|
</p>
|
||||||
maxLength={500}
|
</div>
|
||||||
/>
|
<Switch
|
||||||
</div>
|
checked={isPublished}
|
||||||
|
onCheckedChange={setIsPublished}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<Separator />
|
||||||
|
|
||||||
|
{/* Program */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type">Resource Type</Label>
|
<Label>Program</Label>
|
||||||
<Select value={resourceType} onValueChange={setResourceType}>
|
<Select
|
||||||
<SelectTrigger id="type">
|
value={programId || 'global'}
|
||||||
<SelectValue />
|
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select program" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{resourceTypeOptions.map((option) => (
|
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
{programs?.map((program) => (
|
||||||
<div className="flex items-center gap-2">
|
<SelectItem key={program.id} value={program.id}>
|
||||||
<option.icon className="h-4 w-4" />
|
{program.year} Edition
|
||||||
{option.label}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Separator />
|
||||||
<Label htmlFor="cohort">Access Level</Label>
|
|
||||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
{/* Access Rules */}
|
||||||
<SelectTrigger id="cohort">
|
<div className="space-y-3">
|
||||||
|
<Label>Access Rules</Label>
|
||||||
|
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
|
||||||
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{cohortOptions.map((option) => (
|
<SelectItem value="everyone">Everyone</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem value="roles">By Role</SelectItem>
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{resourceType === 'LINK' && (
|
{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>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* External URL */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="url">External URL *</Label>
|
<Label>External URL</Label>
|
||||||
<Input
|
<Input
|
||||||
id="url"
|
|
||||||
type="url"
|
type="url"
|
||||||
value={externalUrl}
|
value={externalUrl}
|
||||||
onChange={(e) => setExternalUrl(e.target.value)}
|
onChange={(e) => setExternalUrl(e.target.value)}
|
||||||
placeholder="https://example.com/resource"
|
placeholder="https://example.com/resource"
|
||||||
/>
|
/>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">
|
||||||
)}
|
Optional link to an external resource
|
||||||
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
|
||||||
id="published"
|
|
||||||
checked={isPublished}
|
|
||||||
onCheckedChange={setIsPublished}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Button
|
||||||
<Label htmlFor="program">Program</Label>
|
size="sm"
|
||||||
<Select
|
onClick={handleSubmit}
|
||||||
value={programId || 'global'}
|
disabled={createResource.isPending || !title.trim()}
|
||||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
>
|
||||||
>
|
{createResource.isPending ? (
|
||||||
<SelectTrigger id="program">
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<SelectValue placeholder="Select program" />
|
) : (
|
||||||
</SelectTrigger>
|
<Save className="mr-2 h-4 w-4" />
|
||||||
<SelectContent>
|
)}
|
||||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
Save
|
||||||
{programs?.map((program) => (
|
</Button>
|
||||||
<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>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -22,48 +23,212 @@ import {
|
|||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
FileText,
|
FileText,
|
||||||
Video,
|
|
||||||
Link as LinkIcon,
|
|
||||||
File,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Search,
|
Search,
|
||||||
|
GripVertical,
|
||||||
} from 'lucide-react'
|
} 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'
|
||||||
|
|
||||||
const resourceTypeIcons = {
|
type Resource = {
|
||||||
PDF: FileText,
|
id: string
|
||||||
VIDEO: Video,
|
title: string
|
||||||
DOCUMENT: File,
|
description: string | null
|
||||||
LINK: LinkIcon,
|
isPublished: boolean
|
||||||
OTHER: File,
|
sortOrder: number
|
||||||
|
externalUrl: string | null
|
||||||
|
objectKey: string | null
|
||||||
|
contentJson: unknown
|
||||||
|
accessJson: unknown
|
||||||
|
_count: { accessLogs: number }
|
||||||
|
program: { id: string; name: string; year: number } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const cohortColors: Record<string, string> = {
|
function getAccessSummary(accessJson: unknown): string {
|
||||||
ALL: 'bg-gray-100 text-gray-800',
|
if (!accessJson || !Array.isArray(accessJson) || accessJson.length === 0) {
|
||||||
SEMIFINALIST: 'bg-blue-100 text-blue-800',
|
return 'Everyone'
|
||||||
FINALIST: 'bg-purple-100 text-purple-800',
|
}
|
||||||
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LearningHubPage() {
|
export default function LearningHubPage() {
|
||||||
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 50 })
|
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 100 })
|
||||||
const resources = data?.data
|
const resources = (data?.data || []) as Resource[]
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const debouncedSearch = useDebounce(search, 300)
|
const debouncedSearch = useDebounce(search, 300)
|
||||||
const [typeFilter, setTypeFilter] = useState('all')
|
const [publishedFilter, setPublishedFilter] = useState('all')
|
||||||
const [cohortFilter, setCohortFilter] = 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 filteredResources = useMemo(() => {
|
const filteredResources = useMemo(() => {
|
||||||
if (!resources) return []
|
|
||||||
return resources.filter((resource) => {
|
return resources.filter((resource) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
!debouncedSearch ||
|
!debouncedSearch ||
|
||||||
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
|
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||||
const matchesType = typeFilter === 'all' || resource.resourceType === typeFilter
|
const matchesPublished =
|
||||||
const matchesCohort = cohortFilter === 'all' || resource.cohortLevel === cohortFilter
|
publishedFilter === 'all' ||
|
||||||
return matchesSearch && matchesType && matchesCohort
|
(publishedFilter === 'published' && resource.isPublished) ||
|
||||||
|
(publishedFilter === 'draft' && !resource.isPublished)
|
||||||
|
return matchesSearch && matchesPublished
|
||||||
})
|
})
|
||||||
}, [resources, debouncedSearch, typeFilter, cohortFilter])
|
}, [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'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -75,25 +240,20 @@ export default function LearningHubPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-9 w-32" />
|
<Skeleton className="h-9 w-32" />
|
||||||
</div>
|
</div>
|
||||||
{/* Toolbar skeleton */}
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<Skeleton className="h-10 flex-1" />
|
<Skeleton className="h-10 flex-1" />
|
||||||
<div className="flex items-center gap-2">
|
<Skeleton className="h-10 w-[160px]" />
|
||||||
<Skeleton className="h-10 w-[160px]" />
|
|
||||||
<Skeleton className="h-10 w-[160px]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/* Resource list skeleton */}
|
<div className="grid gap-3">
|
||||||
<div className="grid gap-4">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<Card key={i}>
|
<Card key={i}>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
<Skeleton className="h-4 w-4" />
|
||||||
|
<Skeleton className="h-9 w-9 rounded-lg" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<Skeleton className="h-5 w-48" />
|
<Skeleton className="h-5 w-48" />
|
||||||
<Skeleton className="h-4 w-32" />
|
<Skeleton className="h-3 w-32" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-8 w-8 rounded" />
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -109,7 +269,7 @@ export default function LearningHubPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Learning Hub</h1>
|
<h1 className="text-2xl font-bold">Learning Hub</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Manage educational resources for jury members
|
Manage educational resources for program participants
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/learning/new">
|
<Link href="/admin/learning/new">
|
||||||
@@ -131,92 +291,49 @@ export default function LearningHubPage() {
|
|||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<Select value={publishedFilter} onValueChange={setPublishedFilter}>
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
<SelectTrigger className="w-[160px]">
|
||||||
<SelectTrigger className="w-[160px]">
|
<SelectValue placeholder="All" />
|
||||||
<SelectValue placeholder="All types" />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
<SelectContent>
|
<SelectItem value="all">All</SelectItem>
|
||||||
<SelectItem value="all">All types</SelectItem>
|
<SelectItem value="published">Published</SelectItem>
|
||||||
<SelectItem value="PDF">PDF</SelectItem>
|
<SelectItem value="draft">Drafts</SelectItem>
|
||||||
<SelectItem value="VIDEO">Video</SelectItem>
|
</SelectContent>
|
||||||
<SelectItem value="DOCUMENT">Document</SelectItem>
|
</Select>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
{resources && (
|
{resources.length > 0 && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{filteredResources.length} of {resources.length} resources
|
{filteredResources.length} of {resources.length} resources
|
||||||
|
{reorderMutation.isPending && ' · Saving order...'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Resource List */}
|
{/* Resource List with DnD */}
|
||||||
{filteredResources.length > 0 ? (
|
{filteredResources.length > 0 ? (
|
||||||
<div className="grid gap-4">
|
<DndContext
|
||||||
{filteredResources.map((resource) => {
|
sensors={sensors}
|
||||||
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
|
collisionDetection={closestCenter}
|
||||||
return (
|
onDragEnd={handleDragEnd}
|
||||||
<Card key={resource.id}>
|
>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<SortableContext
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
items={filteredResources.map((r) => r.id)}
|
||||||
<Icon className="h-5 w-5" />
|
strategy={verticalListSortingStrategy}
|
||||||
</div>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="grid gap-3">
|
||||||
<div className="flex items-center gap-2">
|
{filteredResources.map((resource) => (
|
||||||
<h3 className="font-medium truncate">{resource.title}</h3>
|
<SortableResourceCard
|
||||||
{!resource.isPublished && (
|
key={resource.id}
|
||||||
<Badge variant="secondary">Draft</Badge>
|
resource={resource}
|
||||||
)}
|
onTogglePublished={handleTogglePublished}
|
||||||
</div>
|
/>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
))}
|
||||||
<Badge className={cohortColors[resource.cohortLevel] || ''} variant="outline">
|
</div>
|
||||||
{resource.cohortLevel}
|
</SortableContext>
|
||||||
</Badge>
|
</DndContext>
|
||||||
<span>{resource.resourceType}</span>
|
) : resources.length > 0 ? (
|
||||||
<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>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||||
|
|||||||
10
src/app/(admin)/admin/mentors/[id]/page.tsx
Normal file
10
src/app/(admin)/admin/mentors/[id]/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default async function MentorDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
redirect(`/admin/members/${id}`)
|
||||||
|
}
|
||||||
5
src/app/(admin)/admin/mentors/page.tsx
Normal file
5
src/app/(admin)/admin/mentors/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
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) {
|
if (!editionId) {
|
||||||
const defaultEdition = await prisma.program.findFirst({
|
const defaultEdition = await prisma.program.findFirst({
|
||||||
where: { status: 'ACTIVE', isTest: false },
|
where: { status: 'ACTIVE' },
|
||||||
orderBy: { year: 'desc' },
|
orderBy: { year: 'desc' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
@@ -38,7 +38,6 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
|||||||
|
|
||||||
if (!editionId) {
|
if (!editionId) {
|
||||||
const anyEdition = await prisma.program.findFirst({
|
const anyEdition = await prisma.program.findFirst({
|
||||||
where: { isTest: false },
|
|
||||||
orderBy: { year: 'desc' },
|
orderBy: { year: 'desc' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { ArrowLeft, GraduationCap, Pencil, Plus } from 'lucide-react'
|
import { ArrowLeft, Pencil, Plus } from 'lucide-react'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
|
|
||||||
interface ProgramDetailPageProps {
|
interface ProgramDetailPageProps {
|
||||||
@@ -65,20 +65,12 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<Button variant="outline" asChild>
|
||||||
<Button variant="outline" asChild>
|
<Link href={`/admin/programs/${id}/edit`}>
|
||||||
<Link href={`/admin/programs/${id}/mentorship` as Route}>
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
<GraduationCap className="mr-2 h-4 w-4" />
|
Edit
|
||||||
Mentorship
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link href={`/admin/programs/${id}/edit`}>
|
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{program.description && (
|
{program.description && (
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import { formatDateOnly } from '@/lib/utils'
|
|||||||
|
|
||||||
async function ProgramsContent() {
|
async function ProgramsContent() {
|
||||||
const programs = await prisma.program.findMany({
|
const programs = await prisma.program.findMany({
|
||||||
where: { isTest: false },
|
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
|
||||||
include: {
|
include: {
|
||||||
competitions: {
|
competitions: {
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
History,
|
History,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ArrowRightLeft,
|
||||||
|
Sparkles,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -1667,6 +1669,8 @@ export default function RoundDetailPage() {
|
|||||||
<InlineMemberCap
|
<InlineMemberCap
|
||||||
memberId={member.id}
|
memberId={member.id}
|
||||||
currentValue={member.maxAssignmentsOverride as number | null}
|
currentValue={member.maxAssignmentsOverride as number | null}
|
||||||
|
roundId={roundId}
|
||||||
|
jurorUserId={member.userId}
|
||||||
onSave={(val) => updateJuryMemberMutation.mutate({
|
onSave={(val) => updateJuryMemberMutation.mutate({
|
||||||
id: member.id,
|
id: member.id,
|
||||||
maxAssignmentsOverride: val,
|
maxAssignmentsOverride: val,
|
||||||
@@ -2242,20 +2246,43 @@ function InlineMemberCap({
|
|||||||
memberId,
|
memberId,
|
||||||
currentValue,
|
currentValue,
|
||||||
onSave,
|
onSave,
|
||||||
|
roundId,
|
||||||
|
jurorUserId,
|
||||||
}: {
|
}: {
|
||||||
memberId: string
|
memberId: string
|
||||||
currentValue: number | null
|
currentValue: number | null
|
||||||
onSave: (val: number | null) => void
|
onSave: (val: number | null) => void
|
||||||
|
roundId?: string
|
||||||
|
jurorUserId?: string
|
||||||
}) {
|
}) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [value, setValue] = useState(currentValue?.toString() ?? '')
|
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 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(() => {
|
useEffect(() => {
|
||||||
if (editing) inputRef.current?.focus()
|
if (editing) inputRef.current?.focus()
|
||||||
}, [editing])
|
}, [editing])
|
||||||
|
|
||||||
const save = () => {
|
const save = async () => {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
const newVal = trimmed === '' ? null : parseInt(trimmed, 10)
|
const newVal = trimmed === '' ? null : parseInt(trimmed, 10)
|
||||||
if (newVal !== null && (isNaN(newVal) || newVal < 1)) {
|
if (newVal !== null && (isNaN(newVal) || newVal < 1)) {
|
||||||
@@ -2266,10 +2293,76 @@ function InlineMemberCap({
|
|||||||
setEditing(false)
|
setEditing(false)
|
||||||
return
|
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)
|
onSave(newVal)
|
||||||
setEditing(false)
|
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) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
@@ -2364,6 +2457,8 @@ function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: { roundId: strin
|
|||||||
|
|
||||||
function JuryProgressTable({ roundId }: { roundId: string }) {
|
function JuryProgressTable({ roundId }: { roundId: string }) {
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
const [transferJuror, setTransferJuror] = useState<{ id: string; name: string } | null>(null)
|
||||||
|
|
||||||
const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery(
|
const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
{ refetchInterval: 15_000 },
|
{ refetchInterval: 15_000 },
|
||||||
@@ -2393,6 +2488,7 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Jury Progress</CardTitle>
|
<CardTitle className="text-base">Jury Progress</CardTitle>
|
||||||
@@ -2448,6 +2544,22 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</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}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -2489,6 +2601,318 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2512,7 +2936,7 @@ function ReassignmentHistory({ roundId }: { roundId: string }) {
|
|||||||
Reassignment History
|
Reassignment History
|
||||||
<ChevronRight className={cn('h-4 w-4 ml-auto transition-transform', expanded && 'rotate-90')} />
|
<ChevronRight className={cn('h-4 w-4 ml-auto transition-transform', expanded && 'rotate-90')} />
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Juror dropout and COI reassignment audit trail</CardDescription>
|
<CardDescription>Juror dropout, COI, transfer, and cap redistribution audit trail</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -2531,7 +2955,7 @@ function ReassignmentHistory({ roundId }: { roundId: string }) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={event.type === 'DROPOUT' ? 'destructive' : 'secondary'}>
|
<Badge variant={event.type === 'DROPOUT' ? 'destructive' : 'secondary'}>
|
||||||
{event.type === 'DROPOUT' ? 'Juror Dropout' : 'COI Reassignment'}
|
{event.type === 'DROPOUT' ? 'Juror Dropout' : event.type === 'COI' ? 'COI Reassignment' : event.type === 'TRANSFER' ? 'Assignment Transfer' : 'Cap Redistribution'}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{event.droppedJuror.name}
|
{event.droppedJuror.name}
|
||||||
@@ -2622,19 +3046,19 @@ function ScoreDistribution({ roundId }: { roundId: string }) {
|
|||||||
No evaluations submitted yet
|
No evaluations submitted yet
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-end gap-1 h-32">
|
<div className="flex gap-1 h-32">
|
||||||
{dist.globalDistribution.map((bucket) => {
|
{dist.globalDistribution.map((bucket) => {
|
||||||
const heightPct = (bucket.count / maxCount) * 100
|
const heightPct = (bucket.count / maxCount) * 100
|
||||||
return (
|
return (
|
||||||
<div key={bucket.score} className="flex-1 flex flex-col items-center gap-1">
|
<div key={bucket.score} className="flex-1 flex flex-col items-center gap-1 h-full">
|
||||||
<span className="text-[9px] text-muted-foreground">{bucket.count || ''}</span>
|
<span className="text-[9px] text-muted-foreground">{bucket.count || ''}</span>
|
||||||
<div className="w-full relative rounded-t" style={{ height: `${Math.max(heightPct, 2)}%` }}>
|
<div className="w-full flex-1 relative">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'absolute inset-0 rounded-t transition-all',
|
'absolute inset-x-0 bottom-0 rounded-t transition-all',
|
||||||
bucket.score <= 3 ? 'bg-red-400' :
|
bucket.score <= 3 ? 'bg-red-400' :
|
||||||
bucket.score <= 6 ? 'bg-amber-400' :
|
bucket.score <= 6 ? 'bg-amber-400' :
|
||||||
'bg-emerald-400',
|
'bg-emerald-400',
|
||||||
)} />
|
)} style={{ height: `${Math.max(heightPct, 4)}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-muted-foreground">{bucket.score}</span>
|
<span className="text-[10px] text-muted-foreground">{bucket.score}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -205,14 +205,14 @@ export default function RoundsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startEditSettings = () => {
|
const startEditSettings = () => {
|
||||||
if (!comp || !compDetail) return
|
if (!comp) return
|
||||||
setEditingCompId(comp.id)
|
setEditingCompId(comp.id)
|
||||||
setCompetitionEdits({
|
setCompetitionEdits({
|
||||||
name: compDetail.name,
|
name: comp.name,
|
||||||
categoryMode: compDetail.categoryMode,
|
categoryMode: (comp as any).categoryMode,
|
||||||
startupFinalistCount: compDetail.startupFinalistCount,
|
startupFinalistCount: (comp as any).startupFinalistCount,
|
||||||
conceptFinalistCount: compDetail.conceptFinalistCount,
|
conceptFinalistCount: (comp as any).conceptFinalistCount,
|
||||||
notifyOnDeadlineApproach: compDetail.notifyOnDeadlineApproach,
|
notifyOnDeadlineApproach: (comp as any).notifyOnDeadlineApproach,
|
||||||
})
|
})
|
||||||
setSettingsOpen(true)
|
setSettingsOpen(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export default async function AdminLayout({
|
|||||||
|
|
||||||
// Fetch all editions (programs) for the edition selector
|
// Fetch all editions (programs) for the edition selector
|
||||||
const editions = await prisma.program.findMany({
|
const editions = await prisma.program.findMany({
|
||||||
where: { isTest: false },
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import { useSession } from 'next-auth/react'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline'
|
import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline'
|
||||||
import { ArrowLeft, FileText, Calendar } from 'lucide-react'
|
import { ArrowLeft, FileText, Calendar } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
export default function ApplicantCompetitionsPage() {
|
export default function ApplicantCompetitionPage() {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
||||||
enabled: !!session,
|
enabled: !!session,
|
||||||
@@ -26,7 +25,7 @@ export default function ApplicantCompetitionsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const competitionId = myProject?.project?.programId
|
const hasProject = !!myProject?.project
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -45,7 +44,7 @@ export default function ApplicantCompetitionsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!competitionId ? (
|
{!hasProject ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||||
@@ -59,7 +58,7 @@ export default function ApplicantCompetitionsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<ApplicantCompetitionTimeline competitionId={competitionId} />
|
<ApplicantCompetitionTimeline />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
147
src/app/(applicant)/applicant/evaluations/page.tsx
Normal file
147
src/app/(applicant)/applicant/evaluations/page.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
'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,25 +9,26 @@ import { Button } from '@/components/ui/button'
|
|||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
import { StatusTracker } from '@/components/shared/status-tracker'
|
import { StatusTracker } from '@/components/shared/status-tracker'
|
||||||
|
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
AlertCircle,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Users,
|
Users,
|
||||||
Crown,
|
Crown,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Upload,
|
Upload,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
Star,
|
||||||
|
AlertCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
||||||
@@ -49,6 +50,18 @@ export default function ApplicantDashboardPage() {
|
|||||||
enabled: isAuthenticated,
|
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) {
|
if (sessionStatus === 'loading' || isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -98,10 +111,11 @@ export default function ApplicantDashboardPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { project, timeline, currentStatus, openRounds } = data
|
const { project, timeline, currentStatus, openRounds, hasPassedIntake } = data
|
||||||
const isDraft = !project.submittedAt
|
const isDraft = !project.submittedAt
|
||||||
const programYear = project.program?.year
|
const programYear = project.program?.year
|
||||||
const programName = project.program?.name
|
const programName = project.program?.name
|
||||||
|
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -213,7 +227,7 @@ export default function ApplicantDashboardPage() {
|
|||||||
|
|
||||||
{/* Quick actions */}
|
{/* Quick actions */}
|
||||||
<AnimatedCard index={1}>
|
<AnimatedCard index={1}>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg: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">
|
<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">
|
<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" />
|
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
@@ -240,41 +254,108 @@ export default function ApplicantDashboardPage() {
|
|||||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
</Link>
|
</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">
|
{project.mentorAssignment && (
|
||||||
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
|
<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">
|
||||||
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
|
||||||
</div>
|
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
<div className="flex-1 min-w-0">
|
</div>
|
||||||
<p className="text-sm font-medium">Mentor</p>
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-sm font-medium">Mentor</p>
|
||||||
{project.mentorAssignment?.mentor?.name || 'Not assigned'}
|
<p className="text-xs text-muted-foreground">
|
||||||
</p>
|
{project.mentorAssignment.mentor?.name || 'Assigned'}
|
||||||
</div>
|
</p>
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
</div>
|
||||||
</Link>
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AnimatedCard>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Status timeline */}
|
{/* Competition timeline or status tracker */}
|
||||||
<AnimatedCard index={2}>
|
<AnimatedCard index={3}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Status Timeline</CardTitle>
|
<CardTitle>
|
||||||
|
{hasPassedIntake ? 'Competition Progress' : 'Status Timeline'}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<StatusTracker
|
{hasPassedIntake ? (
|
||||||
timeline={timeline}
|
<CompetitionTimelineSidebar />
|
||||||
currentStatus={currentStatus || 'SUBMITTED'}
|
) : (
|
||||||
/>
|
<StatusTracker
|
||||||
|
timeline={timeline}
|
||||||
|
currentStatus={currentStatus || 'SUBMITTED'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</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 */}
|
{/* Team overview */}
|
||||||
<AnimatedCard index={3}>
|
<AnimatedCard index={5}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -326,8 +407,32 @@ export default function ApplicantDashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</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 */}
|
{/* Key dates */}
|
||||||
<AnimatedCard index={4}>
|
<AnimatedCard index={7}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Key Dates</CardTitle>
|
<CardTitle>Key Dates</CardTitle>
|
||||||
@@ -347,12 +452,6 @@ export default function ApplicantDashboardPage() {
|
|||||||
<span className="text-muted-foreground">Last Updated</span>
|
<span className="text-muted-foreground">Last Updated</span>
|
||||||
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
|
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|||||||
122
src/app/(applicant)/applicant/resources/[id]/page.tsx
Normal file
122
src/app/(applicant)/applicant/resources/[id]/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
146
src/app/(applicant)/applicant/resources/page.tsx
Normal file
146
src/app/(applicant)/applicant/resources/page.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
'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,6 +280,15 @@ export default function ApplicantTeamPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
122
src/app/(jury)/jury/learning/[id]/page.tsx
Normal file
122
src/app/(jury)/jury/learning/[id]/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'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,41 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Video,
|
|
||||||
Link as LinkIcon,
|
|
||||||
File,
|
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
} from 'lucide-react'
|
} 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() {
|
export default function JuryLearningPage() {
|
||||||
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -81,7 +62,6 @@ export default function JuryLearningPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resources = data?.resources || []
|
const resources = data?.resources || []
|
||||||
const userCohortLevel = data?.userCohortLevel || 'ALL'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -90,11 +70,6 @@ export default function JuryLearningPage() {
|
|||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Educational resources for jury members
|
Educational resources for jury members
|
||||||
</p>
|
</p>
|
||||||
{userCohortLevel !== 'ALL' && (
|
|
||||||
<Badge className={cohortColors[userCohortLevel]} variant="outline">
|
|
||||||
Your access level: {userCohortLevel}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{resources.length === 0 ? (
|
{resources.length === 0 ? (
|
||||||
@@ -110,14 +85,14 @@ export default function JuryLearningPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{resources.map((resource) => {
|
{resources.map((resource) => {
|
||||||
const Icon = resourceTypeIcons[resource.resourceType]
|
|
||||||
const isDownloading = downloadingId === resource.id
|
const isDownloading = downloadingId === resource.id
|
||||||
|
const hasContent = !!resource.contentJson
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={resource.id}>
|
<Card key={resource.id}>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<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">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||||
<Icon className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-medium">{resource.title}</h3>
|
<h3 className="font-medium">{resource.title}</h3>
|
||||||
@@ -126,36 +101,39 @@ export default function JuryLearningPage() {
|
|||||||
{resource.description}
|
{resource.description}
|
||||||
</p>
|
</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>
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
{resource.externalUrl ? (
|
{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 && (
|
||||||
<a
|
<a
|
||||||
href={resource.externalUrl}
|
href={resource.externalUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<Button>
|
<Button variant="outline" size="sm">
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
Open
|
Open
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
) : resource.objectKey ? (
|
)}
|
||||||
|
{resource.objectKey && (
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={() => handleDownload(resource.id)}
|
onClick={() => handleDownload(resource.id)}
|
||||||
disabled={isDownloading}
|
disabled={isDownloading}
|
||||||
>
|
>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
{isDownloading ? 'Loading...' : 'Download'}
|
{isDownloading ? 'Loading...' : 'Download'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
122
src/app/(mentor)/mentor/resources/[id]/page.tsx
Normal file
122
src/app/(mentor)/mentor/resources/[id]/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'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,38 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Video,
|
|
||||||
Link as LinkIcon,
|
|
||||||
File,
|
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
} from 'lucide-react'
|
} 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() {
|
export default function MentorResourcesPage() {
|
||||||
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -101,14 +85,14 @@ export default function MentorResourcesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{resources.map((resource) => {
|
{resources.map((resource) => {
|
||||||
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
|
|
||||||
const isDownloading = downloadingId === resource.id
|
const isDownloading = downloadingId === resource.id
|
||||||
|
const hasContent = !!resource.contentJson
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={resource.id}>
|
<Card key={resource.id}>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<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">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||||
<Icon className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-medium">{resource.title}</h3>
|
<h3 className="font-medium">{resource.title}</h3>
|
||||||
@@ -117,36 +101,39 @@ export default function MentorResourcesPage() {
|
|||||||
{resource.description}
|
{resource.description}
|
||||||
</p>
|
</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>
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
{resource.externalUrl ? (
|
{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 && (
|
||||||
<a
|
<a
|
||||||
href={resource.externalUrl}
|
href={resource.externalUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<Button>
|
<Button variant="outline" size="sm">
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
Open
|
Open
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
) : resource.objectKey ? (
|
)}
|
||||||
|
{resource.objectKey && (
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={() => handleDownload(resource.id)}
|
onClick={() => handleDownload(resource.id)}
|
||||||
disabled={isDownloading}
|
disabled={isDownloading}
|
||||||
>
|
>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
{isDownloading ? 'Loading...' : 'Download'}
|
{isDownloading ? 'Loading...' : 'Download'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
// Delete projects where isDraft=true AND draftExpiresAt has passed
|
// Delete projects where isDraft=true AND draftExpiresAt has passed
|
||||||
// Exclude test projects — they are managed separately
|
|
||||||
const result = await prisma.project.deleteMany({
|
const result = await prisma.project.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
isTest: false,
|
|
||||||
isDraft: true,
|
isDraft: true,
|
||||||
draftExpiresAt: {
|
draftExpiresAt: {
|
||||||
lt: now,
|
lt: now,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { Metadata } from 'next'
|
|||||||
import './globals.css'
|
import './globals.css'
|
||||||
import { Providers } from './providers'
|
import { Providers } from './providers'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@@ -23,10 +22,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className="min-h-screen bg-background font-sans antialiased">
|
<body className="min-h-screen bg-background font-sans antialiased">
|
||||||
<Providers>
|
<Providers>{children}</Providers>
|
||||||
<ImpersonationBanner />
|
|
||||||
{children}
|
|
||||||
</Providers>
|
|
||||||
<Toaster
|
<Toaster
|
||||||
position="top-right"
|
position="top-right"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
|
|||||||
654
src/components/admin/round/submission-window-manager.tsx
Normal file
654
src/components/admin/round/submission-window-manager.tsx
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
'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,6 +25,14 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
|
|||||||
update('advancementConfig', { ...advancementConfig, [key]: value })
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Scoring */}
|
{/* Scoring */}
|
||||||
@@ -202,6 +210,71 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Advancement */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -4,36 +4,17 @@ import { trpc } from '@/lib/trpc/client'
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { CheckCircle2, Circle, Clock } from 'lucide-react'
|
import { CheckCircle2, Circle, Clock, XCircle, Trophy } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
interface ApplicantCompetitionTimelineProps {
|
const roundStatusDisplay: Record<string, { label: string; variant: 'default' | 'secondary' }> = {
|
||||||
competitionId: string
|
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' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusIcons: Record<string, React.ElementType> = {
|
export function ApplicantCompetitionTimeline() {
|
||||||
completed: CheckCircle2,
|
const { data, isLoading } = trpc.applicant.getMyCompetitionTimeline.useQuery()
|
||||||
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -52,7 +33,7 @@ export function ApplicantCompetitionTimeline({ competitionId }: ApplicantCompeti
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!competition || !competition.rounds || competition.rounds.length === 0) {
|
if (!data || data.entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -60,77 +41,117 @@ export function ApplicantCompetitionTimeline({ competitionId }: ApplicantCompeti
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-center py-8">
|
<CardContent className="text-center py-8">
|
||||||
<Circle className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
<Circle className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||||
<p className="text-sm text-muted-foreground">No rounds available</p>
|
<p className="text-sm text-muted-foreground">No rounds available yet</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rounds = competition.rounds || []
|
|
||||||
const currentRoundIndex = rounds.findIndex(r => r.status === 'ROUND_ACTIVE')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Competition Timeline</CardTitle>
|
<CardTitle>Competition Timeline</CardTitle>
|
||||||
|
{data.competitionName && (
|
||||||
|
<p className="text-sm text-muted-foreground">{data.competitionName}</p>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="relative space-y-6">
|
<div className="relative space-y-6">
|
||||||
{/* Vertical connecting line */}
|
{/* Vertical connecting line */}
|
||||||
<div className="absolute left-5 top-5 bottom-5 w-0.5 bg-border" />
|
<div className="absolute left-5 top-5 bottom-5 w-0.5 bg-border" />
|
||||||
|
|
||||||
{rounds.map((round, index) => {
|
{data.entries.map((entry) => {
|
||||||
const isActive = round.status === 'ROUND_ACTIVE'
|
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
|
||||||
const isCompleted = index < currentRoundIndex || round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED'
|
const isActive = entry.status === 'ROUND_ACTIVE'
|
||||||
const isCurrent = index === currentRoundIndex || isActive
|
const isRejected = entry.projectState === 'REJECTED'
|
||||||
const status = isCompleted ? 'completed' : isCurrent ? 'current' : 'upcoming'
|
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
|
||||||
const Icon = statusIcons[status]
|
|
||||||
|
// 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 }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={round.id} className="relative flex items-start gap-4">
|
<div key={entry.id} className="relative flex items-start gap-4">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div
|
<div
|
||||||
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full ${statusBgColors[status]} shrink-0`}
|
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full ${iconBg} shrink-0`}
|
||||||
>
|
>
|
||||||
<Icon className={`h-5 w-5 ${statusColors[status]}`} />
|
<Icon className={`h-5 w-5 ${iconColor}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0 pb-6">
|
<div className="flex-1 min-w-0 pb-6">
|
||||||
<div className="flex items-start justify-between flex-wrap gap-2 mb-2">
|
<div className="flex items-start justify-between flex-wrap gap-2 mb-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">{round.name}</h3>
|
<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>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{round.windowOpenAt && round.windowCloseAt && (
|
{entry.windowOpenAt && entry.windowCloseAt && (
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
<p>
|
<p>Opens: {new Date(entry.windowOpenAt).toLocaleDateString()}</p>
|
||||||
Opens: {new Date(round.windowOpenAt).toLocaleDateString()}
|
<p>Closes: {new Date(entry.windowCloseAt).toLocaleDateString()}</p>
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Closes: {new Date(round.windowCloseAt).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -142,3 +163,76 @@ export function ApplicantCompetitionTimeline({ competitionId }: ApplicantCompeti
|
|||||||
</Card>
|
</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,8 +226,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||||||
{navigation.map((item) => {
|
{navigation.map((item) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
pathname === item.href ||
|
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 (
|
return (
|
||||||
<div key={item.name}>
|
<div key={item.name}>
|
||||||
<Link
|
<Link
|
||||||
@@ -259,24 +258,12 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||||||
Administration
|
Administration
|
||||||
</p>
|
</p>
|
||||||
{dynamicAdminNav.map((item) => {
|
{dynamicAdminNav.map((item) => {
|
||||||
const isDisabled = item.name === 'Apply Page' && !currentEdition?.id
|
|
||||||
let isActive = pathname.startsWith(item.href)
|
let isActive = pathname.startsWith(item.href)
|
||||||
if (item.activeMatch) {
|
if (item.activeMatch) {
|
||||||
isActive = pathname.includes(item.activeMatch)
|
isActive = pathname.includes(item.activeMatch)
|
||||||
} else if (item.activeExclude && pathname.includes(item.activeExclude)) {
|
} else if (item.activeExclude && pathname.includes(item.activeExclude)) {
|
||||||
isActive = false
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Home, Users, FileText, MessageSquare, Layers } from 'lucide-react'
|
import { Home, Users, FileText, MessageSquare, Trophy, Star, BookOpen } from 'lucide-react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||||
|
|
||||||
interface ApplicantNavProps {
|
interface ApplicantNavProps {
|
||||||
@@ -8,32 +9,22 @@ interface ApplicantNavProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ApplicantNav({ user }: ApplicantNavProps) {
|
export function ApplicantNav({ user }: ApplicantNavProps) {
|
||||||
|
const { data: flags } = trpc.applicant.getNavFlags.useQuery(undefined, {
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
const navigation: NavItem[] = [
|
const navigation: NavItem[] = [
|
||||||
{
|
{ name: 'Dashboard', href: '/applicant', icon: Home },
|
||||||
name: 'Dashboard',
|
{ name: 'Team', href: '/applicant/team', icon: Users },
|
||||||
href: '/applicant',
|
{ name: 'Competition', href: '/applicant/competition', icon: Trophy },
|
||||||
icon: Home,
|
{ name: 'Documents', href: '/applicant/documents', icon: FileText },
|
||||||
},
|
...(flags?.hasEvaluationRounds
|
||||||
{
|
? [{ name: 'Evaluations', href: '/applicant/evaluations', icon: Star }]
|
||||||
name: 'Team',
|
: []),
|
||||||
href: '/applicant/team',
|
...(flags?.hasMentor
|
||||||
icon: Users,
|
? [{ name: 'Mentoring', href: '/applicant/mentor', icon: MessageSquare }]
|
||||||
},
|
: []),
|
||||||
{
|
{ name: 'Resources', href: '/applicant/resources', icon: BookOpen },
|
||||||
name: 'Competitions',
|
|
||||||
href: '/applicant/competitions',
|
|
||||||
icon: Layers,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Documents',
|
|
||||||
href: '/applicant/documents',
|
|
||||||
icon: FileText,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Mentoring',
|
|
||||||
href: '/applicant/mentor',
|
|
||||||
icon: MessageSquare,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -25,13 +25,11 @@ import {
|
|||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Webhook,
|
Webhook,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
FlaskConical,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { AISettingsForm } from './ai-settings-form'
|
import { AISettingsForm } from './ai-settings-form'
|
||||||
import { AIUsageCard } from './ai-usage-card'
|
import { AIUsageCard } from './ai-usage-card'
|
||||||
import { TestEnvironmentPanel } from './test-environment-panel'
|
|
||||||
import { BrandingSettingsForm } from './branding-settings-form'
|
import { BrandingSettingsForm } from './branding-settings-form'
|
||||||
import { EmailSettingsForm } from './email-settings-form'
|
import { EmailSettingsForm } from './email-settings-form'
|
||||||
import { StorageSettingsForm } from './storage-settings-form'
|
import { StorageSettingsForm } from './storage-settings-form'
|
||||||
@@ -228,12 +226,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||||||
Webhooks
|
Webhooks
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{isSuperAdmin && (
|
|
||||||
<TabsTrigger value="testenv" className="gap-2 shrink-0">
|
|
||||||
<FlaskConical className="h-4 w-4" />
|
|
||||||
Test Env
|
|
||||||
</TabsTrigger>
|
|
||||||
)}
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="lg:flex lg:gap-8">
|
<div className="lg:flex lg:gap-8">
|
||||||
@@ -327,12 +319,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||||||
Webhooks
|
Webhooks
|
||||||
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
|
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -527,28 +513,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</TabsContent>
|
</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 content area */}
|
||||||
</div>{/* end lg:flex */}
|
</div>{/* end lg:flex */}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -1,297 +0,0 @@
|
|||||||
'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 { useEffect, useMemo, useState } from 'react'
|
||||||
import { useCreateBlockNote } from '@blocknote/react'
|
import { useCreateBlockNote } from '@blocknote/react'
|
||||||
import { BlockNoteView } from '@blocknote/mantine'
|
import { BlockNoteView } from '@blocknote/shadcn'
|
||||||
import '@blocknote/core/fonts/inter.css'
|
import '@blocknote/core/fonts/inter.css'
|
||||||
import '@blocknote/mantine/style.css'
|
import '@blocknote/shadcn/style.css'
|
||||||
|
|
||||||
import type { PartialBlock } from '@blocknote/core'
|
import type { PartialBlock } from '@blocknote/core'
|
||||||
|
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
71
src/components/shared/resource-renderer.tsx
Normal file
71
src/components/shared/resource-renderer.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
'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,11 +10,6 @@ declare module 'next-auth' {
|
|||||||
name?: string | null
|
name?: string | null
|
||||||
role: UserRole
|
role: UserRole
|
||||||
mustSetPassword?: boolean
|
mustSetPassword?: boolean
|
||||||
// Impersonation fields
|
|
||||||
isImpersonating?: boolean
|
|
||||||
realUserId?: string
|
|
||||||
realRole?: UserRole
|
|
||||||
impersonatedName?: string | null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,12 +24,6 @@ declare module '@auth/core/jwt' {
|
|||||||
id: string
|
id: string
|
||||||
role: UserRole
|
role: UserRole
|
||||||
mustSetPassword?: boolean
|
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: {
|
callbacks: {
|
||||||
...authConfig.callbacks,
|
...authConfig.callbacks,
|
||||||
async jwt({ token, user, trigger, session: sessionUpdate }) {
|
async jwt({ token, user, trigger }) {
|
||||||
// Initial sign in
|
// Initial sign in
|
||||||
if (user) {
|
if (user) {
|
||||||
token.id = user.id as string
|
token.id = user.id as string
|
||||||
@@ -198,48 +198,15 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
token.mustSetPassword = user.mustSetPassword
|
token.mustSetPassword = user.mustSetPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
// On session update
|
// On session update, refresh from database
|
||||||
if (trigger === 'update') {
|
if (trigger === 'update') {
|
||||||
// Handle impersonation request
|
const dbUser = await prisma.user.findUnique({
|
||||||
if (sessionUpdate?.impersonateUserId) {
|
where: { id: token.id as string },
|
||||||
const testUser = await prisma.user.findUnique({
|
select: { role: true, mustSetPassword: true },
|
||||||
where: { id: sessionUpdate.impersonateUserId },
|
})
|
||||||
select: { id: true, name: true, email: true, role: true, isTest: true },
|
if (dbUser) {
|
||||||
})
|
token.role = dbUser.role
|
||||||
// Only allow impersonating test users with @test.local emails
|
token.mustSetPassword = dbUser.mustSetPassword
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,15 +217,6 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
session.user.id = token.id as string
|
session.user.id = token.id as string
|
||||||
session.user.role = token.role as UserRole
|
session.user.role = token.role as UserRole
|
||||||
session.user.mustSetPassword = token.mustSetPassword as boolean | undefined
|
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
|
return session
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,32 +7,6 @@ let cachedTransporter: Transporter | null = null
|
|||||||
let cachedConfigHash = ''
|
let cachedConfigHash = ''
|
||||||
let cachedFrom = ''
|
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.
|
* Get SMTP transporter using database settings with env var fallback.
|
||||||
* Caches the transporter and rebuilds it when settings change.
|
* Caches the transporter and rebuilds it when settings change.
|
||||||
@@ -73,31 +47,12 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create new transporter
|
// Create new transporter
|
||||||
const rawTransporter = nodemailer.createTransport({
|
cachedTransporter = nodemailer.createTransport({
|
||||||
host,
|
host,
|
||||||
port: parseInt(port),
|
port: parseInt(port),
|
||||||
secure: port === '465',
|
secure: port === '465',
|
||||||
auth: { user, pass },
|
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
|
cachedConfigHash = configHash
|
||||||
cachedFrom = from
|
cachedFrom = from
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import { roundEngineRouter } from './roundEngine'
|
|||||||
import { roundAssignmentRouter } from './roundAssignment'
|
import { roundAssignmentRouter } from './roundAssignment'
|
||||||
import { deliberationRouter } from './deliberation'
|
import { deliberationRouter } from './deliberation'
|
||||||
import { resultLockRouter } from './resultLock'
|
import { resultLockRouter } from './resultLock'
|
||||||
import { testEnvironmentRouter } from './testEnvironment'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root tRPC router that combines all domain routers
|
* Root tRPC router that combines all domain routers
|
||||||
@@ -109,8 +108,6 @@ export const appRouter = router({
|
|||||||
roundAssignment: roundAssignmentRouter,
|
roundAssignment: roundAssignmentRouter,
|
||||||
deliberation: deliberationRouter,
|
deliberation: deliberationRouter,
|
||||||
resultLock: resultLockRouter,
|
resultLock: resultLockRouter,
|
||||||
// Test environment
|
|
||||||
testEnvironment: testEnvironmentRouter,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter
|
export type AppRouter = typeof appRouter
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ const editionOrRoundInput = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function projectWhere(input: { roundId?: string; programId?: string }) {
|
function projectWhere(input: { roundId?: string; programId?: string }) {
|
||||||
if (input.roundId) return { isTest: false, projectRoundStates: { some: { roundId: input.roundId } } }
|
if (input.roundId) return { projectRoundStates: { some: { roundId: input.roundId } } }
|
||||||
return { isTest: false, programId: input.programId! }
|
return { programId: input.programId! }
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignmentWhere(input: { roundId?: string; programId?: string }) {
|
function assignmentWhere(input: { roundId?: string; programId?: string }) {
|
||||||
@@ -263,7 +263,7 @@ export const analyticsRouter = router({
|
|||||||
if (round?.roundType === 'EVALUATION') {
|
if (round?.roundType === 'EVALUATION') {
|
||||||
// For evaluation rounds, break down by evaluation status per project
|
// For evaluation rounds, break down by evaluation status per project
|
||||||
const projects = await ctx.prisma.projectRoundState.findMany({
|
const projects = await ctx.prisma.projectRoundState.findMany({
|
||||||
where: { roundId: input.roundId, project: { isTest: false } },
|
where: { roundId: input.roundId },
|
||||||
select: {
|
select: {
|
||||||
projectId: true,
|
projectId: true,
|
||||||
project: {
|
project: {
|
||||||
@@ -309,7 +309,7 @@ export const analyticsRouter = router({
|
|||||||
// Non-evaluation rounds: use ProjectRoundState
|
// Non-evaluation rounds: use ProjectRoundState
|
||||||
const states = await ctx.prisma.projectRoundState.groupBy({
|
const states = await ctx.prisma.projectRoundState.groupBy({
|
||||||
by: ['state'],
|
by: ['state'],
|
||||||
where: { roundId: input.roundId, project: { isTest: false } },
|
where: { roundId: input.roundId },
|
||||||
_count: true,
|
_count: true,
|
||||||
})
|
})
|
||||||
return states.map((s) => ({
|
return states.map((s) => ({
|
||||||
@@ -469,8 +469,8 @@ export const analyticsRouter = router({
|
|||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const where = input.roundId
|
const where = input.roundId
|
||||||
? { isTest: false, assignments: { some: { roundId: input.roundId } } }
|
? { assignments: { some: { roundId: input.roundId } } }
|
||||||
: { isTest: false, programId: input.programId }
|
: { programId: input.programId }
|
||||||
|
|
||||||
const distribution = await ctx.prisma.project.groupBy({
|
const distribution = await ctx.prisma.project.groupBy({
|
||||||
by: ['country'],
|
by: ['country'],
|
||||||
@@ -537,7 +537,7 @@ export const analyticsRouter = router({
|
|||||||
|
|
||||||
// Count distinct projects per round via assignments
|
// Count distinct projects per round via assignments
|
||||||
const projectAssignments = await ctx.prisma.assignment.findMany({
|
const projectAssignments = await ctx.prisma.assignment.findMany({
|
||||||
where: { roundId: { in: roundIds }, project: { isTest: false } },
|
where: { roundId: { in: roundIds } },
|
||||||
select: { roundId: true, projectId: true },
|
select: { roundId: true, projectId: true },
|
||||||
distinct: ['roundId', 'projectId'],
|
distinct: ['roundId', 'projectId'],
|
||||||
})
|
})
|
||||||
@@ -714,14 +714,12 @@ export const analyticsRouter = router({
|
|||||||
const roundId = input?.roundId
|
const roundId = input?.roundId
|
||||||
|
|
||||||
const projectFilter = roundId
|
const projectFilter = roundId
|
||||||
? { isTest: false, projectRoundStates: { some: { roundId } } }
|
? { projectRoundStates: { some: { roundId } } }
|
||||||
: { isTest: false }
|
: {}
|
||||||
const assignmentFilter = roundId
|
const assignmentFilter = roundId ? { roundId } : {}
|
||||||
? { roundId }
|
|
||||||
: { round: { competition: { isTest: false } } }
|
|
||||||
const evalFilter = roundId
|
const evalFilter = roundId
|
||||||
? { assignment: { roundId }, status: 'SUBMITTED' as const }
|
? { assignment: { roundId }, status: 'SUBMITTED' as const }
|
||||||
: { assignment: { round: { competition: { isTest: false } } }, status: 'SUBMITTED' as const }
|
: { status: 'SUBMITTED' as const }
|
||||||
|
|
||||||
const [
|
const [
|
||||||
programCount,
|
programCount,
|
||||||
@@ -732,9 +730,9 @@ export const analyticsRouter = router({
|
|||||||
totalAssignments,
|
totalAssignments,
|
||||||
evaluationScores,
|
evaluationScores,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
ctx.prisma.program.count({ where: { isTest: false } }),
|
ctx.prisma.program.count(),
|
||||||
ctx.prisma.round.findMany({
|
ctx.prisma.round.findMany({
|
||||||
where: { status: 'ROUND_ACTIVE', competition: { isTest: false } },
|
where: { status: 'ROUND_ACTIVE' },
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
take: 5,
|
take: 5,
|
||||||
}),
|
}),
|
||||||
@@ -745,7 +743,7 @@ export const analyticsRouter = router({
|
|||||||
select: { userId: true },
|
select: { userId: true },
|
||||||
distinct: ['userId'],
|
distinct: ['userId'],
|
||||||
}).then((rows) => rows.length)
|
}).then((rows) => rows.length)
|
||||||
: ctx.prisma.user.count({ where: { isTest: false, role: 'JURY_MEMBER', status: 'ACTIVE' } }),
|
: ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }),
|
||||||
ctx.prisma.evaluation.count({ where: evalFilter }),
|
ctx.prisma.evaluation.count({ where: evalFilter }),
|
||||||
ctx.prisma.assignment.count({ where: assignmentFilter }),
|
ctx.prisma.assignment.count({ where: assignmentFilter }),
|
||||||
ctx.prisma.evaluation.findMany({
|
ctx.prisma.evaluation.findMany({
|
||||||
@@ -990,7 +988,7 @@ export const analyticsRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const where: Record<string, unknown> = { isTest: false }
|
const where: Record<string, unknown> = {}
|
||||||
|
|
||||||
if (input.roundId) {
|
if (input.roundId) {
|
||||||
where.projectRoundStates = { some: { roundId: input.roundId } }
|
where.projectRoundStates = { some: { roundId: input.roundId } }
|
||||||
@@ -1153,15 +1151,15 @@ export const analyticsRouter = router({
|
|||||||
switch (roundType) {
|
switch (roundType) {
|
||||||
case 'INTAKE': {
|
case 'INTAKE': {
|
||||||
const [total, byState, byCategory] = await Promise.all([
|
const [total, byState, byCategory] = await Promise.all([
|
||||||
ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId, project: { isTest: false } } }),
|
ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId } }),
|
||||||
ctx.prisma.projectRoundState.groupBy({
|
ctx.prisma.projectRoundState.groupBy({
|
||||||
by: ['state'],
|
by: ['state'],
|
||||||
where: { roundId: input.roundId, project: { isTest: false } },
|
where: { roundId: input.roundId },
|
||||||
_count: true,
|
_count: true,
|
||||||
}),
|
}),
|
||||||
ctx.prisma.project.groupBy({
|
ctx.prisma.project.groupBy({
|
||||||
by: ['competitionCategory'],
|
by: ['competitionCategory'],
|
||||||
where: { isTest: false, projectRoundStates: { some: { roundId: input.roundId } } },
|
where: { projectRoundStates: { some: { roundId: input.roundId } } },
|
||||||
_count: true,
|
_count: true,
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
@@ -1397,7 +1395,7 @@ export const analyticsRouter = router({
|
|||||||
// Get competition rounds for file grouping
|
// Get competition rounds for file grouping
|
||||||
let competitionRounds: { id: string; name: string; roundType: string }[] = []
|
let competitionRounds: { id: string; name: string; roundType: string }[] = []
|
||||||
const competition = await ctx.prisma.competition.findFirst({
|
const competition = await ctx.prisma.competition.findFirst({
|
||||||
where: { programId: projectRaw.programId, isTest: false },
|
where: { programId: projectRaw.programId },
|
||||||
include: { rounds: { select: { id: true, name: true, roundType: true }, orderBy: { sortOrder: 'asc' } } },
|
include: { rounds: { select: { id: true, name: true, roundType: true }, orderBy: { sortOrder: 'asc' } } },
|
||||||
})
|
})
|
||||||
if (competition) {
|
if (competition) {
|
||||||
@@ -1480,23 +1478,9 @@ export const analyticsRouter = router({
|
|||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const limit = input?.limit ?? 10
|
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({
|
const entries = await ctx.prisma.decisionAuditLog.findMany({
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: limit,
|
take: limit,
|
||||||
...(testUserIds.length > 0 && {
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ actorId: null },
|
|
||||||
{ actorId: { notIn: testUserIds } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
eventType: true,
|
eventType: true,
|
||||||
@@ -1512,7 +1496,7 @@ export const analyticsRouter = router({
|
|||||||
const actorIds = [...new Set(entries.map((e) => e.actorId).filter(Boolean))] as string[]
|
const actorIds = [...new Set(entries.map((e) => e.actorId).filter(Boolean))] as string[]
|
||||||
const actors = actorIds.length > 0
|
const actors = actorIds.length > 0
|
||||||
? await ctx.prisma.user.findMany({
|
? await ctx.prisma.user.findMany({
|
||||||
where: { id: { in: actorIds }, isTest: false },
|
where: { id: { in: actorIds } },
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/em
|
|||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
import { createNotification } from '../services/in-app-notification'
|
import { createNotification } from '../services/in-app-notification'
|
||||||
import { checkRequirementsAndTransition } from '../services/round-engine'
|
import { checkRequirementsAndTransition } from '../services/round-engine'
|
||||||
|
import { EvaluationConfigSchema } from '@/types/competition-configs'
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
// Bucket for applicant submissions
|
// Bucket for applicant submissions
|
||||||
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
|
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
|
||||||
@@ -1278,6 +1280,12 @@ export const applicantRouter = router({
|
|||||||
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
|
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
|
||||||
const isTeamLead = project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD'
|
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 {
|
return {
|
||||||
project: {
|
project: {
|
||||||
...project,
|
...project,
|
||||||
@@ -1287,6 +1295,461 @@ export const applicantRouter = router({
|
|||||||
openRounds,
|
openRounds,
|
||||||
timeline,
|
timeline,
|
||||||
currentStatus,
|
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') {
|
if (input.mode === 'edition') {
|
||||||
// Edition-wide application mode
|
// Edition-wide application mode
|
||||||
const program = await ctx.prisma.program.findFirst({
|
const program = await ctx.prisma.program.findFirst({
|
||||||
where: { slug: input.slug, isTest: false },
|
where: { slug: input.slug },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!program) {
|
if (!program) {
|
||||||
@@ -687,7 +687,6 @@ export const applicationRouter = router({
|
|||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
isDraft: true,
|
isDraft: true,
|
||||||
isTest: false,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -838,7 +837,6 @@ export const applicationRouter = router({
|
|||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
isDraft: true,
|
isDraft: true,
|
||||||
isTest: false,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -560,7 +560,6 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
|||||||
where: {
|
where: {
|
||||||
role: 'JURY_MEMBER',
|
role: 'JURY_MEMBER',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
isTest: false,
|
|
||||||
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -1256,7 +1255,6 @@ export const assignmentRouter = router({
|
|||||||
where: {
|
where: {
|
||||||
role: 'JURY_MEMBER',
|
role: 'JURY_MEMBER',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
isTest: false,
|
|
||||||
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -2071,6 +2069,762 @@ 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
|
* Get reshuffle history for a round — shows all dropout/COI reassignment events
|
||||||
* with per-project detail of where each project was moved to.
|
* with per-project detail of where each project was moved to.
|
||||||
@@ -2082,7 +2836,7 @@ export const assignmentRouter = router({
|
|||||||
const auditEntries = await ctx.prisma.auditLog.findMany({
|
const auditEntries = await ctx.prisma.auditLog.findMany({
|
||||||
where: {
|
where: {
|
||||||
entityType: { in: ['Round', 'Assignment'] },
|
entityType: { in: ['Round', 'Assignment'] },
|
||||||
action: { in: ['JUROR_DROPOUT_RESHUFFLE', 'COI_REASSIGNMENT'] },
|
action: { in: ['JUROR_DROPOUT_RESHUFFLE', 'COI_REASSIGNMENT', 'ASSIGNMENT_TRANSFER', 'CAP_REDISTRIBUTE'] },
|
||||||
entityId: input.roundId,
|
entityId: input.roundId,
|
||||||
},
|
},
|
||||||
orderBy: { timestamp: 'desc' },
|
orderBy: { timestamp: 'desc' },
|
||||||
@@ -2126,7 +2880,7 @@ export const assignmentRouter = router({
|
|||||||
|
|
||||||
type ReshuffleEvent = {
|
type ReshuffleEvent = {
|
||||||
id: string
|
id: string
|
||||||
type: 'DROPOUT' | 'COI'
|
type: 'DROPOUT' | 'COI' | 'TRANSFER' | 'CAP_REDISTRIBUTE'
|
||||||
timestamp: Date
|
timestamp: Date
|
||||||
performedBy: { name: string | null; email: string }
|
performedBy: { name: string | null; email: string }
|
||||||
droppedJuror: { id: string; name: string }
|
droppedJuror: { id: string; name: string }
|
||||||
@@ -2181,6 +2935,44 @@ export const assignmentRouter = router({
|
|||||||
failedProjects: (details.failedProjects as string[]) || [],
|
failedProjects: (details.failedProjects as string[]) || [],
|
||||||
moves: reconstructedMoves,
|
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() }))
|
.input(z.object({ programId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return ctx.prisma.competition.findMany({
|
return ctx.prisma.competition.findMany({
|
||||||
where: { programId: input.programId, isTest: false },
|
where: { programId: input.programId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
@@ -254,7 +254,7 @@ export const competitionRouter = router({
|
|||||||
const competitionIds = [...new Set(memberships.map((m) => m.juryGroup.competitionId))]
|
const competitionIds = [...new Set(memberships.map((m) => m.juryGroup.competitionId))]
|
||||||
if (competitionIds.length === 0) return []
|
if (competitionIds.length === 0) return []
|
||||||
return ctx.prisma.competition.findMany({
|
return ctx.prisma.competition.findMany({
|
||||||
where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' }, isTest: false },
|
where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' } },
|
||||||
include: {
|
include: {
|
||||||
rounds: {
|
rounds: {
|
||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
|||||||
@@ -172,19 +172,18 @@ export const dashboardRouter = router({
|
|||||||
|
|
||||||
// 7. Project count
|
// 7. Project count
|
||||||
ctx.prisma.project.count({
|
ctx.prisma.project.count({
|
||||||
where: { programId: editionId, isTest: false },
|
where: { programId: editionId },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 8. New projects this week
|
// 8. New projects this week
|
||||||
ctx.prisma.project.count({
|
ctx.prisma.project.count({
|
||||||
where: { programId: editionId, isTest: false, createdAt: { gte: sevenDaysAgo } },
|
where: { programId: editionId, createdAt: { gte: sevenDaysAgo } },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 9. Total jurors
|
// 9. Total jurors
|
||||||
ctx.prisma.user.count({
|
ctx.prisma.user.count({
|
||||||
where: {
|
where: {
|
||||||
role: 'JURY_MEMBER',
|
role: 'JURY_MEMBER',
|
||||||
isTest: false,
|
|
||||||
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
|
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
|
||||||
assignments: { some: { round: { competition: { programId: editionId } } } },
|
assignments: { some: { round: { competition: { programId: editionId } } } },
|
||||||
},
|
},
|
||||||
@@ -194,7 +193,6 @@ export const dashboardRouter = router({
|
|||||||
ctx.prisma.user.count({
|
ctx.prisma.user.count({
|
||||||
where: {
|
where: {
|
||||||
role: 'JURY_MEMBER',
|
role: 'JURY_MEMBER',
|
||||||
isTest: false,
|
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
assignments: { some: { round: { competition: { programId: editionId } } } },
|
assignments: { some: { round: { competition: { programId: editionId } } } },
|
||||||
},
|
},
|
||||||
@@ -214,7 +212,7 @@ export const dashboardRouter = router({
|
|||||||
|
|
||||||
// 13. Latest projects
|
// 13. Latest projects
|
||||||
ctx.prisma.project.findMany({
|
ctx.prisma.project.findMany({
|
||||||
where: { programId: editionId, isTest: false },
|
where: { programId: editionId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 8,
|
take: 8,
|
||||||
select: {
|
select: {
|
||||||
@@ -234,20 +232,20 @@ export const dashboardRouter = router({
|
|||||||
// 14. Category breakdown
|
// 14. Category breakdown
|
||||||
ctx.prisma.project.groupBy({
|
ctx.prisma.project.groupBy({
|
||||||
by: ['competitionCategory'],
|
by: ['competitionCategory'],
|
||||||
where: { programId: editionId, isTest: false },
|
where: { programId: editionId },
|
||||||
_count: true,
|
_count: true,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 15. Ocean issue breakdown
|
// 15. Ocean issue breakdown
|
||||||
ctx.prisma.project.groupBy({
|
ctx.prisma.project.groupBy({
|
||||||
by: ['oceanIssue'],
|
by: ['oceanIssue'],
|
||||||
where: { programId: editionId, isTest: false },
|
where: { programId: editionId },
|
||||||
_count: true,
|
_count: true,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 16. Recent activity (exclude test user actions)
|
// 16. Recent activity
|
||||||
ctx.prisma.auditLog.findMany({
|
ctx.prisma.auditLog.findMany({
|
||||||
where: { timestamp: { gte: sevenDaysAgo }, user: { isTest: false } },
|
where: { timestamp: { gte: sevenDaysAgo } },
|
||||||
orderBy: { timestamp: 'desc' },
|
orderBy: { timestamp: 'desc' },
|
||||||
take: 8,
|
take: 8,
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ export const exportRouter = router({
|
|||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
isTest: false,
|
|
||||||
assignments: { some: { roundId: input.roundId } },
|
assignments: { some: { roundId: input.roundId } },
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -356,7 +355,7 @@ export const exportRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logs = await ctx.prisma.auditLog.findMany({
|
const logs = await ctx.prisma.auditLog.findMany({
|
||||||
where: { ...where, user: { isTest: false } },
|
where,
|
||||||
orderBy: { timestamp: 'desc' },
|
orderBy: { timestamp: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
user: { select: { name: true, email: true } },
|
user: { select: { name: true, email: true } },
|
||||||
@@ -432,7 +431,7 @@ export const exportRouter = router({
|
|||||||
if (includeSection('summary')) {
|
if (includeSection('summary')) {
|
||||||
const [projectCount, assignmentCount, evaluationCount, jurorCount] = await Promise.all([
|
const [projectCount, assignmentCount, evaluationCount, jurorCount] = await Promise.all([
|
||||||
ctx.prisma.project.count({
|
ctx.prisma.project.count({
|
||||||
where: { isTest: false, assignments: { some: { roundId: input.roundId } } },
|
where: { assignments: { some: { roundId: input.roundId } } },
|
||||||
}),
|
}),
|
||||||
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
||||||
ctx.prisma.evaluation.count({
|
ctx.prisma.evaluation.count({
|
||||||
@@ -487,7 +486,7 @@ export const exportRouter = router({
|
|||||||
// Rankings
|
// Rankings
|
||||||
if (includeSection('rankings')) {
|
if (includeSection('rankings')) {
|
||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
where: { isTest: false, assignments: { some: { roundId: input.roundId } } },
|
where: { assignments: { some: { roundId: input.roundId } } },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
|
|||||||
@@ -994,7 +994,6 @@ export const fileRouter = router({
|
|||||||
// Build project filter
|
// Build project filter
|
||||||
const projectWhere: Record<string, unknown> = {
|
const projectWhere: Record<string, unknown> = {
|
||||||
programId: window.competition.programId,
|
programId: window.competition.programId,
|
||||||
isTest: false,
|
|
||||||
}
|
}
|
||||||
if (input.search) {
|
if (input.search) {
|
||||||
projectWhere.OR = [
|
projectWhere.OR = [
|
||||||
@@ -1304,7 +1303,6 @@ export const fileRouter = router({
|
|||||||
// Build project filter
|
// Build project filter
|
||||||
const projectWhere: Record<string, unknown> = {
|
const projectWhere: Record<string, unknown> = {
|
||||||
programId: round.competition.programId,
|
programId: round.competition.programId,
|
||||||
isTest: false,
|
|
||||||
}
|
}
|
||||||
if (input.search) {
|
if (input.search) {
|
||||||
projectWhere.OR = [
|
projectWhere.OR = [
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
|||||||
roundId,
|
roundId,
|
||||||
exitedAt: null,
|
exitedAt: null,
|
||||||
state: { in: ['PENDING', 'IN_PROGRESS'] },
|
state: { in: ['PENDING', 'IN_PROGRESS'] },
|
||||||
project: { isTest: false },
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
project: {
|
project: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { Prisma } from '@prisma/client'
|
||||||
import {
|
import {
|
||||||
router,
|
router,
|
||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
@@ -11,6 +12,69 @@ import { logAudit } from '../utils/audit'
|
|||||||
// Bucket for learning resources
|
// Bucket for learning resources
|
||||||
export const LEARNING_BUCKET = 'mopc-learning'
|
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({
|
export const learningResourceRouter = router({
|
||||||
/**
|
/**
|
||||||
* List all resources (admin view)
|
* List all resources (admin view)
|
||||||
@@ -19,11 +83,9 @@ export const learningResourceRouter = router({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
programId: z.string().optional(),
|
programId: z.string().optional(),
|
||||||
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(),
|
|
||||||
cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).optional(),
|
|
||||||
isPublished: z.boolean().optional(),
|
isPublished: z.boolean().optional(),
|
||||||
page: z.number().int().min(1).default(1),
|
page: z.number().int().min(1).default(1),
|
||||||
perPage: z.number().int().min(1).max(100).default(20),
|
perPage: z.number().int().min(1).max(100).default(50),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
@@ -32,12 +94,6 @@ export const learningResourceRouter = router({
|
|||||||
if (input.programId !== undefined) {
|
if (input.programId !== undefined) {
|
||||||
where.programId = input.programId
|
where.programId = input.programId
|
||||||
}
|
}
|
||||||
if (input.resourceType) {
|
|
||||||
where.resourceType = input.resourceType
|
|
||||||
}
|
|
||||||
if (input.cohortLevel) {
|
|
||||||
where.cohortLevel = input.cohortLevel
|
|
||||||
}
|
|
||||||
if (input.isPublished !== undefined) {
|
if (input.isPublished !== undefined) {
|
||||||
where.isPublished = input.isPublished
|
where.isPublished = input.isPublished
|
||||||
}
|
}
|
||||||
@@ -67,71 +123,41 @@ export const learningResourceRouter = router({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get resources accessible to the current user (jury view)
|
* Get resources accessible to the current user (jury/mentor/observer view)
|
||||||
*/
|
*/
|
||||||
myResources: protectedProcedure
|
myResources: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
programId: z.string().optional(),
|
programId: z.string().optional(),
|
||||||
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(),
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.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> = {
|
const where: Record<string, unknown> = {
|
||||||
isPublished: true,
|
isPublished: true,
|
||||||
cohortLevel: { in: cohortLevels },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.programId) {
|
if (input.programId) {
|
||||||
where.OR = [{ programId: input.programId }, { programId: null }]
|
where.OR = [{ programId: input.programId }, { programId: null }]
|
||||||
}
|
}
|
||||||
if (input.resourceType) {
|
|
||||||
where.resourceType = input.resourceType
|
|
||||||
}
|
|
||||||
|
|
||||||
const resources = await ctx.prisma.learningResource.findMany({
|
const resources = await ctx.prisma.learningResource.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
// Filter by access rules in application code (small dataset)
|
||||||
resources,
|
const accessible = []
|
||||||
userCohortLevel,
|
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: accessible }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,7 +175,8 @@ export const learningResourceRouter = router({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Check access for non-admins
|
// Check access for non-admins
|
||||||
if (ctx.user.role === 'JURY_MEMBER') {
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||||
|
if (!isAdmin) {
|
||||||
if (!resource.isPublished) {
|
if (!resource.isPublished) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
@@ -157,39 +184,13 @@ export const learningResourceRouter = router({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cohort level access
|
const allowed = await canUserAccessResource(
|
||||||
const assignments = await ctx.prisma.assignment.findMany({
|
ctx.prisma,
|
||||||
where: { userId: ctx.user.id },
|
ctx.user.id,
|
||||||
include: {
|
ctx.user.role,
|
||||||
project: {
|
resource.accessJson,
|
||||||
select: {
|
)
|
||||||
status: true,
|
if (!allowed) {
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
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({
|
throw new TRPCError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
message: 'You do not have access to this resource',
|
message: 'You do not have access to this resource',
|
||||||
@@ -202,7 +203,6 @@ export const learningResourceRouter = router({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get download URL for a resource file
|
* Get download URL for a resource file
|
||||||
* Checks cohort level access for non-admin users
|
|
||||||
*/
|
*/
|
||||||
getDownloadUrl: protectedProcedure
|
getDownloadUrl: protectedProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
@@ -228,39 +228,13 @@ export const learningResourceRouter = router({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cohort level access
|
const allowed = await canUserAccessResource(
|
||||||
const assignments = await ctx.prisma.assignment.findMany({
|
ctx.prisma,
|
||||||
where: { userId: ctx.user.id },
|
ctx.user.id,
|
||||||
include: {
|
ctx.user.role,
|
||||||
project: {
|
resource.accessJson,
|
||||||
select: {
|
)
|
||||||
status: true,
|
if (!allowed) {
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
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({
|
throw new TRPCError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
message: 'You do not have access to this resource',
|
message: 'You do not have access to this resource',
|
||||||
@@ -281,6 +255,22 @@ export const learningResourceRouter = router({
|
|||||||
return { url }
|
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)
|
* Create a new resource (admin only)
|
||||||
*/
|
*/
|
||||||
@@ -291,9 +281,9 @@ export const learningResourceRouter = router({
|
|||||||
title: z.string().min(1).max(255),
|
title: z.string().min(1).max(255),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
contentJson: z.any().optional(), // BlockNote document structure
|
contentJson: z.any().optional(), // BlockNote document structure
|
||||||
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']),
|
accessJson: accessRuleSchema.array().nullable().optional(),
|
||||||
cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).default('ALL'),
|
|
||||||
externalUrl: z.string().url().optional(),
|
externalUrl: z.string().url().optional(),
|
||||||
|
coverImageKey: z.string().optional(),
|
||||||
sortOrder: z.number().int().default(0),
|
sortOrder: z.number().int().default(0),
|
||||||
isPublished: z.boolean().default(false),
|
isPublished: z.boolean().default(false),
|
||||||
// File info (set after upload)
|
// File info (set after upload)
|
||||||
@@ -305,9 +295,12 @@ export const learningResourceRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { accessJson, ...rest } = input
|
||||||
|
|
||||||
const resource = await ctx.prisma.learningResource.create({
|
const resource = await ctx.prisma.learningResource.create({
|
||||||
data: {
|
data: {
|
||||||
...input,
|
...rest,
|
||||||
|
accessJson: accessJson === null ? Prisma.JsonNull : accessJson ?? undefined,
|
||||||
createdById: ctx.user.id,
|
createdById: ctx.user.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -319,7 +312,7 @@ export const learningResourceRouter = router({
|
|||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
entityType: 'LearningResource',
|
entityType: 'LearningResource',
|
||||||
entityId: resource.id,
|
entityId: resource.id,
|
||||||
detailsJson: { title: input.title, resourceType: input.resourceType },
|
detailsJson: { title: input.title },
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
@@ -335,11 +328,12 @@ export const learningResourceRouter = router({
|
|||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
title: z.string().min(1).max(255).optional(),
|
title: z.string().min(1).max(255).optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional().nullable(),
|
||||||
contentJson: z.any().optional(), // BlockNote document structure
|
contentJson: z.any().optional(), // BlockNote document structure
|
||||||
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(),
|
accessJson: accessRuleSchema.array().nullable().optional(),
|
||||||
cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).optional(),
|
|
||||||
externalUrl: z.string().url().optional().nullable(),
|
externalUrl: z.string().url().optional().nullable(),
|
||||||
|
coverImageKey: z.string().optional().nullable(),
|
||||||
|
programId: z.string().nullable().optional(),
|
||||||
sortOrder: z.number().int().optional(),
|
sortOrder: z.number().int().optional(),
|
||||||
isPublished: z.boolean().optional(),
|
isPublished: z.boolean().optional(),
|
||||||
// File info (set after upload)
|
// File info (set after upload)
|
||||||
@@ -351,7 +345,15 @@ export const learningResourceRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { id, ...data } = 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 resource = await ctx.prisma.learningResource.update({
|
const resource = await ctx.prisma.learningResource.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -365,7 +367,7 @@ export const learningResourceRouter = router({
|
|||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
entityType: 'LearningResource',
|
entityType: 'LearningResource',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
detailsJson: data,
|
detailsJson: rest,
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -420,7 +420,6 @@ export const mentorRouter = router({
|
|||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
programId: input.programId,
|
programId: input.programId,
|
||||||
isTest: false,
|
|
||||||
mentorAssignment: null,
|
mentorAssignment: null,
|
||||||
wantsMentorship: true,
|
wantsMentorship: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ async function resolveRecipients(
|
|||||||
const role = filter?.role as string
|
const role = filter?.role as string
|
||||||
if (!role) return []
|
if (!role) return []
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: { role: role as any, status: 'ACTIVE', isTest: false },
|
where: { role: role as any, status: 'ACTIVE' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
return users.map((u) => u.id)
|
return users.map((u) => u.id)
|
||||||
@@ -412,7 +412,7 @@ async function resolveRecipients(
|
|||||||
const targetRoundId = roundId || (filter?.roundId as string)
|
const targetRoundId = roundId || (filter?.roundId as string)
|
||||||
if (!targetRoundId) return []
|
if (!targetRoundId) return []
|
||||||
const assignments = await prisma.assignment.findMany({
|
const assignments = await prisma.assignment.findMany({
|
||||||
where: { roundId: targetRoundId, user: { isTest: false } },
|
where: { roundId: targetRoundId },
|
||||||
select: { userId: true },
|
select: { userId: true },
|
||||||
distinct: ['userId'],
|
distinct: ['userId'],
|
||||||
})
|
})
|
||||||
@@ -423,7 +423,7 @@ async function resolveRecipients(
|
|||||||
const programId = filter?.programId as string
|
const programId = filter?.programId as string
|
||||||
if (!programId) return []
|
if (!programId) return []
|
||||||
const projects = await prisma.project.findMany({
|
const projects = await prisma.project.findMany({
|
||||||
where: { programId, isTest: false },
|
where: { programId },
|
||||||
select: { submittedByUserId: true },
|
select: { submittedByUserId: true },
|
||||||
})
|
})
|
||||||
const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[])
|
const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[])
|
||||||
@@ -432,7 +432,7 @@ async function resolveRecipients(
|
|||||||
|
|
||||||
case 'ALL': {
|
case 'ALL': {
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: { status: 'ACTIVE', isTest: false },
|
where: { status: 'ACTIVE' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
return users.map((u) => u.id)
|
return users.map((u) => u.id)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const programRouter = router({
|
|||||||
const includeStages = input?.includeStages || false
|
const includeStages = input?.includeStages || false
|
||||||
|
|
||||||
const programs = await ctx.prisma.program.findMany({
|
const programs = await ctx.prisma.program.findMany({
|
||||||
where: input?.status ? { isTest: false, status: input.status } : { isTest: false },
|
where: input?.status ? { status: input.status } : undefined,
|
||||||
orderBy: { year: 'desc' },
|
orderBy: { year: 'desc' },
|
||||||
include: includeStages
|
include: includeStages
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ export const projectPoolRouter = router({
|
|||||||
|
|
||||||
// Build where clause
|
// Build where clause
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
isTest: false,
|
|
||||||
programId,
|
programId,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +317,6 @@ export const projectPoolRouter = router({
|
|||||||
|
|
||||||
// Find projects to assign
|
// Find projects to assign
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
isTest: false,
|
|
||||||
programId,
|
programId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,9 +84,7 @@ export const projectRouter = router({
|
|||||||
const skip = (page - 1) * perPage
|
const skip = (page - 1) * perPage
|
||||||
|
|
||||||
// Build where clause
|
// Build where clause
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {}
|
||||||
isTest: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by program
|
// Filter by program
|
||||||
if (programId) where.programId = programId
|
if (programId) where.programId = programId
|
||||||
@@ -221,9 +219,7 @@ export const projectRouter = router({
|
|||||||
wantsMentorship, hasFiles, hasAssignments,
|
wantsMentorship, hasFiles, hasAssignments,
|
||||||
} = input
|
} = input
|
||||||
|
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {}
|
||||||
isTest: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (programId) where.programId = programId
|
if (programId) where.programId = programId
|
||||||
if (roundId) {
|
if (roundId) {
|
||||||
@@ -361,19 +357,19 @@ export const projectRouter = router({
|
|||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const [countries, categories, issues] = await Promise.all([
|
const [countries, categories, issues] = await Promise.all([
|
||||||
ctx.prisma.project.findMany({
|
ctx.prisma.project.findMany({
|
||||||
where: { isTest: false, country: { not: null } },
|
where: { country: { not: null } },
|
||||||
select: { country: true },
|
select: { country: true },
|
||||||
distinct: ['country'],
|
distinct: ['country'],
|
||||||
orderBy: { country: 'asc' },
|
orderBy: { country: 'asc' },
|
||||||
}),
|
}),
|
||||||
ctx.prisma.project.groupBy({
|
ctx.prisma.project.groupBy({
|
||||||
by: ['competitionCategory'],
|
by: ['competitionCategory'],
|
||||||
where: { isTest: false, competitionCategory: { not: null } },
|
where: { competitionCategory: { not: null } },
|
||||||
_count: true,
|
_count: true,
|
||||||
}),
|
}),
|
||||||
ctx.prisma.project.groupBy({
|
ctx.prisma.project.groupBy({
|
||||||
by: ['oceanIssue'],
|
by: ['oceanIssue'],
|
||||||
where: { isTest: false, oceanIssue: { not: null } },
|
where: { oceanIssue: { not: null } },
|
||||||
_count: true,
|
_count: true,
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
@@ -842,7 +838,7 @@ export const projectRouter = router({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
where: { id: { in: input.ids }, isTest: false },
|
where: { id: { in: input.ids } },
|
||||||
select: { id: true, title: true, status: true },
|
select: { id: true, title: true, status: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -952,13 +948,11 @@ export const projectRouter = router({
|
|||||||
programId: z.string().optional(),
|
programId: z.string().optional(),
|
||||||
}))
|
}))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {}
|
||||||
isTest: false,
|
|
||||||
}
|
|
||||||
if (input.programId) where.programId = input.programId
|
if (input.programId) where.programId = input.programId
|
||||||
|
|
||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
where,
|
where: Object.keys(where).length > 0 ? where : undefined,
|
||||||
select: { tags: true },
|
select: { tags: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -990,7 +984,6 @@ export const projectRouter = router({
|
|||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: input.ids },
|
id: { in: input.ids },
|
||||||
isTest: false,
|
|
||||||
},
|
},
|
||||||
select: { id: true, title: true },
|
select: { id: true, title: true },
|
||||||
})
|
})
|
||||||
@@ -1109,7 +1102,6 @@ export const projectRouter = router({
|
|||||||
|
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
programId,
|
programId,
|
||||||
isTest: false,
|
|
||||||
projectRoundStates: { none: {} }, // Projects not assigned to any round
|
projectRoundStates: { none: {} }, // Projects not assigned to any round
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const specialAwardRouter = router({
|
|||||||
let { competition } = award
|
let { competition } = award
|
||||||
if (!competition && award.programId) {
|
if (!competition && award.programId) {
|
||||||
const comp = await ctx.prisma.competition.findFirst({
|
const comp = await ctx.prisma.competition.findFirst({
|
||||||
where: { programId: award.programId, isTest: false },
|
where: { programId: award.programId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' as const } } },
|
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
|
let competitionId = input.competitionId
|
||||||
if (!competitionId) {
|
if (!competitionId) {
|
||||||
const comp = await ctx.prisma.competition.findFirst({
|
const comp = await ctx.prisma.competition.findFirst({
|
||||||
where: { programId: input.programId, isTest: false },
|
where: { programId: input.programId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
@@ -217,7 +217,7 @@ export const specialAwardRouter = router({
|
|||||||
})
|
})
|
||||||
if (existing && !existing.competitionId) {
|
if (existing && !existing.competitionId) {
|
||||||
const comp = await ctx.prisma.competition.findFirst({
|
const comp = await ctx.prisma.competition.findFirst({
|
||||||
where: { programId: existing.programId, isTest: false },
|
where: { programId: existing.programId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
@@ -404,7 +404,7 @@ export const specialAwardRouter = router({
|
|||||||
const { awardId, eligibleOnly, page, perPage } = input
|
const { awardId, eligibleOnly, page, perPage } = input
|
||||||
const skip = (page - 1) * perPage
|
const skip = (page - 1) * perPage
|
||||||
|
|
||||||
const where: Record<string, unknown> = { awardId, project: { isTest: false } }
|
const where: Record<string, unknown> = { awardId }
|
||||||
if (eligibleOnly) where.eligible = true
|
if (eligibleOnly) where.eligible = true
|
||||||
|
|
||||||
const [eligibilities, total] = await Promise.all([
|
const [eligibilities, total] = await Promise.all([
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ async function runTaggingJob(jobId: string, userId: string) {
|
|||||||
if (!job.programId) {
|
if (!job.programId) {
|
||||||
throw new Error('Job must have a programId')
|
throw new Error('Job must have a programId')
|
||||||
}
|
}
|
||||||
const whereClause = { programId: job.programId, isTest: false }
|
const whereClause = { programId: job.programId }
|
||||||
|
|
||||||
const allProjects = await prisma.project.findMany({
|
const allProjects = await prisma.project.findMany({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
@@ -196,13 +196,11 @@ export const tagRouter = router({
|
|||||||
const userCount = await ctx.prisma.user.count({
|
const userCount = await ctx.prisma.user.count({
|
||||||
where: {
|
where: {
|
||||||
expertiseTags: { has: tag.name },
|
expertiseTags: { has: tag.name },
|
||||||
isTest: false,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const projectCount = await ctx.prisma.project.count({
|
const projectCount = await ctx.prisma.project.count({
|
||||||
where: {
|
where: {
|
||||||
tags: { has: tag.name },
|
tags: { has: tag.name },
|
||||||
isTest: false,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
@@ -230,10 +228,10 @@ export const tagRouter = router({
|
|||||||
// Get usage counts
|
// Get usage counts
|
||||||
const [userCount, projectCount] = await Promise.all([
|
const [userCount, projectCount] = await Promise.all([
|
||||||
ctx.prisma.user.count({
|
ctx.prisma.user.count({
|
||||||
where: { expertiseTags: { has: tag.name }, isTest: false },
|
where: { expertiseTags: { has: tag.name } },
|
||||||
}),
|
}),
|
||||||
ctx.prisma.project.count({
|
ctx.prisma.project.count({
|
||||||
where: { tags: { has: tag.name }, isTest: false },
|
where: { tags: { has: tag.name } },
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -356,7 +354,7 @@ export const tagRouter = router({
|
|||||||
|
|
||||||
// Update users
|
// Update users
|
||||||
const usersWithTag = await ctx.prisma.user.findMany({
|
const usersWithTag = await ctx.prisma.user.findMany({
|
||||||
where: { expertiseTags: { has: oldTag.name }, isTest: false },
|
where: { expertiseTags: { has: oldTag.name } },
|
||||||
select: { id: true, expertiseTags: true },
|
select: { id: true, expertiseTags: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -373,7 +371,7 @@ export const tagRouter = router({
|
|||||||
|
|
||||||
// Update projects
|
// Update projects
|
||||||
const projectsWithTag = await ctx.prisma.project.findMany({
|
const projectsWithTag = await ctx.prisma.project.findMany({
|
||||||
where: { tags: { has: oldTag.name }, isTest: false },
|
where: { tags: { has: oldTag.name } },
|
||||||
select: { id: true, tags: true },
|
select: { id: true, tags: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -414,9 +412,9 @@ export const tagRouter = router({
|
|||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Remove tag from all users (excluding test users)
|
// Remove tag from all users
|
||||||
const usersWithTag = await ctx.prisma.user.findMany({
|
const usersWithTag = await ctx.prisma.user.findMany({
|
||||||
where: { expertiseTags: { has: tag.name }, isTest: false },
|
where: { expertiseTags: { has: tag.name } },
|
||||||
select: { id: true, expertiseTags: true },
|
select: { id: true, expertiseTags: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -429,9 +427,9 @@ export const tagRouter = router({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove tag from all projects (excluding test projects)
|
// Remove tag from all projects
|
||||||
const projectsWithTag = await ctx.prisma.project.findMany({
|
const projectsWithTag = await ctx.prisma.project.findMany({
|
||||||
where: { tags: { has: tag.name }, isTest: false },
|
where: { tags: { has: tag.name } },
|
||||||
select: { id: true, tags: true },
|
select: { id: true, tags: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
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,9 +249,7 @@ export const userRouter = router({
|
|||||||
const { role, roles, status, search, page, perPage } = input
|
const { role, roles, status, search, page, perPage } = input
|
||||||
const skip = (page - 1) * perPage
|
const skip = (page - 1) * perPage
|
||||||
|
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {}
|
||||||
isTest: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (roles && roles.length > 0) {
|
if (roles && roles.length > 0) {
|
||||||
where.role = { in: roles }
|
where.role = { in: roles }
|
||||||
@@ -318,7 +316,6 @@ export const userRouter = router({
|
|||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
isTest: false,
|
|
||||||
status: { in: ['NONE', 'INVITED'] },
|
status: { in: ['NONE', 'INVITED'] },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -932,7 +929,6 @@ export const userRouter = router({
|
|||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
isTest: false,
|
|
||||||
role: 'JURY_MEMBER',
|
role: 'JURY_MEMBER',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,7 +290,6 @@ export async function generateSummary({
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
isTest: true,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -298,10 +297,6 @@ export async function generateSummary({
|
|||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
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
|
// Fetch submitted evaluations for this project in this round
|
||||||
const evaluations = await prisma.evaluation.findMany({
|
const evaluations = await prisma.evaluation.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ async function generateCategoryShortlist(
|
|||||||
const projects = await prisma.project.findMany({
|
const projects = await prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
competitionCategory: category,
|
competitionCategory: category,
|
||||||
isTest: false,
|
|
||||||
assignments: { some: { roundId } },
|
assignments: { some: { roundId } },
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -473,10 +473,6 @@ export async function tagProject(
|
|||||||
throw new Error(`Project not found: ${projectId}`)
|
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
|
// Get available tags
|
||||||
const availableTags = await getAvailableTags()
|
const availableTags = await getAvailableTags()
|
||||||
if (availableTags.length === 0) {
|
if (availableTags.length === 0) {
|
||||||
@@ -578,7 +574,7 @@ export async function tagProjectsBatch(
|
|||||||
|
|
||||||
// Fetch full project data for all projects at once (single DB query)
|
// Fetch full project data for all projects at once (single DB query)
|
||||||
const fullProjects = await prisma.project.findMany({
|
const fullProjects = await prisma.project.findMany({
|
||||||
where: { id: { in: projects.map((p) => p.id) }, isTest: false },
|
where: { id: { in: projects.map((p) => p.id) } },
|
||||||
include: {
|
include: {
|
||||||
projectTags: true,
|
projectTags: true,
|
||||||
files: { select: { fileType: true } },
|
files: { select: { fileType: true } },
|
||||||
@@ -716,10 +712,6 @@ export async function getTagSuggestions(
|
|||||||
throw new Error(`Project not found: ${projectId}`)
|
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
|
// Get available tags
|
||||||
const availableTags = await getAvailableTags()
|
const availableTags = await getAvailableTags()
|
||||||
if (availableTags.length === 0) {
|
if (availableTags.length === 0) {
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ export async function processEligibilityJob(
|
|||||||
where: {
|
where: {
|
||||||
id: { in: passedIds },
|
id: { in: passedIds },
|
||||||
programId: award.programId,
|
programId: award.programId,
|
||||||
isTest: false,
|
|
||||||
},
|
},
|
||||||
select: projectSelect,
|
select: projectSelect,
|
||||||
})
|
})
|
||||||
@@ -100,7 +99,6 @@ export async function processEligibilityJob(
|
|||||||
projects = await prisma.project.findMany({
|
projects = await prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
programId: award.programId,
|
programId: award.programId,
|
||||||
isTest: false,
|
|
||||||
status: { in: [...statusFilter] },
|
status: { in: [...statusFilter] },
|
||||||
},
|
},
|
||||||
select: projectSelect,
|
select: projectSelect,
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ export async function analyzeAllUnanalyzed(): Promise<{
|
|||||||
total: number
|
total: number
|
||||||
}> {
|
}> {
|
||||||
const files = await prisma.projectFile.findMany({
|
const files = await prisma.projectFile.findMany({
|
||||||
where: { analyzedAt: null, project: { isTest: false } },
|
where: { analyzedAt: null },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
objectKey: true,
|
objectKey: true,
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export async function processDigests(
|
|||||||
// Find users who opted in for this digest frequency
|
// Find users who opted in for this digest frequency
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
isTest: false,
|
|
||||||
digestFrequency: type,
|
digestFrequency: type,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export async function sendManualReminders(roundId: string): Promise<ReminderResu
|
|||||||
if (usersToNotify.length === 0) return { sent, errors }
|
if (usersToNotify.length === 0) return { sent, errors }
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: { id: { in: usersToNotify }, isTest: false },
|
where: { id: { in: usersToNotify } },
|
||||||
select: { id: true, name: true, email: true },
|
select: { id: true, name: true, email: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -133,7 +133,6 @@ export async function processEvaluationReminders(roundId?: string): Promise<Remi
|
|||||||
status: 'ROUND_ACTIVE' as const,
|
status: 'ROUND_ACTIVE' as const,
|
||||||
windowCloseAt: { gt: now },
|
windowCloseAt: { gt: now },
|
||||||
windowOpenAt: { lte: now },
|
windowOpenAt: { lte: now },
|
||||||
competition: { isTest: false },
|
|
||||||
...(roundId && { id: roundId }),
|
...(roundId && { id: roundId }),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -214,7 +213,7 @@ async function sendRemindersForRound(
|
|||||||
|
|
||||||
// Get user details and their pending counts
|
// Get user details and their pending counts
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: { id: { in: usersToNotify }, isTest: false },
|
where: { id: { in: usersToNotify } },
|
||||||
select: { id: true, name: true, email: true },
|
select: { id: true, name: true, email: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -297,7 +297,6 @@ export async function notifyAdmins(params: {
|
|||||||
where: {
|
where: {
|
||||||
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
|
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
isTest: false,
|
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -232,7 +232,6 @@ export async function getAIMentorSuggestionsBatch(
|
|||||||
{ role: 'JURY_MEMBER' },
|
{ role: 'JURY_MEMBER' },
|
||||||
],
|
],
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
isTest: false,
|
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -459,7 +458,6 @@ export async function getRoundRobinMentor(
|
|||||||
{ role: 'JURY_MEMBER' },
|
{ role: 'JURY_MEMBER' },
|
||||||
],
|
],
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
isTest: false,
|
|
||||||
id: { notIn: excludeMentorIds },
|
id: { notIn: excludeMentorIds },
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function sendNotification(
|
|||||||
): Promise<NotificationResult> {
|
): Promise<NotificationResult> {
|
||||||
// Get user with notification preferences
|
// Get user with notification preferences
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId, isTest: false },
|
where: { id: userId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export async function previewRoundAssignment(
|
|||||||
|
|
||||||
// Load jury group members
|
// Load jury group members
|
||||||
const members = await db.juryGroupMember.findMany({
|
const members = await db.juryGroupMember.findMany({
|
||||||
where: { juryGroupId: ctx.juryGroup.id, user: { isTest: false } },
|
where: { juryGroupId: ctx.juryGroup.id },
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, name: true, email: true, role: true, bio: true, expertiseTags: true, country: true, availabilityJson: true } },
|
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
|
// Find a SUPER_ADMIN to use as the actor for audit logging
|
||||||
const systemActor = await prisma.user.findFirst({
|
const systemActor = await prisma.user.findFirst({
|
||||||
where: { role: 'SUPER_ADMIN', isTest: false },
|
where: { role: 'SUPER_ADMIN' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ export async function processScheduledRounds(): Promise<{
|
|||||||
where: {
|
where: {
|
||||||
status: 'ROUND_DRAFT',
|
status: 'ROUND_DRAFT',
|
||||||
windowOpenAt: { lte: now },
|
windowOpenAt: { lte: now },
|
||||||
competition: { status: { not: 'ARCHIVED' }, isTest: false },
|
competition: { status: { not: 'ARCHIVED' } },
|
||||||
},
|
},
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
})
|
})
|
||||||
@@ -49,7 +49,6 @@ export async function processScheduledRounds(): Promise<{
|
|||||||
where: {
|
where: {
|
||||||
status: 'ROUND_ACTIVE',
|
status: 'ROUND_ACTIVE',
|
||||||
windowCloseAt: { lte: now },
|
windowCloseAt: { lte: now },
|
||||||
competition: { isTest: false },
|
|
||||||
},
|
},
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -362,7 +362,6 @@ export async function getSmartSuggestions(options: {
|
|||||||
where: {
|
where: {
|
||||||
role,
|
role,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
isTest: false,
|
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -684,7 +683,6 @@ export async function getMentorSuggestionsForProject(
|
|||||||
where: {
|
where: {
|
||||||
role: 'MENTOR',
|
role: 'MENTOR',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
isTest: false,
|
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
@@ -1,676 +0,0 @@
|
|||||||
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,6 +109,20 @@ export const EvaluationConfigSchema = z.object({
|
|||||||
generateAiShortlist: z.boolean().default(false),
|
generateAiShortlist: z.boolean().default(false),
|
||||||
aiParseFiles: 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
|
advancementMode: z
|
||||||
.enum(['auto_top_n', 'admin_selection', 'ai_recommended'])
|
.enum(['auto_top_n', 'admin_selection', 'ai_recommended'])
|
||||||
.default('admin_selection'),
|
.default('admin_selection'),
|
||||||
|
|||||||
Reference in New Issue
Block a user