Add jury assignment transfer, cap redistribution, and learning hub overhaul
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m19s
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m19s
- Add getTransferCandidates/transferAssignments procedures for targeted assignment moves between jurors with TOCTOU guards and audit logging - Add getOverCapPreview/redistributeOverCap for auto-redistributing assignments when a juror's cap is lowered below their current load - Add TransferAssignmentsDialog (2-step: select projects, pick destinations) - Extend InlineMemberCap with over-cap detection and redistribute banner - Extend getReassignmentHistory to show ASSIGNMENT_TRANSFER and CAP_REDISTRIBUTE events - Learning hub: replace ResourceType/CohortLevel enums with accessJson JSONB, add coverImageKey, resource detail pages for jury/mentor, shared renderer - Migration: 20260221200000_learning_hub_overhaul Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
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
|
||||||
@@ -1010,13 +997,12 @@ model NotificationEmailSetting {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
model LearningResource {
|
model LearningResource {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
programId String? // null = global resource
|
programId String? // null = global resource
|
||||||
title String
|
title String
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
contentJson Json? @db.JsonB // BlockNote document structure
|
contentJson Json? @db.JsonB // BlockNote document structure
|
||||||
resourceType ResourceType
|
accessJson Json? @db.JsonB // Fine-grained access rules
|
||||||
cohortLevel CohortLevel @default(ALL)
|
|
||||||
|
|
||||||
// File storage (for uploaded resources)
|
// File storage (for uploaded resources)
|
||||||
fileName String?
|
fileName String?
|
||||||
@@ -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,74 +159,88 @@ export default function EditLearningResourcePage() {
|
|||||||
await fetch(url, {
|
await fetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: file,
|
body: file,
|
||||||
headers: {
|
headers: { 'Content-Type': file.type },
|
||||||
'Content-Type': file.type,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to upload file')
|
toast.error('Failed to upload file')
|
||||||
throw error
|
throw new Error('Upload failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const buildAccessJson = (): AccessRule[] | null => {
|
||||||
|
if (accessMode === 'everyone') return null
|
||||||
|
if (accessMode === 'roles' && selectedRoles.length > 0) {
|
||||||
|
return [{ type: 'roles', roles: selectedRoles }]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
toast.error('Please enter a title')
|
toast.error('Please enter a title')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceType === 'LINK' && !externalUrl) {
|
|
||||||
toast.error('Please enter an external URL')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateResource.mutateAsync({
|
await updateResource.mutateAsync({
|
||||||
id: resourceId,
|
id: resourceId,
|
||||||
|
programId,
|
||||||
title,
|
title,
|
||||||
description: description || undefined,
|
description: description || null,
|
||||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
accessJson: buildAccessJson(),
|
||||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
|
||||||
externalUrl: externalUrl || null,
|
externalUrl: externalUrl || null,
|
||||||
isPublished,
|
isPublished,
|
||||||
})
|
})
|
||||||
|
|
||||||
toast.success('Resource updated successfully')
|
toast.success('Resource updated')
|
||||||
router.push('/admin/learning')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
|
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
|
||||||
}
|
}
|
||||||
}
|
}, [title, description, contentJson, externalUrl, isPublished, programId, accessMode, selectedRoles, resourceId])
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteResource.mutateAsync({ id: resourceId })
|
await deleteResource.mutateAsync({ id: resourceId })
|
||||||
toast.success('Resource deleted successfully')
|
toast.success('Resource deleted')
|
||||||
router.push('/admin/learning')
|
router.push('/admin/learning')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
|
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+S save
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [handleSubmit])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex min-h-screen flex-col">
|
||||||
<div className="flex items-center gap-4">
|
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2">
|
||||||
<Skeleton className="h-9 w-40" />
|
<Skeleton className="h-8 w-20" />
|
||||||
</div>
|
<div className="flex gap-2">
|
||||||
<Skeleton className="h-8 w-64" />
|
<Skeleton className="h-8 w-20" />
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<Skeleton className="h-8 w-20" />
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<Skeleton className="h-8 w-16" />
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
<Skeleton className="h-96 w-full" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6">
|
</div>
|
||||||
<Skeleton className="h-48 w-full" />
|
<div className="flex-1 px-4 py-8">
|
||||||
<Skeleton className="h-32 w-full" />
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
<Skeleton className="h-10 w-2/3" />
|
||||||
|
<Skeleton className="h-6 w-1/3" />
|
||||||
|
<Skeleton className="h-px w-full" />
|
||||||
|
<Skeleton className="h-96 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,7 +249,7 @@ export default function EditLearningResourcePage() {
|
|||||||
|
|
||||||
if (error || !resource) {
|
if (error || !resource) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 p-6">
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Resource not found</AlertTitle>
|
<AlertTitle>Resource not found</AlertTitle>
|
||||||
@@ -229,253 +268,250 @@ export default function EditLearningResourcePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* Header */}
|
{/* Sticky toolbar */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href="/admin/learning">
|
<Link href="/admin/learning">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Learning Hub
|
Back
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-center gap-2">
|
||||||
<div>
|
<Button
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Edit Resource</h1>
|
variant={previewing ? 'default' : 'outline'}
|
||||||
<p className="text-muted-foreground">
|
size="sm"
|
||||||
Update this learning resource
|
onClick={() => setPreviewing(!previewing)}
|
||||||
</p>
|
>
|
||||||
</div>
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
<AlertDialog>
|
{previewing ? 'Edit' : 'Preview'}
|
||||||
<AlertDialogTrigger asChild>
|
</Button>
|
||||||
<Button variant="outline" className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Are you sure you want to delete "{resource.title}"? This action
|
|
||||||
cannot be undone.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={handleDelete}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
{deleteResource.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : null}
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<Sheet>
|
||||||
{/* Main content */}
|
<SheetTrigger asChild>
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<Button variant="outline" size="sm">
|
||||||
{/* Basic Info */}
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
<Card>
|
Settings
|
||||||
<CardHeader>
|
</Button>
|
||||||
<CardTitle>Resource Details</CardTitle>
|
</SheetTrigger>
|
||||||
<CardDescription>
|
<SheetContent className="overflow-y-auto">
|
||||||
Basic information about this resource
|
<SheetHeader>
|
||||||
</CardDescription>
|
<SheetTitle>Resource Settings</SheetTitle>
|
||||||
</CardHeader>
|
<SheetDescription>
|
||||||
<CardContent className="space-y-4">
|
Configure publishing, access, and metadata
|
||||||
<div className="space-y-2">
|
</SheetDescription>
|
||||||
<Label htmlFor="title">Title *</Label>
|
</SheetHeader>
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="e.g., Ocean Conservation Best Practices"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="mt-6 space-y-6">
|
||||||
<Label htmlFor="description">Short Description</Label>
|
{/* Publish toggle */}
|
||||||
<Textarea
|
<div className="flex items-center justify-between">
|
||||||
id="description"
|
<div>
|
||||||
value={description}
|
<Label>Published</Label>
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
<p className="text-sm text-muted-foreground">
|
||||||
placeholder="Brief description of this resource"
|
Make visible to users
|
||||||
rows={2}
|
</p>
|
||||||
maxLength={500}
|
</div>
|
||||||
/>
|
<Switch
|
||||||
</div>
|
checked={isPublished}
|
||||||
|
onCheckedChange={setIsPublished}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<Separator />
|
||||||
|
|
||||||
|
{/* Program */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type">Resource Type</Label>
|
<Label>Program</Label>
|
||||||
<Select value={resourceType} onValueChange={setResourceType}>
|
<Select
|
||||||
<SelectTrigger id="type">
|
value={programId || 'global'}
|
||||||
<SelectValue />
|
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select program" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{resourceTypeOptions.map((option) => (
|
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
{programs?.map((program) => (
|
||||||
<div className="flex items-center gap-2">
|
<SelectItem key={program.id} value={program.id}>
|
||||||
<option.icon className="h-4 w-4" />
|
{program.year} Edition
|
||||||
{option.label}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Separator />
|
||||||
<Label htmlFor="cohort">Access Level</Label>
|
|
||||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
{/* Access Rules */}
|
||||||
<SelectTrigger id="cohort">
|
<div className="space-y-3">
|
||||||
|
<Label>Access Rules</Label>
|
||||||
|
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
|
||||||
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{cohortOptions.map((option) => (
|
<SelectItem value="everyone">Everyone</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem value="roles">By Role</SelectItem>
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{resourceType === 'LINK' && (
|
{accessMode === 'roles' && (
|
||||||
|
<div className="space-y-2 rounded-lg border p-3">
|
||||||
|
{ROLE_OPTIONS.map((role) => (
|
||||||
|
<label key={role.value} className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRoles.includes(role.value)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setSelectedRoles(
|
||||||
|
checked
|
||||||
|
? [...selectedRoles, role.value]
|
||||||
|
: selectedRoles.filter((r) => r !== role.value)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{role.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* External URL */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="url">External URL *</Label>
|
<Label>External URL</Label>
|
||||||
<Input
|
<Input
|
||||||
id="url"
|
|
||||||
type="url"
|
type="url"
|
||||||
value={externalUrl}
|
value={externalUrl}
|
||||||
onChange={(e) => setExternalUrl(e.target.value)}
|
onChange={(e) => setExternalUrl(e.target.value)}
|
||||||
placeholder="https://example.com/resource"
|
placeholder="https://example.com/resource"
|
||||||
/>
|
/>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">
|
||||||
)}
|
Optional link to an external resource
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Content Editor */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Content</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Rich text content with images and videos. Type / for commands.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<BlockEditor
|
|
||||||
key={resourceId}
|
|
||||||
initialContent={contentJson || undefined}
|
|
||||||
onChange={setContentJson}
|
|
||||||
onUploadFile={handleUploadFile}
|
|
||||||
className="min-h-[300px]"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Publish Settings */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Publish Settings</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="published">Published</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Make this resource visible to jury members
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
|
||||||
id="published"
|
|
||||||
checked={isPublished}
|
|
||||||
onCheckedChange={setIsPublished}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Separator />
|
||||||
<Label htmlFor="program">Program</Label>
|
|
||||||
<Select
|
|
||||||
value={programId || 'global'}
|
|
||||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="program">
|
|
||||||
<SelectValue placeholder="Select program" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
|
||||||
{programs?.map((program) => (
|
|
||||||
<SelectItem key={program.id} value={program.id}>
|
|
||||||
{program.year} Edition
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Statistics */}
|
{/* Statistics */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<Card>
|
<div className="space-y-2">
|
||||||
<CardHeader>
|
<Label>Statistics</Label>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="grid grid-cols-2 gap-4 rounded-lg border p-3">
|
||||||
<Eye className="h-5 w-5" />
|
<div>
|
||||||
Statistics
|
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
||||||
</CardTitle>
|
<p className="text-xs text-muted-foreground">Total views</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
||||||
<div>
|
<p className="text-xs text-muted-foreground">Unique users</p>
|
||||||
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Total views</p>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">Unique users</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-destructive">Danger Zone</Label>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete Resource
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete "{resource.title}"?
|
||||||
|
This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{deleteResource.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={updateResource.isPending || !title.trim()}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{updateResource.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild className="w-full">
|
|
||||||
<Link href="/admin/learning">Cancel</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</SheetContent>
|
||||||
</Card>
|
</Sheet>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={updateResource.isPending || !title.trim()}
|
||||||
|
>
|
||||||
|
{updateResource.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="flex-1 px-4 py-8">
|
||||||
|
{previewing ? (
|
||||||
|
<ResourceRenderer
|
||||||
|
title={title || 'Untitled'}
|
||||||
|
description={description || null}
|
||||||
|
contentJson={contentJson ? JSON.parse(contentJson) : null}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
{/* Inline title */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Untitled"
|
||||||
|
className="w-full border-0 bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 focus:outline-none sm:text-4xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Inline description */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Add a description..."
|
||||||
|
className="w-full border-0 bg-transparent text-lg text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<hr className="border-border" />
|
||||||
|
|
||||||
|
{/* Block editor */}
|
||||||
|
<BlockEditor
|
||||||
|
key={resourceId}
|
||||||
|
initialContent={contentJson || undefined}
|
||||||
|
onChange={setContentJson}
|
||||||
|
onUploadFile={handleUploadFile}
|
||||||
|
className="min-h-[400px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
@@ -8,15 +8,9 @@ import { trpc } from '@/lib/trpc/client'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
Card,
|
import { Separator } from '@/components/ui/separator'
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -24,33 +18,57 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ArrowLeft, Save, Loader2, FileText, Video, Link as LinkIcon, File } from 'lucide-react'
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Save,
|
||||||
|
Loader2,
|
||||||
|
Settings,
|
||||||
|
Eye,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Dynamically import BlockEditor to avoid SSR issues
|
// Dynamically import editors to avoid SSR issues
|
||||||
const BlockEditor = dynamic(
|
const BlockEditor = dynamic(
|
||||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
<div className="mx-auto max-w-3xl min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const resourceTypeOptions = [
|
const ResourceRenderer = dynamic(
|
||||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
{
|
||||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
ssr: false,
|
||||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
loading: () => (
|
||||||
{ value: 'OTHER', label: 'Other', icon: File },
|
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const ROLE_OPTIONS = [
|
||||||
|
{ value: 'JURY_MEMBER', label: 'Jury Members' },
|
||||||
|
{ value: 'MENTOR', label: 'Mentors' },
|
||||||
|
{ value: 'OBSERVER', label: 'Observers' },
|
||||||
|
{ value: 'APPLICANT', label: 'Applicants' },
|
||||||
|
{ value: 'AWARD_MASTER', label: 'Award Masters' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const cohortOptions = [
|
type AccessRule =
|
||||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
| { type: 'everyone' }
|
||||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
| { type: 'roles'; roles: string[] }
|
||||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
| { type: 'jury_group'; juryGroupIds: string[] }
|
||||||
]
|
| { type: 'round'; roundIds: string[] }
|
||||||
|
|
||||||
export default function NewLearningResourcePage() {
|
export default function NewLearningResourcePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -59,14 +77,17 @@ export default function NewLearningResourcePage() {
|
|||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [contentJson, setContentJson] = useState<string>('')
|
const [contentJson, setContentJson] = useState<string>('')
|
||||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
|
||||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
|
||||||
const [externalUrl, setExternalUrl] = useState('')
|
const [externalUrl, setExternalUrl] = useState('')
|
||||||
const [isPublished, setIsPublished] = useState(false)
|
const [isPublished, setIsPublished] = useState(false)
|
||||||
|
const [programId, setProgramId] = useState<string | null>(null)
|
||||||
|
const [previewing, setPreviewing] = useState(false)
|
||||||
|
|
||||||
|
// Access rules state
|
||||||
|
const [accessMode, setAccessMode] = useState<'everyone' | 'roles'>('everyone')
|
||||||
|
const [selectedRoles, setSelectedRoles] = useState<string[]>([])
|
||||||
|
|
||||||
// API
|
// API
|
||||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||||
const [programId, setProgramId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const createResource = trpc.learningResource.create.useMutation({
|
const createResource = trpc.learningResource.create.useMutation({
|
||||||
@@ -82,43 +103,41 @@ export default function NewLearningResourcePage() {
|
|||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Upload to MinIO
|
|
||||||
await fetch(url, {
|
await fetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: file,
|
body: file,
|
||||||
headers: {
|
headers: { 'Content-Type': file.type },
|
||||||
'Content-Type': file.type,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Return the MinIO URL
|
|
||||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to upload file')
|
toast.error('Failed to upload file')
|
||||||
throw error
|
throw new Error('Upload failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const buildAccessJson = (): AccessRule[] | null => {
|
||||||
|
if (accessMode === 'everyone') return null
|
||||||
|
if (accessMode === 'roles' && selectedRoles.length > 0) {
|
||||||
|
return [{ type: 'roles', roles: selectedRoles }]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
toast.error('Please enter a title')
|
toast.error('Please enter a title')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceType === 'LINK' && !externalUrl) {
|
|
||||||
toast.error('Please enter an external URL')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createResource.mutateAsync({
|
await createResource.mutateAsync({
|
||||||
programId,
|
programId,
|
||||||
title,
|
title,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
accessJson: buildAccessJson(),
|
||||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
|
||||||
externalUrl: externalUrl || undefined,
|
externalUrl: externalUrl || undefined,
|
||||||
isPublished,
|
isPublished,
|
||||||
})
|
})
|
||||||
@@ -128,200 +147,205 @@ export default function NewLearningResourcePage() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
|
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
|
||||||
}
|
}
|
||||||
}
|
}, [title, description, contentJson, externalUrl, isPublished, programId, accessMode, selectedRoles])
|
||||||
|
|
||||||
|
// Ctrl+S save
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [handleSubmit])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* Header */}
|
{/* Sticky toolbar */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href="/admin/learning">
|
<Link href="/admin/learning">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Learning Hub
|
Back
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Add Resource</h1>
|
<Button
|
||||||
<p className="text-muted-foreground">
|
variant={previewing ? 'default' : 'outline'}
|
||||||
Create a new learning resource for jury members
|
size="sm"
|
||||||
</p>
|
onClick={() => setPreviewing(!previewing)}
|
||||||
</div>
|
>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
{previewing ? 'Edit' : 'Preview'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<Sheet>
|
||||||
{/* Main content */}
|
<SheetTrigger asChild>
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<Button variant="outline" size="sm">
|
||||||
{/* Basic Info */}
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
<Card>
|
Settings
|
||||||
<CardHeader>
|
</Button>
|
||||||
<CardTitle>Resource Details</CardTitle>
|
</SheetTrigger>
|
||||||
<CardDescription>
|
<SheetContent className="overflow-y-auto">
|
||||||
Basic information about this resource
|
<SheetHeader>
|
||||||
</CardDescription>
|
<SheetTitle>Resource Settings</SheetTitle>
|
||||||
</CardHeader>
|
<SheetDescription>
|
||||||
<CardContent className="space-y-4">
|
Configure publishing, access, and metadata
|
||||||
<div className="space-y-2">
|
</SheetDescription>
|
||||||
<Label htmlFor="title">Title *</Label>
|
</SheetHeader>
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="e.g., Ocean Conservation Best Practices"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="mt-6 space-y-6">
|
||||||
<Label htmlFor="description">Short Description</Label>
|
{/* Publish toggle */}
|
||||||
<Textarea
|
<div className="flex items-center justify-between">
|
||||||
id="description"
|
<div>
|
||||||
value={description}
|
<Label>Published</Label>
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
<p className="text-sm text-muted-foreground">
|
||||||
placeholder="Brief description of this resource"
|
Make visible to users
|
||||||
rows={2}
|
</p>
|
||||||
maxLength={500}
|
</div>
|
||||||
/>
|
<Switch
|
||||||
</div>
|
checked={isPublished}
|
||||||
|
onCheckedChange={setIsPublished}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<Separator />
|
||||||
|
|
||||||
|
{/* Program */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type">Resource Type</Label>
|
<Label>Program</Label>
|
||||||
<Select value={resourceType} onValueChange={setResourceType}>
|
<Select
|
||||||
<SelectTrigger id="type">
|
value={programId || 'global'}
|
||||||
<SelectValue />
|
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select program" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{resourceTypeOptions.map((option) => (
|
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
{programs?.map((program) => (
|
||||||
<div className="flex items-center gap-2">
|
<SelectItem key={program.id} value={program.id}>
|
||||||
<option.icon className="h-4 w-4" />
|
{program.year} Edition
|
||||||
{option.label}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Separator />
|
||||||
<Label htmlFor="cohort">Access Level</Label>
|
|
||||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
{/* Access Rules */}
|
||||||
<SelectTrigger id="cohort">
|
<div className="space-y-3">
|
||||||
|
<Label>Access Rules</Label>
|
||||||
|
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
|
||||||
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{cohortOptions.map((option) => (
|
<SelectItem value="everyone">Everyone</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem value="roles">By Role</SelectItem>
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{resourceType === 'LINK' && (
|
{accessMode === 'roles' && (
|
||||||
|
<div className="space-y-2 rounded-lg border p-3">
|
||||||
|
{ROLE_OPTIONS.map((role) => (
|
||||||
|
<label key={role.value} className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRoles.includes(role.value)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setSelectedRoles(
|
||||||
|
checked
|
||||||
|
? [...selectedRoles, role.value]
|
||||||
|
: selectedRoles.filter((r) => r !== role.value)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{role.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* External URL */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="url">External URL *</Label>
|
<Label>External URL</Label>
|
||||||
<Input
|
<Input
|
||||||
id="url"
|
|
||||||
type="url"
|
type="url"
|
||||||
value={externalUrl}
|
value={externalUrl}
|
||||||
onChange={(e) => setExternalUrl(e.target.value)}
|
onChange={(e) => setExternalUrl(e.target.value)}
|
||||||
placeholder="https://example.com/resource"
|
placeholder="https://example.com/resource"
|
||||||
/>
|
/>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">
|
||||||
)}
|
Optional link to an external resource
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Content Editor */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Content</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Rich text content with images and videos. Type / for commands.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<BlockEditor
|
|
||||||
initialContent={contentJson || undefined}
|
|
||||||
onChange={setContentJson}
|
|
||||||
onUploadFile={handleUploadFile}
|
|
||||||
className="min-h-[300px]"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Publish Settings */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Publish Settings</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="published">Published</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Make this resource visible to jury members
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
|
||||||
id="published"
|
|
||||||
checked={isPublished}
|
|
||||||
onCheckedChange={setIsPublished}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Button
|
||||||
<Label htmlFor="program">Program</Label>
|
size="sm"
|
||||||
<Select
|
onClick={handleSubmit}
|
||||||
value={programId || 'global'}
|
disabled={createResource.isPending || !title.trim()}
|
||||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
>
|
||||||
>
|
{createResource.isPending ? (
|
||||||
<SelectTrigger id="program">
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<SelectValue placeholder="Select program" />
|
) : (
|
||||||
</SelectTrigger>
|
<Save className="mr-2 h-4 w-4" />
|
||||||
<SelectContent>
|
)}
|
||||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
Save
|
||||||
{programs?.map((program) => (
|
</Button>
|
||||||
<SelectItem key={program.id} value={program.id}>
|
|
||||||
{program.year} Edition
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={createResource.isPending || !title.trim()}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{createResource.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Create Resource
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild className="w-full">
|
|
||||||
<Link href="/admin/learning">Cancel</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="flex-1 px-4 py-8">
|
||||||
|
{previewing ? (
|
||||||
|
<ResourceRenderer
|
||||||
|
title={title || 'Untitled'}
|
||||||
|
description={description || null}
|
||||||
|
contentJson={contentJson ? JSON.parse(contentJson) : null}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
{/* Inline title */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Untitled"
|
||||||
|
autoFocus
|
||||||
|
className="w-full border-0 bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 focus:outline-none sm:text-4xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Inline description */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Add a description..."
|
||||||
|
className="w-full border-0 bg-transparent text-lg text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<hr className="border-border" />
|
||||||
|
|
||||||
|
{/* Block editor */}
|
||||||
|
<BlockEditor
|
||||||
|
onChange={setContentJson}
|
||||||
|
onUploadFile={handleUploadFile}
|
||||||
|
className="min-h-[400px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -22,48 +23,212 @@ import {
|
|||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
FileText,
|
FileText,
|
||||||
Video,
|
|
||||||
Link as LinkIcon,
|
|
||||||
File,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Search,
|
Search,
|
||||||
|
GripVertical,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
const resourceTypeIcons = {
|
type Resource = {
|
||||||
PDF: FileText,
|
id: string
|
||||||
VIDEO: Video,
|
title: string
|
||||||
DOCUMENT: File,
|
description: string | null
|
||||||
LINK: LinkIcon,
|
isPublished: boolean
|
||||||
OTHER: File,
|
sortOrder: number
|
||||||
|
externalUrl: string | null
|
||||||
|
objectKey: string | null
|
||||||
|
contentJson: unknown
|
||||||
|
accessJson: unknown
|
||||||
|
_count: { accessLogs: number }
|
||||||
|
program: { id: string; name: string; year: number } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const cohortColors: Record<string, string> = {
|
function getAccessSummary(accessJson: unknown): string {
|
||||||
ALL: 'bg-gray-100 text-gray-800',
|
if (!accessJson || !Array.isArray(accessJson) || accessJson.length === 0) {
|
||||||
SEMIFINALIST: 'bg-blue-100 text-blue-800',
|
return 'Everyone'
|
||||||
FINALIST: 'bg-purple-100 text-purple-800',
|
}
|
||||||
|
const rule = accessJson[0] as { type: string; roles?: string[] }
|
||||||
|
if (rule.type === 'everyone') return 'Everyone'
|
||||||
|
if (rule.type === 'roles' && rule.roles) {
|
||||||
|
if (rule.roles.length === 1) return rule.roles[0].replace('_', ' ').toLowerCase()
|
||||||
|
return `${rule.roles.length} roles`
|
||||||
|
}
|
||||||
|
if (rule.type === 'jury_group') return 'Jury groups'
|
||||||
|
if (rule.type === 'round') return 'By round'
|
||||||
|
return 'Custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableResourceCard({
|
||||||
|
resource,
|
||||||
|
onTogglePublished,
|
||||||
|
}: {
|
||||||
|
resource: Resource
|
||||||
|
onTogglePublished: (id: string, published: boolean) => void
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: resource.id })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={isDragging ? 'opacity-50 shadow-lg' : ''}
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-center gap-3 py-3">
|
||||||
|
{/* Drag handle */}
|
||||||
|
<button
|
||||||
|
className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Drag to reorder"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title & meta */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium truncate">{resource.title}</h3>
|
||||||
|
{!resource.isPublished && (
|
||||||
|
<Badge variant="secondary" className="text-xs">Draft</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||||
|
<span className="capitalize">{getAccessSummary(resource.accessJson)}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{resource._count.accessLogs} views</span>
|
||||||
|
{resource.program && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{resource.program.year}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick publish toggle */}
|
||||||
|
<Switch
|
||||||
|
checked={resource.isPublished}
|
||||||
|
onCheckedChange={(checked) => onTogglePublished(resource.id, checked)}
|
||||||
|
aria-label={resource.isPublished ? 'Unpublish' : 'Publish'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{resource.externalUrl && (
|
||||||
|
<a
|
||||||
|
href={resource.externalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<Link href={`/admin/learning/${resource.id}`}>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LearningHubPage() {
|
export default function LearningHubPage() {
|
||||||
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 50 })
|
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 100 })
|
||||||
const resources = data?.data
|
const resources = (data?.data || []) as Resource[]
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const debouncedSearch = useDebounce(search, 300)
|
const debouncedSearch = useDebounce(search, 300)
|
||||||
const [typeFilter, setTypeFilter] = useState('all')
|
const [publishedFilter, setPublishedFilter] = useState('all')
|
||||||
const [cohortFilter, setCohortFilter] = useState('all')
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const reorderMutation = trpc.learningResource.reorder.useMutation({
|
||||||
|
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||||
|
})
|
||||||
|
const updateMutation = trpc.learningResource.update.useMutation({
|
||||||
|
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const filteredResources = useMemo(() => {
|
const filteredResources = useMemo(() => {
|
||||||
if (!resources) return []
|
|
||||||
return resources.filter((resource) => {
|
return resources.filter((resource) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
!debouncedSearch ||
|
!debouncedSearch ||
|
||||||
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
|
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||||
const matchesType = typeFilter === 'all' || resource.resourceType === typeFilter
|
const matchesPublished =
|
||||||
const matchesCohort = cohortFilter === 'all' || resource.cohortLevel === cohortFilter
|
publishedFilter === 'all' ||
|
||||||
return matchesSearch && matchesType && matchesCohort
|
(publishedFilter === 'published' && resource.isPublished) ||
|
||||||
|
(publishedFilter === 'draft' && !resource.isPublished)
|
||||||
|
return matchesSearch && matchesPublished
|
||||||
})
|
})
|
||||||
}, [resources, debouncedSearch, typeFilter, cohortFilter])
|
}, [resources, debouncedSearch, publishedFilter])
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id) return
|
||||||
|
|
||||||
|
const oldIndex = filteredResources.findIndex((r) => r.id === active.id)
|
||||||
|
const newIndex = filteredResources.findIndex((r) => r.id === over.id)
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return
|
||||||
|
|
||||||
|
const reordered = arrayMove(filteredResources, oldIndex, newIndex)
|
||||||
|
const items = reordered.map((r, i) => ({ id: r.id, sortOrder: i }))
|
||||||
|
|
||||||
|
reorderMutation.mutate({ items }, {
|
||||||
|
onError: () => toast.error('Failed to reorder'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTogglePublished = (id: string, published: boolean) => {
|
||||||
|
updateMutation.mutate({ id, isPublished: published }, {
|
||||||
|
onSuccess: () => toast.success(published ? 'Published' : 'Unpublished'),
|
||||||
|
onError: () => toast.error('Failed to update'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -75,25 +240,20 @@ export default function LearningHubPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-9 w-32" />
|
<Skeleton className="h-9 w-32" />
|
||||||
</div>
|
</div>
|
||||||
{/* Toolbar skeleton */}
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<Skeleton className="h-10 flex-1" />
|
<Skeleton className="h-10 flex-1" />
|
||||||
<div className="flex items-center gap-2">
|
<Skeleton className="h-10 w-[160px]" />
|
||||||
<Skeleton className="h-10 w-[160px]" />
|
|
||||||
<Skeleton className="h-10 w-[160px]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/* Resource list skeleton */}
|
<div className="grid gap-3">
|
||||||
<div className="grid gap-4">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<Card key={i}>
|
<Card key={i}>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
<Skeleton className="h-4 w-4" />
|
||||||
|
<Skeleton className="h-9 w-9 rounded-lg" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<Skeleton className="h-5 w-48" />
|
<Skeleton className="h-5 w-48" />
|
||||||
<Skeleton className="h-4 w-32" />
|
<Skeleton className="h-3 w-32" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-8 w-8 rounded" />
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -109,7 +269,7 @@ export default function LearningHubPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Learning Hub</h1>
|
<h1 className="text-2xl font-bold">Learning Hub</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Manage educational resources for jury members
|
Manage educational resources for program participants
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/learning/new">
|
<Link href="/admin/learning/new">
|
||||||
@@ -131,92 +291,49 @@ export default function LearningHubPage() {
|
|||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<Select value={publishedFilter} onValueChange={setPublishedFilter}>
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
<SelectTrigger className="w-[160px]">
|
||||||
<SelectTrigger className="w-[160px]">
|
<SelectValue placeholder="All" />
|
||||||
<SelectValue placeholder="All types" />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
<SelectContent>
|
<SelectItem value="all">All</SelectItem>
|
||||||
<SelectItem value="all">All types</SelectItem>
|
<SelectItem value="published">Published</SelectItem>
|
||||||
<SelectItem value="PDF">PDF</SelectItem>
|
<SelectItem value="draft">Drafts</SelectItem>
|
||||||
<SelectItem value="VIDEO">Video</SelectItem>
|
</SelectContent>
|
||||||
<SelectItem value="DOCUMENT">Document</SelectItem>
|
</Select>
|
||||||
<SelectItem value="LINK">Link</SelectItem>
|
|
||||||
<SelectItem value="OTHER">Other</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select value={cohortFilter} onValueChange={setCohortFilter}>
|
|
||||||
<SelectTrigger className="w-[160px]">
|
|
||||||
<SelectValue placeholder="All cohorts" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All cohorts</SelectItem>
|
|
||||||
<SelectItem value="ALL">All (cohort)</SelectItem>
|
|
||||||
<SelectItem value="SEMIFINALIST">Semifinalist</SelectItem>
|
|
||||||
<SelectItem value="FINALIST">Finalist</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
{resources && (
|
{resources.length > 0 && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{filteredResources.length} of {resources.length} resources
|
{filteredResources.length} of {resources.length} resources
|
||||||
|
{reorderMutation.isPending && ' · Saving order...'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Resource List */}
|
{/* Resource List with DnD */}
|
||||||
{filteredResources.length > 0 ? (
|
{filteredResources.length > 0 ? (
|
||||||
<div className="grid gap-4">
|
<DndContext
|
||||||
{filteredResources.map((resource) => {
|
sensors={sensors}
|
||||||
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
|
collisionDetection={closestCenter}
|
||||||
return (
|
onDragEnd={handleDragEnd}
|
||||||
<Card key={resource.id}>
|
>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<SortableContext
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
items={filteredResources.map((r) => r.id)}
|
||||||
<Icon className="h-5 w-5" />
|
strategy={verticalListSortingStrategy}
|
||||||
</div>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="grid gap-3">
|
||||||
<div className="flex items-center gap-2">
|
{filteredResources.map((resource) => (
|
||||||
<h3 className="font-medium truncate">{resource.title}</h3>
|
<SortableResourceCard
|
||||||
{!resource.isPublished && (
|
key={resource.id}
|
||||||
<Badge variant="secondary">Draft</Badge>
|
resource={resource}
|
||||||
)}
|
onTogglePublished={handleTogglePublished}
|
||||||
</div>
|
/>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
))}
|
||||||
<Badge className={cohortColors[resource.cohortLevel] || ''} variant="outline">
|
</div>
|
||||||
{resource.cohortLevel}
|
</SortableContext>
|
||||||
</Badge>
|
</DndContext>
|
||||||
<span>{resource.resourceType}</span>
|
) : resources.length > 0 ? (
|
||||||
<span>-</span>
|
|
||||||
<span>{resource._count.accessLogs} views</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{resource.externalUrl && (
|
|
||||||
<a
|
|
||||||
href={resource.externalUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<Link href={`/admin/learning/${resource.id}`}>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : resources && resources.length > 0 ? (
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
History,
|
History,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ArrowRightLeft,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -1667,6 +1668,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 +2245,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 +2292,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 +2456,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 +2487,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 +2543,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 +2600,270 @@ 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)
|
||||||
|
|
||||||
|
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="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 +2887,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 +2906,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}
|
||||||
|
|||||||
122
src/app/(jury)/jury/learning/[id]/page.tsx
Normal file
122
src/app/(jury)/jury/learning/[id]/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const ResourceRenderer = dynamic(
|
||||||
|
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function JuryResourceDetailPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const resourceId = params.id as string
|
||||||
|
|
||||||
|
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||||
|
const logAccess = trpc.learningResource.logAccess.useMutation()
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
// Log access on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (resourceId) {
|
||||||
|
logAccess.mutate({ id: resourceId })
|
||||||
|
}
|
||||||
|
}, [resourceId])
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
|
||||||
|
window.open(url, '_blank')
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
<Skeleton className="h-10 w-2/3" />
|
||||||
|
<Skeleton className="h-6 w-1/3" />
|
||||||
|
<Skeleton className="h-px w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !resource) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Resource not found</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
This resource may have been removed or you don't have access.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/jury/learning">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Learning Hub
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button variant="ghost" asChild className="-ml-4">
|
||||||
|
<Link href="/jury/learning">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Learning Hub
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{resource.externalUrl && (
|
||||||
|
<a href={resource.externalUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Open Link
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{resource.objectKey && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<ResourceRenderer
|
||||||
|
title={resource.title}
|
||||||
|
description={resource.description}
|
||||||
|
contentJson={resource.contentJson}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,41 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Video,
|
|
||||||
Link as LinkIcon,
|
|
||||||
File,
|
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const resourceTypeIcons = {
|
|
||||||
PDF: FileText,
|
|
||||||
VIDEO: Video,
|
|
||||||
DOCUMENT: File,
|
|
||||||
LINK: LinkIcon,
|
|
||||||
OTHER: File,
|
|
||||||
}
|
|
||||||
|
|
||||||
const cohortColors = {
|
|
||||||
ALL: 'bg-gray-100 text-gray-800',
|
|
||||||
SEMIFINALIST: 'bg-blue-100 text-blue-800',
|
|
||||||
FINALIST: 'bg-purple-100 text-purple-800',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function JuryLearningPage() {
|
export default function JuryLearningPage() {
|
||||||
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -81,7 +62,6 @@ export default function JuryLearningPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resources = data?.resources || []
|
const resources = data?.resources || []
|
||||||
const userCohortLevel = data?.userCohortLevel || 'ALL'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -90,11 +70,6 @@ export default function JuryLearningPage() {
|
|||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Educational resources for jury members
|
Educational resources for jury members
|
||||||
</p>
|
</p>
|
||||||
{userCohortLevel !== 'ALL' && (
|
|
||||||
<Badge className={cohortColors[userCohortLevel]} variant="outline">
|
|
||||||
Your access level: {userCohortLevel}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{resources.length === 0 ? (
|
{resources.length === 0 ? (
|
||||||
@@ -110,14 +85,14 @@ export default function JuryLearningPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{resources.map((resource) => {
|
{resources.map((resource) => {
|
||||||
const Icon = resourceTypeIcons[resource.resourceType]
|
|
||||||
const isDownloading = downloadingId === resource.id
|
const isDownloading = downloadingId === resource.id
|
||||||
|
const hasContent = !!resource.contentJson
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={resource.id}>
|
<Card key={resource.id}>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||||
<Icon className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-medium">{resource.title}</h3>
|
<h3 className="font-medium">{resource.title}</h3>
|
||||||
@@ -126,36 +101,39 @@ export default function JuryLearningPage() {
|
|||||||
{resource.description}
|
{resource.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<Badge variant="outline" className={cohortColors[resource.cohortLevel]}>
|
|
||||||
{resource.cohortLevel}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{resource.resourceType}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
{resource.externalUrl ? (
|
{hasContent && (
|
||||||
|
<Link href={`/jury/learning/${resource.id}` as Route}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<BookOpen className="mr-2 h-4 w-4" />
|
||||||
|
Read
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{resource.externalUrl && (
|
||||||
<a
|
<a
|
||||||
href={resource.externalUrl}
|
href={resource.externalUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<Button>
|
<Button variant="outline" size="sm">
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
Open
|
Open
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
) : resource.objectKey ? (
|
)}
|
||||||
|
{resource.objectKey && (
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={() => handleDownload(resource.id)}
|
onClick={() => handleDownload(resource.id)}
|
||||||
disabled={isDownloading}
|
disabled={isDownloading}
|
||||||
>
|
>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
{isDownloading ? 'Loading...' : 'Download'}
|
{isDownloading ? 'Loading...' : 'Download'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
122
src/app/(mentor)/mentor/resources/[id]/page.tsx
Normal file
122
src/app/(mentor)/mentor/resources/[id]/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const ResourceRenderer = dynamic(
|
||||||
|
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function MentorResourceDetailPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const resourceId = params.id as string
|
||||||
|
|
||||||
|
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||||
|
const logAccess = trpc.learningResource.logAccess.useMutation()
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
// Log access on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (resourceId) {
|
||||||
|
logAccess.mutate({ id: resourceId })
|
||||||
|
}
|
||||||
|
}, [resourceId])
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
|
||||||
|
window.open(url, '_blank')
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
<Skeleton className="h-10 w-2/3" />
|
||||||
|
<Skeleton className="h-6 w-1/3" />
|
||||||
|
<Skeleton className="h-px w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !resource) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Resource not found</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
This resource may have been removed or you don't have access.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/mentor/resources">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Resources
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button variant="ghost" asChild className="-ml-4">
|
||||||
|
<Link href="/mentor/resources">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Resources
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{resource.externalUrl && (
|
||||||
|
<a href={resource.externalUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Open Link
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{resource.objectKey && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<ResourceRenderer
|
||||||
|
title={resource.title}
|
||||||
|
description={resource.description}
|
||||||
|
contentJson={resource.contentJson}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,38 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Video,
|
|
||||||
Link as LinkIcon,
|
|
||||||
File,
|
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const resourceTypeIcons = {
|
|
||||||
PDF: FileText,
|
|
||||||
VIDEO: Video,
|
|
||||||
DOCUMENT: File,
|
|
||||||
LINK: LinkIcon,
|
|
||||||
OTHER: File,
|
|
||||||
}
|
|
||||||
|
|
||||||
const cohortColors: Record<string, string> = {
|
|
||||||
ALL: 'bg-gray-100 text-gray-800',
|
|
||||||
SEMIFINALIST: 'bg-blue-100 text-blue-800',
|
|
||||||
FINALIST: 'bg-purple-100 text-purple-800',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MentorResourcesPage() {
|
export default function MentorResourcesPage() {
|
||||||
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -101,14 +85,14 @@ export default function MentorResourcesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{resources.map((resource) => {
|
{resources.map((resource) => {
|
||||||
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
|
|
||||||
const isDownloading = downloadingId === resource.id
|
const isDownloading = downloadingId === resource.id
|
||||||
|
const hasContent = !!resource.contentJson
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={resource.id}>
|
<Card key={resource.id}>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||||
<Icon className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-medium">{resource.title}</h3>
|
<h3 className="font-medium">{resource.title}</h3>
|
||||||
@@ -117,36 +101,39 @@ export default function MentorResourcesPage() {
|
|||||||
{resource.description}
|
{resource.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<Badge variant="outline" className={cohortColors[resource.cohortLevel] || cohortColors.ALL}>
|
|
||||||
{resource.cohortLevel}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{resource.resourceType}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
{resource.externalUrl ? (
|
{hasContent && (
|
||||||
|
<Link href={`/mentor/resources/${resource.id}` as Route}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<BookOpen className="mr-2 h-4 w-4" />
|
||||||
|
Read
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{resource.externalUrl && (
|
||||||
<a
|
<a
|
||||||
href={resource.externalUrl}
|
href={resource.externalUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<Button>
|
<Button variant="outline" size="sm">
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
Open
|
Open
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
) : resource.objectKey ? (
|
)}
|
||||||
|
{resource.objectKey && (
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={() => handleDownload(resource.id)}
|
onClick={() => handleDownload(resource.id)}
|
||||||
disabled={isDownloading}
|
disabled={isDownloading}
|
||||||
>
|
>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
{isDownloading ? 'Loading...' : 'Download'}
|
{isDownloading ? 'Loading...' : 'Download'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user