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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1015,8 +1002,7 @@ model LearningResource {
|
|||||||
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?
|
||||||
@@ -1025,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?
|
||||||
|
|
||||||
@@ -1041,7 +1030,6 @@ model LearningResource {
|
|||||||
accessLogs ResourceAccess[]
|
accessLogs ResourceAccess[]
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([cohortLevel])
|
|
||||||
@@index([isPublished])
|
@@index([isPublished])
|
||||||
@@index([sortOrder])
|
@@index([sortOrder])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,75 +159,89 @@ 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 className="flex gap-2">
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-8 w-64" />
|
</div>
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="flex-1 px-4 py-8">
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
<Skeleton className="h-64 w-full" />
|
<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" />
|
<Skeleton className="h-96 w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6">
|
|
||||||
<Skeleton className="h-48 w-full" />
|
|
||||||
<Skeleton className="h-32 w-full" />
|
|
||||||
</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,37 +268,172 @@ 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">
|
||||||
|
<Button
|
||||||
|
variant={previewing ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPreviewing(!previewing)}
|
||||||
|
>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
{previewing ? 'Edit' : 'Preview'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent className="overflow-y-auto">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Resource Settings</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Configure publishing, access, and metadata
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-6">
|
||||||
|
{/* Publish toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Edit Resource</h1>
|
<Label>Published</Label>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Update this learning resource
|
Make visible to users
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isPublished}
|
||||||
|
onCheckedChange={setIsPublished}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Program */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Program</Label>
|
||||||
|
<Select
|
||||||
|
value={programId || 'global'}
|
||||||
|
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Access Rules */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Access Rules</Label>
|
||||||
|
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="everyone">Everyone</SelectItem>
|
||||||
|
<SelectItem value="roles">By Role</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{accessMode === 'roles' && (
|
||||||
|
<div className="space-y-2 rounded-lg border p-3">
|
||||||
|
{ROLE_OPTIONS.map((role) => (
|
||||||
|
<label key={role.value} className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRoles.includes(role.value)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setSelectedRoles(
|
||||||
|
checked
|
||||||
|
? [...selectedRoles, role.value]
|
||||||
|
: selectedRoles.filter((r) => r !== role.value)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{role.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* External URL */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>External URL</Label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={externalUrl}
|
||||||
|
onChange={(e) => setExternalUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/resource"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Optional link to an external resource
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Statistics */}
|
||||||
|
{stats && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Statistics</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-4 rounded-lg border p-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Total views</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Unique users</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-destructive">Danger Zone</Label>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="outline" className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
<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" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
Delete Resource
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
|
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to delete "{resource.title}"? This action
|
Are you sure you want to delete "{resource.title}"?
|
||||||
cannot be undone.
|
This action cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -277,204 +451,66 @@ export default function EditLearningResourcePage() {
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
|
||||||
{/* Main content */}
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
|
||||||
{/* Basic Info */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Resource Details</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Basic information about this resource
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="title">Title *</Label>
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="e.g., Ocean Conservation Best Practices"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">Short Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="Brief description of this resource"
|
|
||||||
rows={2}
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="type">Resource Type</Label>
|
|
||||||
<Select value={resourceType} onValueChange={setResourceType}>
|
|
||||||
<SelectTrigger id="type">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{resourceTypeOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<option.icon className="h-4 w-4" />
|
|
||||||
{option.label}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="cohort">Access Level</Label>
|
|
||||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
|
||||||
<SelectTrigger id="cohort">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{cohortOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{resourceType === 'LINK' && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="url">External URL *</Label>
|
|
||||||
<Input
|
|
||||||
id="url"
|
|
||||||
type="url"
|
|
||||||
value={externalUrl}
|
|
||||||
onChange={(e) => setExternalUrl(e.target.value)}
|
|
||||||
placeholder="https://example.com/resource"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Content Editor */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Content</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Rich text content with images and videos. Type / for commands.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<BlockEditor
|
|
||||||
key={resourceId}
|
|
||||||
initialContent={contentJson || undefined}
|
|
||||||
onChange={setContentJson}
|
|
||||||
onUploadFile={handleUploadFile}
|
|
||||||
className="min-h-[300px]"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Publish Settings */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Publish Settings</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="published">Published</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Make this resource visible to jury members
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="published"
|
|
||||||
checked={isPublished}
|
|
||||||
onCheckedChange={setIsPublished}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="program">Program</Label>
|
|
||||||
<Select
|
|
||||||
value={programId || 'global'}
|
|
||||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="program">
|
|
||||||
<SelectValue placeholder="Select program" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
|
||||||
{programs?.map((program) => (
|
|
||||||
<SelectItem key={program.id} value={program.id}>
|
|
||||||
{program.year} Edition
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Statistics */}
|
|
||||||
{stats && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Eye className="h-5 w-5" />
|
|
||||||
Statistics
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">Total views</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">Unique users</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
|
size="sm"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={updateResource.isPending || !title.trim()}
|
disabled={updateResource.isPending || !title.trim()}
|
||||||
className="w-full"
|
|
||||||
>
|
>
|
||||||
{updateResource.isPending ? (
|
{updateResource.isPending ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Save Changes
|
Save
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild className="w-full">
|
|
||||||
<Link href="/admin/learning">Cancel</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="flex-1 px-4 py-8">
|
||||||
|
{previewing ? (
|
||||||
|
<ResourceRenderer
|
||||||
|
title={title || 'Untitled'}
|
||||||
|
description={description || null}
|
||||||
|
contentJson={contentJson ? JSON.parse(contentJson) : null}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
{/* Inline title */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Untitled"
|
||||||
|
className="w-full border-0 bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 focus:outline-none sm:text-4xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Inline description */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Add a description..."
|
||||||
|
className="w-full border-0 bg-transparent text-lg text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<hr className="border-border" />
|
||||||
|
|
||||||
|
{/* Block editor */}
|
||||||
|
<BlockEditor
|
||||||
|
key={resourceId}
|
||||||
|
initialContent={contentJson || undefined}
|
||||||
|
onChange={setContentJson}
|
||||||
|
onUploadFile={handleUploadFile}
|
||||||
|
className="min-h-[400px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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,161 +147,81 @@ 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>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Add Resource</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Create a new learning resource for jury members
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
|
||||||
{/* Main content */}
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
|
||||||
{/* Basic Info */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Resource Details</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Basic information about this resource
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="title">Title *</Label>
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="e.g., Ocean Conservation Best Practices"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">Short Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="Brief description of this resource"
|
|
||||||
rows={2}
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="type">Resource Type</Label>
|
|
||||||
<Select value={resourceType} onValueChange={setResourceType}>
|
|
||||||
<SelectTrigger id="type">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{resourceTypeOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<option.icon className="h-4 w-4" />
|
<Button
|
||||||
{option.label}
|
variant={previewing ? 'default' : 'outline'}
|
||||||
</div>
|
size="sm"
|
||||||
</SelectItem>
|
onClick={() => setPreviewing(!previewing)}
|
||||||
))}
|
>
|
||||||
</SelectContent>
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
</Select>
|
{previewing ? 'Edit' : 'Preview'}
|
||||||
</div>
|
</Button>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Sheet>
|
||||||
<Label htmlFor="cohort">Access Level</Label>
|
<SheetTrigger asChild>
|
||||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
<Button variant="outline" size="sm">
|
||||||
<SelectTrigger id="cohort">
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
<SelectValue />
|
Settings
|
||||||
</SelectTrigger>
|
</Button>
|
||||||
<SelectContent>
|
</SheetTrigger>
|
||||||
{cohortOptions.map((option) => (
|
<SheetContent className="overflow-y-auto">
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SheetHeader>
|
||||||
{option.label}
|
<SheetTitle>Resource Settings</SheetTitle>
|
||||||
</SelectItem>
|
<SheetDescription>
|
||||||
))}
|
Configure publishing, access, and metadata
|
||||||
</SelectContent>
|
</SheetDescription>
|
||||||
</Select>
|
</SheetHeader>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{resourceType === 'LINK' && (
|
<div className="mt-6 space-y-6">
|
||||||
<div className="space-y-2">
|
{/* Publish toggle */}
|
||||||
<Label htmlFor="url">External URL *</Label>
|
|
||||||
<Input
|
|
||||||
id="url"
|
|
||||||
type="url"
|
|
||||||
value={externalUrl}
|
|
||||||
onChange={(e) => setExternalUrl(e.target.value)}
|
|
||||||
placeholder="https://example.com/resource"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Content Editor */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Content</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Rich text content with images and videos. Type / for commands.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<BlockEditor
|
|
||||||
initialContent={contentJson || undefined}
|
|
||||||
onChange={setContentJson}
|
|
||||||
onUploadFile={handleUploadFile}
|
|
||||||
className="min-h-[300px]"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Publish Settings */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Publish Settings</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="published">Published</Label>
|
<Label>Published</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Make this resource visible to jury members
|
Make visible to users
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="published"
|
|
||||||
checked={isPublished}
|
checked={isPublished}
|
||||||
onCheckedChange={setIsPublished}
|
onCheckedChange={setIsPublished}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Program */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="program">Program</Label>
|
<Label>Program</Label>
|
||||||
<Select
|
<Select
|
||||||
value={programId || 'global'}
|
value={programId || 'global'}
|
||||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="program">
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select program" />
|
<SelectValue placeholder="Select program" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -295,32 +234,117 @@ export default function NewLearningResourcePage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Actions */}
|
<Separator />
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
{/* Access Rules */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="space-y-3">
|
||||||
|
<Label>Access Rules</Label>
|
||||||
|
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="everyone">Everyone</SelectItem>
|
||||||
|
<SelectItem value="roles">By Role</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{accessMode === 'roles' && (
|
||||||
|
<div className="space-y-2 rounded-lg border p-3">
|
||||||
|
{ROLE_OPTIONS.map((role) => (
|
||||||
|
<label key={role.value} className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRoles.includes(role.value)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setSelectedRoles(
|
||||||
|
checked
|
||||||
|
? [...selectedRoles, role.value]
|
||||||
|
: selectedRoles.filter((r) => r !== role.value)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{role.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* External URL */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>External URL</Label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={externalUrl}
|
||||||
|
onChange={(e) => setExternalUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/resource"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Optional link to an external resource
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
size="sm"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={createResource.isPending || !title.trim()}
|
disabled={createResource.isPending || !title.trim()}
|
||||||
className="w-full"
|
|
||||||
>
|
>
|
||||||
{createResource.isPending ? (
|
{createResource.isPending ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Create Resource
|
Save
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild className="w-full">
|
|
||||||
<Link href="/admin/learning">Cancel</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="flex-1 px-4 py-8">
|
||||||
|
{previewing ? (
|
||||||
|
<ResourceRenderer
|
||||||
|
title={title || 'Untitled'}
|
||||||
|
description={description || null}
|
||||||
|
contentJson={contentJson ? JSON.parse(contentJson) : null}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
{/* Inline title */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Untitled"
|
||||||
|
autoFocus
|
||||||
|
className="w-full border-0 bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 focus:outline-none sm:text-4xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Inline description */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Add a description..."
|
||||||
|
className="w-full border-0 bg-transparent text-lg text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<hr className="border-border" />
|
||||||
|
|
||||||
|
{/* Block editor */}
|
||||||
|
<BlockEditor
|
||||||
|
onChange={setContentJson}
|
||||||
|
onUploadFile={handleUploadFile}
|
||||||
|
className="min-h-[400px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div className="grid gap-3">
|
||||||
{/* Resource list skeleton */}
|
|
||||||
<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 types" />
|
<SelectValue placeholder="All" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All types</SelectItem>
|
<SelectItem value="all">All</SelectItem>
|
||||||
<SelectItem value="PDF">PDF</SelectItem>
|
<SelectItem value="published">Published</SelectItem>
|
||||||
<SelectItem value="VIDEO">Video</SelectItem>
|
<SelectItem value="draft">Drafts</SelectItem>
|
||||||
<SelectItem value="DOCUMENT">Document</SelectItem>
|
|
||||||
<SelectItem value="LINK">Link</SelectItem>
|
|
||||||
<SelectItem value="OTHER">Other</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
|
||||||
<Icon className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-medium truncate">{resource.title}</h3>
|
|
||||||
{!resource.isPublished && (
|
|
||||||
<Badge variant="secondary">Draft</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Badge className={cohortColors[resource.cohortLevel] || ''} variant="outline">
|
|
||||||
{resource.cohortLevel}
|
|
||||||
</Badge>
|
|
||||||
<span>{resource.resourceType}</span>
|
|
||||||
<span>-</span>
|
|
||||||
<span>{resource._count.accessLogs} views</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{resource.externalUrl && (
|
|
||||||
<a
|
|
||||||
href={resource.externalUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="icon">
|
<SortableContext
|
||||||
<ExternalLink className="h-4 w-4" />
|
items={filteredResources.map((r) => r.id)}
|
||||||
</Button>
|
strategy={verticalListSortingStrategy}
|
||||||
</a>
|
>
|
||||||
)}
|
<div className="grid gap-3">
|
||||||
<Link href={`/admin/learning/${resource.id}`}>
|
{filteredResources.map((resource) => (
|
||||||
<Button variant="ghost" size="icon">
|
<SortableResourceCard
|
||||||
<Pencil className="h-4 w-4" />
|
key={resource.id}
|
||||||
</Button>
|
resource={resource}
|
||||||
</Link>
|
onTogglePublished={handleTogglePublished}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</SortableContext>
|
||||||
</Card>
|
</DndContext>
|
||||||
)
|
) : resources.length > 0 ? (
|
||||||
})}
|
|
||||||
</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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,6 +254,7 @@ export default function ApplicantDashboardPage() {
|
|||||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{project.mentorAssignment && (
|
||||||
<Link href={"/applicant/mentor" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-green-500/30 hover:bg-green-500/5">
|
<Link href={"/applicant/mentor" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-green-500/30 hover:bg-green-500/5">
|
||||||
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
|
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
|
||||||
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
@@ -247,34 +262,100 @@ export default function ApplicantDashboardPage() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium">Mentor</p>
|
<p className="text-sm font-medium">Mentor</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{project.mentorAssignment?.mentor?.name || 'Not assigned'}
|
{project.mentorAssignment.mentor?.name || 'Assigned'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
</Link>
|
</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>
|
||||||
|
{hasPassedIntake ? (
|
||||||
|
<CompetitionTimelineSidebar />
|
||||||
|
) : (
|
||||||
<StatusTracker
|
<StatusTracker
|
||||||
timeline={timeline}
|
timeline={timeline}
|
||||||
currentStatus={currentStatus || 'SUBMITTED'}
|
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 className="flex items-center gap-2">
|
||||||
<div>
|
{hasContent && (
|
||||||
{resource.externalUrl ? (
|
<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 className="flex items-center gap-2">
|
||||||
<div>
|
{hasContent && (
|
||||||
{resource.externalUrl ? (
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{stateLabel && (
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant="outline"
|
||||||
status === 'completed'
|
|
||||||
? 'default'
|
|
||||||
: status === 'current'
|
|
||||||
? 'default'
|
|
||||||
: 'secondary'
|
|
||||||
}
|
|
||||||
className={
|
className={
|
||||||
status === 'completed'
|
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'
|
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||||
: status === 'current'
|
: isActive
|
||||||
? 'bg-brand-blue text-white'
|
? 'bg-brand-blue text-white'
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{status === 'completed' && 'Completed'}
|
{statusInfo.label}
|
||||||
{status === 'current' && 'In Progress'}
|
|
||||||
{status === 'upcoming' && 'Upcoming'}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2069,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.
|
||||||
@@ -2080,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' },
|
||||||
@@ -2124,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 }
|
||||||
@@ -2179,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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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