Compare commits
18 Commits
with-test
...
0a96960ae2
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a96960ae2 | |||
| f3fd9eebee | |||
| 230347005c | |||
| 91563f3f47 | |||
| 5ece50268b | |||
| 61c4d0eb75 | |||
| 3bc6552f47 | |||
| ab2c73bad2 | |||
| feccd269f7 | |||
| 95d51e7de3 | |||
| 49e9405e01 | |||
| c1b3a6ade3 | |||
| f26ee3f076 | |||
| f7bc3b4dd2 | |||
| 09cc49d920 | |||
| 351d8144d9 | |||
| 5a609457c2 | |||
| ee2f10e080 |
@@ -33,5 +33,9 @@ else
|
|||||||
echo "==> Database already seeded ($USER_COUNT users found), skipping seed."
|
echo "==> Database already seeded ($USER_COUNT users found), skipping seed."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Always sync notification email settings (upsert — safe for existing data)
|
||||||
|
echo "==> Syncing notification email settings..."
|
||||||
|
npx tsx prisma/seed-notification-settings.ts || echo "WARNING: Notification settings sync failed."
|
||||||
|
|
||||||
echo "==> Starting application..."
|
echo "==> Starting application..."
|
||||||
exec node server.js
|
exec node server.js
|
||||||
|
|||||||
@@ -40,12 +40,12 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/applicant/pipeline',
|
source: '/applicant/pipeline',
|
||||||
destination: '/applicant/competitions',
|
destination: '/applicant/competition',
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/applicant/pipeline/:path*',
|
source: '/applicant/pipeline/:path*',
|
||||||
destination: '/applicant/competitions',
|
destination: '/applicant/competition',
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
334
package-lock.json
generated
334
package-lock.json
generated
@@ -11,14 +11,12 @@
|
|||||||
"@anthropic-ai/sdk": "^0.78.0",
|
"@anthropic-ai/sdk": "^0.78.0",
|
||||||
"@auth/prisma-adapter": "^2.7.4",
|
"@auth/prisma-adapter": "^2.7.4",
|
||||||
"@blocknote/core": "^0.46.2",
|
"@blocknote/core": "^0.46.2",
|
||||||
"@blocknote/mantine": "^0.46.2",
|
|
||||||
"@blocknote/react": "^0.46.2",
|
"@blocknote/react": "^0.46.2",
|
||||||
|
"@blocknote/shadcn": "^0.46.2",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@mantine/core": "^8.3.13",
|
|
||||||
"@mantine/hooks": "^8.3.13",
|
|
||||||
"@notionhq/client": "^2.3.0",
|
"@notionhq/client": "^2.3.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
@@ -246,24 +244,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@blocknote/mantine": {
|
|
||||||
"version": "0.46.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@blocknote/mantine/-/mantine-0.46.2.tgz",
|
|
||||||
"integrity": "sha512-2/A82VIby8NNuQbJrXZURnGsksVMWiGWtUOfhvaawCTiB2thYDOV1XONFF1G4xZ2UreodOKLUTwhLm3u25lGrw==",
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@blocknote/core": "0.46.2",
|
|
||||||
"@blocknote/react": "0.46.2",
|
|
||||||
"react-icons": "^5.5.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@mantine/core": "^8.3.11",
|
|
||||||
"@mantine/hooks": "^8.3.11",
|
|
||||||
"@mantine/utils": "^6.0.22",
|
|
||||||
"react": "^18.0 || ^19.0 || >= 19.0.0-rc",
|
|
||||||
"react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@blocknote/react": {
|
"node_modules/@blocknote/react": {
|
||||||
"version": "0.46.2",
|
"version": "0.46.2",
|
||||||
"resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.46.2.tgz",
|
"resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.46.2.tgz",
|
||||||
@@ -296,6 +276,55 @@
|
|||||||
"integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==",
|
"integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@blocknote/shadcn": {
|
||||||
|
"version": "0.46.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@blocknote/shadcn/-/shadcn-0.46.2.tgz",
|
||||||
|
"integrity": "sha512-rCmML5M814D/dcLrm5/6REQeEJKPNyygls4n751F0jBLpk8IuWsQYvGqnU6iPW2Cz+9IchA2ivg4Vltna83J1Q==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@blocknote/core": "0.46.2",
|
||||||
|
"@blocknote/react": "0.46.2",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.525.0",
|
||||||
|
"react-hook-form": "^7.65.0",
|
||||||
|
"tailwind-merge": "^2.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || >= 19.0.0-rc",
|
||||||
|
"react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc",
|
||||||
|
"tailwindcss": "^4.1.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@blocknote/shadcn/node_modules/lucide-react": {
|
||||||
|
"version": "0.525.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz",
|
||||||
|
"integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@blocknote/shadcn/node_modules/tailwind-merge": {
|
||||||
|
"version": "2.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
|
||||||
|
"integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/dcastil"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@date-fns/tz": {
|
"node_modules/@date-fns/tz": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
|
||||||
@@ -1652,34 +1681,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mantine/core": {
|
|
||||||
"version": "8.3.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.13.tgz",
|
|
||||||
"integrity": "sha512-ZgW4vqN4meaPyIMxzAufBvsgmJRfYZdTpsrAOcS8pWy7m9e8i685E7XsAxnwJCOIHudpvpvt+7Bx7VaIjEsYEw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@floating-ui/react": "^0.27.16",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"react-number-format": "^5.4.4",
|
|
||||||
"react-remove-scroll": "^2.7.1",
|
|
||||||
"react-textarea-autosize": "8.5.9",
|
|
||||||
"type-fest": "^4.41.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@mantine/hooks": "8.3.13",
|
|
||||||
"react": "^18.x || ^19.x",
|
|
||||||
"react-dom": "^18.x || ^19.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@mantine/hooks": {
|
|
||||||
"version": "8.3.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.13.tgz",
|
|
||||||
"integrity": "sha512-7YMbMW/tR9E8m/9DbBW01+3RNApm2mE/JbRKXf9s9+KxgbjQvq0FYGWV8Y4+Sjz48AO4vtWk2qBriUTgBMKAyg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^18.x || ^19.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@napi-rs/canvas": {
|
"node_modules/@napi-rs/canvas": {
|
||||||
"version": "0.1.80",
|
"version": "0.1.80",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
||||||
@@ -6010,6 +6011,42 @@
|
|||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/autoprefixer": {
|
||||||
|
"version": "10.4.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
|
||||||
|
"integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"browserslist": "^4.28.1",
|
||||||
|
"caniuse-lite": "^1.0.30001766",
|
||||||
|
"fraction.js": "^5.3.4",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"postcss-value-parser": "^4.2.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"autoprefixer": "bin/autoprefixer"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"postcss": "^8.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/available-typed-arrays": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
@@ -6091,6 +6128,18 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/baseline-browser-mapping": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"baseline-browser-mapping": "dist/cli.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bcryptjs": {
|
"node_modules/bcryptjs": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
@@ -6145,6 +6194,39 @@
|
|||||||
"integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==",
|
"integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/browserslist": {
|
||||||
|
"version": "4.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
|
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
|
"caniuse-lite": "^1.0.30001759",
|
||||||
|
"electron-to-chromium": "^1.5.263",
|
||||||
|
"node-releases": "^2.0.27",
|
||||||
|
"update-browserslist-db": "^1.2.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"browserslist": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/buffer-crc32": {
|
"node_modules/buffer-crc32": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
|
||||||
@@ -7020,6 +7102,12 @@
|
|||||||
"fast-check": "^3.23.1"
|
"fast-check": "^3.23.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/electron-to-chromium": {
|
||||||
|
"version": "1.5.302",
|
||||||
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
|
||||||
|
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/emoji-mart": {
|
"node_modules/emoji-mart": {
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
|
||||||
@@ -7290,6 +7378,15 @@
|
|||||||
"@esbuild/win32-x64": "0.27.2"
|
"@esbuild/win32-x64": "0.27.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/escalade": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escape-string-regexp": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
@@ -8015,6 +8112,19 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fraction.js": {
|
||||||
|
"version": "5.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
|
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/rawify"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/framer-motion": {
|
"node_modules/framer-motion": {
|
||||||
"version": "11.18.2",
|
"version": "11.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
|
||||||
@@ -11100,6 +11210,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-releases": {
|
||||||
|
"version": "2.0.27",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
|
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "7.0.13",
|
"version": "7.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||||
@@ -11628,6 +11744,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postcss-value-parser": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/preact": {
|
"node_modules/preact": {
|
||||||
"version": "10.24.3",
|
"version": "10.24.3",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
||||||
@@ -12250,16 +12372,6 @@
|
|||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-number-format": {
|
|
||||||
"version": "5.4.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz",
|
|
||||||
"integrity": "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-phone-number-input": {
|
"node_modules/react-phone-number-input": {
|
||||||
"version": "3.4.14",
|
"version": "3.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.14.tgz",
|
"resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.14.tgz",
|
||||||
@@ -12361,23 +12473,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-textarea-autosize": {
|
|
||||||
"version": "8.5.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
|
|
||||||
"integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.20.13",
|
|
||||||
"use-composed-ref": "^1.3.0",
|
|
||||||
"use-latest": "^1.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-transition-group": {
|
"node_modules/react-transition-group": {
|
||||||
"version": "4.4.5",
|
"version": "4.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||||
@@ -13729,18 +13824,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/type-fest": {
|
|
||||||
"version": "4.41.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
|
||||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
|
||||||
"license": "(MIT OR CC0-1.0)",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typed-array-buffer": {
|
"node_modules/typed-array-buffer": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
||||||
@@ -14020,6 +14103,36 @@
|
|||||||
"@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
|
"@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/update-browserslist-db": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"escalade": "^3.2.0",
|
||||||
|
"picocolors": "^1.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"update-browserslist-db": "cli.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"browserslist": ">= 4.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/uri-js": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
@@ -14051,20 +14164,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/use-composed-ref": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/use-debounce": {
|
"node_modules/use-debounce": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.0.tgz",
|
||||||
@@ -14077,37 +14176,6 @@
|
|||||||
"react": "*"
|
"react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/use-isomorphic-layout-effect": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/use-latest": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"use-isomorphic-layout-effect": "^1.1.1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/use-sidecar": {
|
"node_modules/use-sidecar": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||||
|
|||||||
@@ -24,14 +24,12 @@
|
|||||||
"@anthropic-ai/sdk": "^0.78.0",
|
"@anthropic-ai/sdk": "^0.78.0",
|
||||||
"@auth/prisma-adapter": "^2.7.4",
|
"@auth/prisma-adapter": "^2.7.4",
|
||||||
"@blocknote/core": "^0.46.2",
|
"@blocknote/core": "^0.46.2",
|
||||||
"@blocknote/mantine": "^0.46.2",
|
|
||||||
"@blocknote/react": "^0.46.2",
|
"@blocknote/react": "^0.46.2",
|
||||||
|
"@blocknote/shadcn": "^0.46.2",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@mantine/core": "^8.3.13",
|
|
||||||
"@mantine/hooks": "^8.3.13",
|
|
||||||
"@notionhq/client": "^2.3.0",
|
"@notionhq/client": "^2.3.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
-- Add isTest field to User, Program, Project, Competition for test environment isolation
|
|
||||||
ALTER TABLE "User" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
ALTER TABLE "Program" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
ALTER TABLE "Project" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
ALTER TABLE "Competition" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
|
|
||||||
-- Index for efficient test data filtering
|
|
||||||
CREATE INDEX "Competition_isTest_idx" ON "Competition"("isTest");
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Learning Hub Overhaul: Remove ResourceType/CohortLevel enums, add accessJson + coverImageKey
|
||||||
|
|
||||||
|
-- Drop columns that reference the enums
|
||||||
|
ALTER TABLE "LearningResource" DROP COLUMN "resourceType";
|
||||||
|
ALTER TABLE "LearningResource" DROP COLUMN "cohortLevel";
|
||||||
|
|
||||||
|
-- Drop the cohortLevel index
|
||||||
|
DROP INDEX IF EXISTS "LearningResource_cohortLevel_idx";
|
||||||
|
|
||||||
|
-- Add new columns
|
||||||
|
ALTER TABLE "LearningResource" ADD COLUMN "accessJson" JSONB;
|
||||||
|
ALTER TABLE "LearningResource" ADD COLUMN "coverImageKey" TEXT;
|
||||||
|
|
||||||
|
-- Drop the enum types
|
||||||
|
DROP TYPE IF EXISTS "ResourceType";
|
||||||
|
DROP TYPE IF EXISTS "CohortLevel";
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "ConflictOfInterest" DROP CONSTRAINT "ConflictOfInterest_roundId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Project" DROP CONSTRAINT "Project_programId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "TaggingJob" DROP CONSTRAINT "TaggingJob_roundId_fkey";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "DiscussionComment_discussionId_createdAt_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "EvaluationDiscussion_status_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "LiveVote_isAudienceVote_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Message_scheduledAt_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "MessageRecipient_messageId_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "MessageRecipient_userId_isRead_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Project_programId_roundId_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Project_roundId_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "ProjectFile_projectId_roundId_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "ProjectFile_roundId_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "TaggingJob_roundId_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "WebhookDelivery_createdAt_idx";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "roles" "UserRole"[] DEFAULT ARRAY[]::"UserRole"[];
|
||||||
|
|
||||||
|
-- Backfill: populate roles array from existing role column
|
||||||
|
UPDATE "User" SET "roles" = ARRAY["role"]::"UserRole"[];
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Project" ADD CONSTRAINT "Project_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MessageTemplate" ADD CONSTRAINT "MessageTemplate_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- RenameIndex
|
||||||
|
ALTER INDEX "DeliberationVote_sessionId_juryMemberId_projectId_runoffRo_key" RENAME TO "DeliberationVote_sessionId_juryMemberId_projectId_runoffRou_key";
|
||||||
38
prisma/migrations/insert-dropout-reassigned-setting.sql
Normal file
38
prisma/migrations/insert-dropout-reassigned-setting.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
-- Insert missing notification email settings into production DB
|
||||||
|
-- Run manually: psql -d mopc -f prisma/migrations/insert-dropout-reassigned-setting.sql
|
||||||
|
-- Safe to run multiple times (uses ON CONFLICT to skip if already exists)
|
||||||
|
|
||||||
|
INSERT INTO "NotificationEmailSetting" (
|
||||||
|
"id", "notificationType", "category", "label", "description", "sendEmail", "createdAt", "updatedAt"
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
gen_random_uuid()::text,
|
||||||
|
'COI_REASSIGNED',
|
||||||
|
'jury',
|
||||||
|
'COI Reassignment',
|
||||||
|
'When a project is reassigned to you due to another juror''s conflict of interest',
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
),
|
||||||
|
(
|
||||||
|
gen_random_uuid()::text,
|
||||||
|
'MANUAL_REASSIGNED',
|
||||||
|
'jury',
|
||||||
|
'Manual Reassignment',
|
||||||
|
'When an admin manually reassigns a project to you',
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
),
|
||||||
|
(
|
||||||
|
gen_random_uuid()::text,
|
||||||
|
'DROPOUT_REASSIGNED',
|
||||||
|
'jury',
|
||||||
|
'Juror Dropout Reassignment',
|
||||||
|
'When projects are reassigned to you because a juror dropped out or became unavailable',
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT ("notificationType") DO NOTHING;
|
||||||
@@ -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
|
||||||
@@ -316,6 +303,7 @@ model User {
|
|||||||
name String?
|
name String?
|
||||||
emailVerified DateTime? // Required by NextAuth Prisma adapter
|
emailVerified DateTime? // Required by NextAuth Prisma adapter
|
||||||
role UserRole @default(JURY_MEMBER)
|
role UserRole @default(JURY_MEMBER)
|
||||||
|
roles UserRole[] @default([])
|
||||||
status UserStatus @default(INVITED)
|
status UserStatus @default(INVITED)
|
||||||
expertiseTags String[] @default([])
|
expertiseTags String[] @default([])
|
||||||
maxAssignments Int? // Per-round limit
|
maxAssignments Int? // Per-round limit
|
||||||
@@ -350,9 +338,6 @@ model User {
|
|||||||
preferredWorkload Int?
|
preferredWorkload Int?
|
||||||
availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string }
|
availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string }
|
||||||
|
|
||||||
// Test environment isolation
|
|
||||||
isTest Boolean @default(false)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
lastLoginAt DateTime?
|
lastLoginAt DateTime?
|
||||||
@@ -497,9 +482,6 @@ model Program {
|
|||||||
description String?
|
description String?
|
||||||
settingsJson Json? @db.JsonB
|
settingsJson Json? @db.JsonB
|
||||||
|
|
||||||
// Test environment isolation
|
|
||||||
isTest Boolean @default(false)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -624,9 +606,6 @@ model Project {
|
|||||||
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
|
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
|
||||||
externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc.
|
externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc.
|
||||||
|
|
||||||
// Test environment isolation
|
|
||||||
isTest Boolean @default(false)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1019,13 +998,12 @@ model NotificationEmailSetting {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
model LearningResource {
|
model LearningResource {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
programId String? // null = global resource
|
programId String? // null = global resource
|
||||||
title String
|
title String
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
contentJson Json? @db.JsonB // BlockNote document structure
|
contentJson Json? @db.JsonB // BlockNote document structure
|
||||||
resourceType ResourceType
|
accessJson Json? @db.JsonB // Fine-grained access rules
|
||||||
cohortLevel CohortLevel @default(ALL)
|
|
||||||
|
|
||||||
// File storage (for uploaded resources)
|
// File storage (for uploaded resources)
|
||||||
fileName String?
|
fileName String?
|
||||||
@@ -1034,6 +1012,9 @@ model LearningResource {
|
|||||||
bucket String?
|
bucket String?
|
||||||
objectKey String?
|
objectKey String?
|
||||||
|
|
||||||
|
// Cover image (stored in MinIO)
|
||||||
|
coverImageKey String?
|
||||||
|
|
||||||
// External link
|
// External link
|
||||||
externalUrl String?
|
externalUrl String?
|
||||||
|
|
||||||
@@ -1050,7 +1031,6 @@ model LearningResource {
|
|||||||
accessLogs ResourceAccess[]
|
accessLogs ResourceAccess[]
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([cohortLevel])
|
|
||||||
@@index([isPublished])
|
@@index([isPublished])
|
||||||
@@index([sortOrder])
|
@@index([sortOrder])
|
||||||
}
|
}
|
||||||
@@ -2099,9 +2079,6 @@ model Competition {
|
|||||||
notifyOnDeadlineApproach Boolean @default(true)
|
notifyOnDeadlineApproach Boolean @default(true)
|
||||||
deadlineReminderDays Int[] @default([7, 3, 1])
|
deadlineReminderDays Int[] @default([7, 3, 1])
|
||||||
|
|
||||||
// Test environment isolation
|
|
||||||
isTest Boolean @default(false)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -2116,7 +2093,6 @@ model Competition {
|
|||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([isTest])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Round {
|
model Round {
|
||||||
|
|||||||
@@ -90,6 +90,27 @@ const NOTIFICATION_EMAIL_SETTINGS = [
|
|||||||
description: 'When multiple projects are assigned at once',
|
description: 'When multiple projects are assigned at once',
|
||||||
sendEmail: true,
|
sendEmail: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
notificationType: 'COI_REASSIGNED',
|
||||||
|
category: 'jury',
|
||||||
|
label: 'COI Reassignment',
|
||||||
|
description: 'When a project is reassigned to you due to another juror\'s conflict of interest',
|
||||||
|
sendEmail: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
notificationType: 'MANUAL_REASSIGNED',
|
||||||
|
category: 'jury',
|
||||||
|
label: 'Manual Reassignment',
|
||||||
|
description: 'When an admin manually reassigns a project to you',
|
||||||
|
sendEmail: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
notificationType: 'DROPOUT_REASSIGNED',
|
||||||
|
category: 'jury',
|
||||||
|
label: 'Juror Dropout Reassignment',
|
||||||
|
description: 'When projects are reassigned to you because a juror dropped out or became unavailable',
|
||||||
|
sendEmail: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
notificationType: 'ROUND_NOW_OPEN',
|
notificationType: 'ROUND_NOW_OPEN',
|
||||||
category: 'jury',
|
category: 'jury',
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ async function main() {
|
|||||||
email: account.email,
|
email: account.email,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
role: account.role,
|
role: account.role,
|
||||||
|
roles: [account.role],
|
||||||
status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE,
|
status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE,
|
||||||
passwordHash: isSuperAdmin ? passwordHash : null,
|
passwordHash: isSuperAdmin ? passwordHash : null,
|
||||||
mustSetPassword: !isSuperAdmin,
|
mustSetPassword: !isSuperAdmin,
|
||||||
@@ -385,6 +386,7 @@ async function main() {
|
|||||||
email: j.email,
|
email: j.email,
|
||||||
name: j.name,
|
name: j.name,
|
||||||
role: UserRole.JURY_MEMBER,
|
role: UserRole.JURY_MEMBER,
|
||||||
|
roles: [UserRole.JURY_MEMBER],
|
||||||
status: UserStatus.NONE,
|
status: UserStatus.NONE,
|
||||||
country: j.country,
|
country: j.country,
|
||||||
expertiseTags: j.tags,
|
expertiseTags: j.tags,
|
||||||
@@ -416,6 +418,7 @@ async function main() {
|
|||||||
email: m.email,
|
email: m.email,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
role: UserRole.MENTOR,
|
role: UserRole.MENTOR,
|
||||||
|
roles: [UserRole.MENTOR],
|
||||||
status: UserStatus.NONE,
|
status: UserStatus.NONE,
|
||||||
country: m.country,
|
country: m.country,
|
||||||
expertiseTags: m.tags,
|
expertiseTags: m.tags,
|
||||||
@@ -444,6 +447,7 @@ async function main() {
|
|||||||
email: o.email,
|
email: o.email,
|
||||||
name: o.name,
|
name: o.name,
|
||||||
role: UserRole.OBSERVER,
|
role: UserRole.OBSERVER,
|
||||||
|
roles: [UserRole.OBSERVER],
|
||||||
status: UserStatus.NONE,
|
status: UserStatus.NONE,
|
||||||
country: o.country,
|
country: o.country,
|
||||||
},
|
},
|
||||||
@@ -545,6 +549,7 @@ async function main() {
|
|||||||
email,
|
email,
|
||||||
name: name || `Applicant ${rowIdx + 1}`,
|
name: name || `Applicant ${rowIdx + 1}`,
|
||||||
role: UserRole.APPLICANT,
|
role: UserRole.APPLICANT,
|
||||||
|
roles: [UserRole.APPLICANT],
|
||||||
status: UserStatus.NONE,
|
status: UserStatus.NONE,
|
||||||
phoneNumber: phone,
|
phoneNumber: phone,
|
||||||
country,
|
country,
|
||||||
@@ -920,6 +925,8 @@ async function main() {
|
|||||||
{ notificationType: 'REMINDER_1H', category: 'jury', label: 'Reminder (1h)', description: 'Urgent reminder 1 hour before deadline', sendEmail: true },
|
{ notificationType: 'REMINDER_1H', category: 'jury', label: 'Reminder (1h)', description: 'Urgent reminder 1 hour before deadline', sendEmail: true },
|
||||||
{ notificationType: 'ROUND_CLOSED', category: 'jury', label: 'Round Closed', description: 'When a round closes', sendEmail: false },
|
{ notificationType: 'ROUND_CLOSED', category: 'jury', label: 'Round Closed', description: 'When a round closes', sendEmail: false },
|
||||||
{ notificationType: 'AWARD_VOTING_OPEN', category: 'jury', label: 'Award Voting Open', description: 'When special award voting opens', sendEmail: true },
|
{ notificationType: 'AWARD_VOTING_OPEN', category: 'jury', label: 'Award Voting Open', description: 'When special award voting opens', sendEmail: true },
|
||||||
|
{ notificationType: 'COI_REASSIGNED', category: 'jury', label: 'COI Reassignment', description: 'When a project is reassigned to you due to another juror\'s conflict of interest', sendEmail: true },
|
||||||
|
{ notificationType: 'MANUAL_REASSIGNED', category: 'jury', label: 'Manual Reassignment', description: 'When an admin manually reassigns a project to you', sendEmail: true },
|
||||||
// Mentor notifications
|
// Mentor notifications
|
||||||
{ notificationType: 'MENTEE_ASSIGNED', category: 'mentor', label: 'Mentee Assigned', description: 'When assigned as mentor to a project', sendEmail: true },
|
{ notificationType: 'MENTEE_ASSIGNED', category: 'mentor', label: 'Mentee Assigned', description: 'When assigned as mentor to a project', sendEmail: true },
|
||||||
{ notificationType: 'MENTEE_UPLOADED_DOCS', category: 'mentor', label: 'Mentee Documents Updated', description: 'When a mentee uploads new documents', sendEmail: false },
|
{ notificationType: 'MENTEE_UPLOADED_DOCS', category: 'mentor', label: 'Mentee Documents Updated', description: 'When a mentee uploads new documents', sendEmail: false },
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
|
||||||
import { ArrowLeft, Loader2, PlayCircle, Zap } from 'lucide-react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
|
||||||
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
|
|
||||||
export default function AssignmentsDashboardPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const competitionId = params.competitionId as string
|
|
||||||
|
|
||||||
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
|
|
||||||
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
|
|
||||||
|
|
||||||
const aiAssignmentMutation = trpc.roundAssignment.aiPreview.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('AI assignments ready!', {
|
|
||||||
action: { label: 'Review', onClick: () => setPreviewSheetOpen(true) },
|
|
||||||
duration: 10000,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(`AI generation failed: ${err.message}`),
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: competition, isLoading: isLoadingCompetition } = trpc.competition.getById.useQuery({
|
|
||||||
id: competitionId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: selectedRound } = trpc.round.getById.useQuery(
|
|
||||||
{ id: selectedRoundId },
|
|
||||||
{ enabled: !!selectedRoundId }
|
|
||||||
)
|
|
||||||
|
|
||||||
const requiredReviews = (selectedRound?.configJson as Record<string, unknown>)?.requiredReviewsPerProject as number || 3
|
|
||||||
|
|
||||||
const { data: unassignedQueue, isLoading: isLoadingQueue } =
|
|
||||||
trpc.roundAssignment.unassignedQueue.useQuery(
|
|
||||||
{ roundId: selectedRoundId, requiredReviews },
|
|
||||||
{ enabled: !!selectedRoundId }
|
|
||||||
)
|
|
||||||
|
|
||||||
const rounds = competition?.rounds || []
|
|
||||||
const currentRound = rounds.find((r) => r.id === selectedRoundId)
|
|
||||||
|
|
||||||
if (isLoadingCompetition) {
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
<Skeleton className="h-96 w-full" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!competition) {
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<p className="font-medium">Competition not found</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
The requested competition does not exist or you don't have access.
|
|
||||||
</p>
|
|
||||||
<Button variant="outline" className="mt-4" onClick={() => router.back()}>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
||||||
<Button variant="ghost" onClick={() => router.back()} className="mb-4" aria-label="Back to competition details">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back to Competition
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Assignment Dashboard</h1>
|
|
||||||
<p className="text-muted-foreground">Manage jury assignments for rounds</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Select Round</CardTitle>
|
|
||||||
<CardDescription>Choose a round to view and manage assignments</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Select value={selectedRoundId} onValueChange={setSelectedRoundId}>
|
|
||||||
<SelectTrigger className="w-full sm:w-[300px]">
|
|
||||||
<SelectValue placeholder="Select a round..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{rounds.length === 0 ? (
|
|
||||||
<div className="px-2 py-1 text-sm text-muted-foreground">No rounds available</div>
|
|
||||||
) : (
|
|
||||||
rounds.map((round) => (
|
|
||||||
<SelectItem key={round.id} value={round.id}>
|
|
||||||
{round.name} ({round.roundType})
|
|
||||||
</SelectItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{selectedRoundId && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
aiAssignmentMutation.mutate({ roundId: selectedRoundId, requiredReviews })
|
|
||||||
}}
|
|
||||||
disabled={aiAssignmentMutation.isPending}
|
|
||||||
>
|
|
||||||
{aiAssignmentMutation.isPending ? (
|
|
||||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Generating...</>
|
|
||||||
) : (
|
|
||||||
<><Zap className="mr-2 h-4 w-4" />{aiAssignmentMutation.data ? 'Regenerate' : 'Generate with AI'}</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{aiAssignmentMutation.data && (
|
|
||||||
<Button variant="outline" onClick={() => setPreviewSheetOpen(true)}>
|
|
||||||
Review Assignments
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="coverage" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="coverage">Coverage Report</TabsTrigger>
|
|
||||||
<TabsTrigger value="unassigned">Unassigned Queue</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="coverage" className="mt-6">
|
|
||||||
<CoverageReport roundId={selectedRoundId} requiredReviews={requiredReviews} />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="unassigned" className="mt-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Unassigned Projects</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Projects with fewer than {requiredReviews} assignments
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoadingQueue ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<Skeleton key={i} className="h-16 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : unassignedQueue && unassignedQueue.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{unassignedQueue.map((project: any) => (
|
|
||||||
<div
|
|
||||||
key={project.id}
|
|
||||||
className="flex justify-between items-center p-3 border rounded-md"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{project.title}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{project.competitionCategory || 'No category'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{project.assignmentCount || 0} / {requiredReviews} assignments
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
All projects have sufficient assignments
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<AssignmentPreviewSheet
|
|
||||||
roundId={selectedRoundId}
|
|
||||||
open={previewSheetOpen}
|
|
||||||
onOpenChange={setPreviewSheetOpen}
|
|
||||||
requiredReviews={requiredReviews}
|
|
||||||
aiResult={aiAssignmentMutation.data ?? null}
|
|
||||||
isAIGenerating={aiAssignmentMutation.isPending}
|
|
||||||
onGenerateAI={() => aiAssignmentMutation.mutate({ roundId: selectedRoundId, requiredReviews })}
|
|
||||||
onResetAI={() => aiAssignmentMutation.reset()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { use } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { trpc } from '@/lib/trpc/client';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
import type { Route } from 'next';
|
|
||||||
|
|
||||||
export default function AwardDetailPage({
|
|
||||||
params: paramsPromise
|
|
||||||
}: {
|
|
||||||
params: Promise<{ competitionId: string; awardId: string }>;
|
|
||||||
}) {
|
|
||||||
const params = use(paramsPromise);
|
|
||||||
const router = useRouter();
|
|
||||||
const { data: award, isLoading } = trpc.specialAward.get.useQuery({
|
|
||||||
id: params.awardId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Loading...</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!award) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Award Not Found</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() =>
|
|
||||||
router.push(`/admin/competitions/${params.competitionId}/awards` as Route)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h1 className="text-3xl font-bold">{award.name}</h1>
|
|
||||||
<p className="text-muted-foreground">{award.description || 'No description'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="overview" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
||||||
<TabsTrigger value="eligible">Eligible Projects</TabsTrigger>
|
|
||||||
<TabsTrigger value="winners">Winners</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="overview" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Award Information</CardTitle>
|
|
||||||
<CardDescription>Configuration and settings</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Scoring Mode</p>
|
|
||||||
<Badge variant="outline" className="mt-1">
|
|
||||||
{award.scoringMode}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">AI Eligibility</p>
|
|
||||||
<Badge variant="outline" className="mt-1">
|
|
||||||
{award.useAiEligibility ? 'Enabled' : 'Disabled'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Status</p>
|
|
||||||
<Badge variant={award.status === 'DRAFT' ? 'secondary' : 'default'} className="mt-1">
|
|
||||||
{award.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Program</p>
|
|
||||||
<p className="mt-1 text-sm">{award.program?.name}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="eligible" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Eligible Projects</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Projects that qualify for this award ({award?.eligibleCount || 0})
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-center text-muted-foreground">
|
|
||||||
{award?.eligibleCount || 0} eligible projects
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="winners" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Award Winners</CardTitle>
|
|
||||||
<CardDescription>Selected winners for this award</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{award?.winnerProject ? (
|
|
||||||
<div className="rounded-lg border p-4">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{award.winnerProject.title}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{award.winnerProject.teamName}</p>
|
|
||||||
</div>
|
|
||||||
<Badge className="mt-2">Winner</Badge>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-center text-muted-foreground">No winner selected yet</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { use, useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { trpc } from '@/lib/trpc/client';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import type { Route } from 'next';
|
|
||||||
|
|
||||||
export default function NewAwardPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
|
|
||||||
const params = use(paramsPromise);
|
|
||||||
const router = useRouter();
|
|
||||||
const utils = trpc.useUtils();
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
criteriaText: '',
|
|
||||||
useAiEligibility: false,
|
|
||||||
scoringMode: 'PICK_WINNER' as 'PICK_WINNER' | 'RANKED' | 'SCORED',
|
|
||||||
maxRankedPicks: '3',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: competition } = trpc.competition.getById.useQuery({
|
|
||||||
id: params.competitionId
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: juryGroups } = trpc.juryGroup.list.useQuery({
|
|
||||||
competitionId: params.competitionId
|
|
||||||
});
|
|
||||||
|
|
||||||
const createMutation = trpc.specialAward.create.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.specialAward.list.invalidate();
|
|
||||||
toast.success('Award created successfully');
|
|
||||||
router.push(`/admin/competitions/${params.competitionId}/awards` as Route);
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!formData.name.trim()) {
|
|
||||||
toast.error('Award name is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!competition?.programId) {
|
|
||||||
toast.error('Competition data not loaded');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
createMutation.mutate({
|
|
||||||
programId: competition.programId,
|
|
||||||
competitionId: params.competitionId,
|
|
||||||
name: formData.name.trim(),
|
|
||||||
description: formData.description.trim() || undefined,
|
|
||||||
criteriaText: formData.criteriaText.trim() || undefined,
|
|
||||||
scoringMode: formData.scoringMode,
|
|
||||||
useAiEligibility: formData.useAiEligibility,
|
|
||||||
maxRankedPicks: formData.scoringMode === 'RANKED' ? parseInt(formData.maxRankedPicks) : undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => router.push(`/admin/competitions/${params.competitionId}/awards` as Route)}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Create Special Award</h1>
|
|
||||||
<p className="text-muted-foreground">Define a new award for this competition</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Award Details</CardTitle>
|
|
||||||
<CardDescription>Configure the award properties and eligibility</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Award Name *</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
placeholder="e.g., Best Innovation Award"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
||||||
placeholder="Describe the award criteria and purpose"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="criteriaText">Eligibility Criteria</Label>
|
|
||||||
<Textarea
|
|
||||||
id="criteriaText"
|
|
||||||
value={formData.criteriaText}
|
|
||||||
onChange={(e) => setFormData({ ...formData, criteriaText: e.target.value })}
|
|
||||||
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
This text will be used by AI to determine which projects are eligible for this award.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="useAiEligibility"
|
|
||||||
checked={formData.useAiEligibility}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setFormData({ ...formData, useAiEligibility: checked as boolean })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="useAiEligibility" className="font-normal">
|
|
||||||
Use AI-based eligibility assessment
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="scoringMode">Scoring Mode</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.scoringMode}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData({ ...formData, scoringMode: value as 'PICK_WINNER' | 'RANKED' | 'SCORED' })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="scoringMode">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="PICK_WINNER">Pick Winner — Each juror picks 1</SelectItem>
|
|
||||||
<SelectItem value="RANKED">Ranked — Each juror ranks top N</SelectItem>
|
|
||||||
<SelectItem value="SCORED">Scored — Use evaluation form</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.scoringMode === 'RANKED' && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
|
||||||
<Input
|
|
||||||
id="maxPicks"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="20"
|
|
||||||
value={formData.maxRankedPicks}
|
|
||||||
onChange={(e) => setFormData({ ...formData, maxRankedPicks: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push(`/admin/competitions/${params.competitionId}/awards` as Route)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={createMutation.isPending}>
|
|
||||||
{createMutation.isPending ? 'Creating...' : 'Create Award'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { use } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { trpc } from '@/lib/trpc/client';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ArrowLeft, Plus } from 'lucide-react';
|
|
||||||
import type { Route } from 'next';
|
|
||||||
|
|
||||||
export default function AwardsPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
|
|
||||||
const params = use(paramsPromise);
|
|
||||||
const router = useRouter();
|
|
||||||
const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery({
|
|
||||||
id: params.competitionId
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: awards, isLoading, isError: isAwardsError } = trpc.specialAward.list.useQuery({
|
|
||||||
programId: competition?.programId
|
|
||||||
}, {
|
|
||||||
enabled: !!competition?.programId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isCompError || isAwardsError) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Error Loading Awards</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Could not load competition or awards data. Please try again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Special Awards</h1>
|
|
||||||
<p className="text-muted-foreground">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Special Awards</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Manage special awards and prizes for this competition
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Link href={`/admin/competitions/${params.competitionId}/awards/new` as Route}>
|
|
||||||
<Button>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Create Award
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{awards?.map((award) => (
|
|
||||||
<Link
|
|
||||||
key={award.id}
|
|
||||||
href={`/admin/competitions/${params.competitionId}/awards/${award.id}` as Route}
|
|
||||||
>
|
|
||||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-start justify-between">
|
|
||||||
<span className="line-clamp-1">{award.name}</span>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="line-clamp-2">
|
|
||||||
{award.description || 'No description'}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Badge variant="outline">{award.scoringMode}</Badge>
|
|
||||||
<Badge variant={award.status === 'DRAFT' ? 'secondary' : 'default'}>
|
|
||||||
{award.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{awards?.length === 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<p className="text-muted-foreground">No awards created yet</p>
|
|
||||||
<Link href={`/admin/competitions/${params.competitionId}/awards/new` as Route}>
|
|
||||||
<Button variant="link">Create your first award</Button>
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { use, useMemo } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { trpc } from '@/lib/trpc/client';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { ResultsPanel } from '@/components/admin/deliberation/results-panel';
|
|
||||||
import type { Route } from 'next';
|
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
|
||||||
DELIB_OPEN: 'Open',
|
|
||||||
VOTING: 'Voting',
|
|
||||||
TALLYING: 'Tallying',
|
|
||||||
RUNOFF: 'Runoff',
|
|
||||||
DELIB_LOCKED: 'Locked',
|
|
||||||
};
|
|
||||||
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
|
||||||
DELIB_OPEN: 'outline',
|
|
||||||
VOTING: 'default',
|
|
||||||
TALLYING: 'secondary',
|
|
||||||
RUNOFF: 'secondary',
|
|
||||||
DELIB_LOCKED: 'secondary',
|
|
||||||
};
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
|
||||||
STARTUP: 'Startup',
|
|
||||||
BUSINESS_CONCEPT: 'Business Concept',
|
|
||||||
};
|
|
||||||
const TIE_BREAK_LABELS: Record<string, string> = {
|
|
||||||
TIE_RUNOFF: 'Runoff Vote',
|
|
||||||
TIE_ADMIN_DECIDES: 'Admin Decides',
|
|
||||||
SCORE_FALLBACK: 'Score Fallback',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DeliberationSessionPage({
|
|
||||||
params: paramsPromise
|
|
||||||
}: {
|
|
||||||
params: Promise<{ competitionId: string; sessionId: string }>;
|
|
||||||
}) {
|
|
||||||
const params = use(paramsPromise);
|
|
||||||
const router = useRouter();
|
|
||||||
const utils = trpc.useUtils();
|
|
||||||
|
|
||||||
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
|
|
||||||
{ sessionId: params.sessionId },
|
|
||||||
{ refetchInterval: 10_000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const openVotingMutation = trpc.deliberation.openVoting.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.deliberation.getSession.invalidate();
|
|
||||||
toast.success('Voting opened');
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const closeVotingMutation = trpc.deliberation.closeVoting.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.deliberation.getSession.invalidate();
|
|
||||||
toast.success('Voting closed');
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Derive which participants have voted from the votes array
|
|
||||||
const voterUserIds = useMemo(() => {
|
|
||||||
if (!session?.votes) return new Set<string>();
|
|
||||||
return new Set(session.votes.map((v: any) => v.juryMember?.user?.id).filter(Boolean));
|
|
||||||
}, [session?.votes]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Loading...</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Session Not Found</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() =>
|
|
||||||
router.push(`/admin/competitions/${params.competitionId}/deliberation` as Route)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h1 className="text-3xl font-bold">Deliberation Session</h1>
|
|
||||||
<Badge variant={STATUS_VARIANTS[session.status] ?? 'outline'}>{STATUS_LABELS[session.status] ?? session.status}</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{session.round?.name} - {CATEGORY_LABELS[session.category] ?? session.category}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="setup" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
|
||||||
<TabsTrigger value="setup">Setup</TabsTrigger>
|
|
||||||
<TabsTrigger value="voting">Voting Control</TabsTrigger>
|
|
||||||
<TabsTrigger value="results">Results</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="setup" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Session Configuration</CardTitle>
|
|
||||||
<CardDescription>Deliberation settings and participants</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Mode</p>
|
|
||||||
<p className="mt-1">
|
|
||||||
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Tie Break Method</p>
|
|
||||||
<p className="mt-1">{TIE_BREAK_LABELS[session.tieBreakMethod] ?? session.tieBreakMethod}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
|
||||||
Show Collective Rankings
|
|
||||||
</p>
|
|
||||||
<p className="mt-1">{session.showCollectiveRankings ? 'Yes' : 'No'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Show Prior Jury Data</p>
|
|
||||||
<p className="mt-1">{session.showPriorJuryData ? 'Yes' : 'No'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Participants ({session.participants?.length || 0})</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{session.participants?.map((participant: any) => (
|
|
||||||
<div
|
|
||||||
key={participant.id}
|
|
||||||
className="flex items-center justify-between rounded-lg border p-3"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{participant.user?.user?.name ?? 'Unknown'}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{participant.user?.user?.email}</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant={voterUserIds.has(participant.user?.user?.id) ? 'default' : 'outline'}>
|
|
||||||
{voterUserIds.has(participant.user?.user?.id) ? 'Voted' : 'Pending'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="voting" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Voting Controls</CardTitle>
|
|
||||||
<CardDescription>Manage the voting window for jury members</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row">
|
|
||||||
<Button
|
|
||||||
onClick={() => openVotingMutation.mutate({ sessionId: params.sessionId })}
|
|
||||||
disabled={
|
|
||||||
openVotingMutation.isPending || session.status !== 'DELIB_OPEN'
|
|
||||||
}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Open Voting
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => closeVotingMutation.mutate({ sessionId: params.sessionId })}
|
|
||||||
disabled={
|
|
||||||
closeVotingMutation.isPending || session.status !== 'VOTING'
|
|
||||||
}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Close Voting
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Voting Status</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{session.participants?.map((participant: any) => (
|
|
||||||
<div
|
|
||||||
key={participant.id}
|
|
||||||
className="flex items-center justify-between rounded-lg border p-3"
|
|
||||||
>
|
|
||||||
<span>{participant.user?.user?.name ?? 'Unknown'}</span>
|
|
||||||
<Badge variant={voterUserIds.has(participant.user?.user?.id) ? 'default' : 'secondary'}>
|
|
||||||
{voterUserIds.has(participant.user?.user?.id) ? 'Submitted' : 'Not Voted'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="results" className="space-y-4">
|
|
||||||
<ResultsPanel sessionId={params.sessionId} />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { use, useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { trpc } from '@/lib/trpc/client';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
|
||||||
import { ArrowLeft, Plus } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import type { Route } from 'next';
|
|
||||||
|
|
||||||
export default function DeliberationListPage({
|
|
||||||
params: paramsPromise
|
|
||||||
}: {
|
|
||||||
params: Promise<{ competitionId: string }>;
|
|
||||||
}) {
|
|
||||||
const params = use(paramsPromise);
|
|
||||||
const router = useRouter();
|
|
||||||
const utils = trpc.useUtils();
|
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
|
||||||
const [selectedJuryGroupId, setSelectedJuryGroupId] = useState('');
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
roundId: '',
|
|
||||||
category: 'STARTUP' as 'STARTUP' | 'BUSINESS_CONCEPT',
|
|
||||||
mode: 'SINGLE_WINNER_VOTE' as 'SINGLE_WINNER_VOTE' | 'FULL_RANKING',
|
|
||||||
tieBreakMethod: 'TIE_RUNOFF' as 'TIE_RUNOFF' | 'TIE_ADMIN_DECIDES' | 'SCORE_FALLBACK',
|
|
||||||
showCollectiveRankings: false,
|
|
||||||
showPriorJuryData: false,
|
|
||||||
participantUserIds: [] as string[]
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: sessions = [], isLoading, isError: isSessionsError } = trpc.deliberation.listSessions.useQuery(
|
|
||||||
{ competitionId: params.competitionId },
|
|
||||||
{ enabled: !!params.competitionId }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get rounds for this competition
|
|
||||||
const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery(
|
|
||||||
{ id: params.competitionId },
|
|
||||||
{ enabled: !!params.competitionId }
|
|
||||||
);
|
|
||||||
const rounds = competition?.rounds || [];
|
|
||||||
|
|
||||||
// Jury groups & members for participant selection
|
|
||||||
const { data: juryGroups = [] } = trpc.juryGroup.list.useQuery(
|
|
||||||
{ competitionId: params.competitionId },
|
|
||||||
{ enabled: !!params.competitionId }
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: selectedJuryGroup } = trpc.juryGroup.getById.useQuery(
|
|
||||||
{ id: selectedJuryGroupId },
|
|
||||||
{ enabled: !!selectedJuryGroupId }
|
|
||||||
);
|
|
||||||
const juryMembers = selectedJuryGroup?.members ?? [];
|
|
||||||
|
|
||||||
const createSessionMutation = trpc.deliberation.createSession.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
utils.deliberation.listSessions.invalidate({ competitionId: params.competitionId });
|
|
||||||
toast.success('Deliberation session created');
|
|
||||||
setCreateDialogOpen(false);
|
|
||||||
router.push(
|
|
||||||
`/admin/competitions/${params.competitionId}/deliberation/${data.id}` as Route
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCreateSession = () => {
|
|
||||||
if (!formData.roundId) {
|
|
||||||
toast.error('Please select a round');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (formData.participantUserIds.length === 0) {
|
|
||||||
toast.error('Please select at least one participant');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
createSessionMutation.mutate({
|
|
||||||
competitionId: params.competitionId,
|
|
||||||
roundId: formData.roundId,
|
|
||||||
category: formData.category,
|
|
||||||
mode: formData.mode,
|
|
||||||
tieBreakMethod: formData.tieBreakMethod,
|
|
||||||
showCollectiveRankings: formData.showCollectiveRankings,
|
|
||||||
showPriorJuryData: formData.showPriorJuryData,
|
|
||||||
participantUserIds: formData.participantUserIds
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
|
||||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
|
||||||
DELIB_OPEN: 'outline',
|
|
||||||
VOTING: 'default',
|
|
||||||
TALLYING: 'secondary',
|
|
||||||
RUNOFF: 'secondary',
|
|
||||||
DELIB_LOCKED: 'secondary',
|
|
||||||
};
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
DELIB_OPEN: 'Open',
|
|
||||||
VOTING: 'Voting',
|
|
||||||
TALLYING: 'Tallying',
|
|
||||||
RUNOFF: 'Runoff',
|
|
||||||
DELIB_LOCKED: 'Locked',
|
|
||||||
};
|
|
||||||
return <Badge variant={variants[status] || 'outline'}>{labels[status] || status}</Badge>;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isCompError || isSessionsError) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 p-4 sm:p-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Go back">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Error Loading Deliberations</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Could not load competition or deliberation data. Please try again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 p-4 sm:p-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Skeleton className="h-8 w-8 shrink-0" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-8 w-64" />
|
|
||||||
<Skeleton className="h-4 w-96 max-w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<Skeleton key={i} className="h-32 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 p-4 sm:p-6">
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Back to competition details">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Deliberation Sessions</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Manage final jury deliberations and winner selection
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
New Session
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
|
||||||
{sessions?.map((session: any) => (
|
|
||||||
<Link
|
|
||||||
key={session.id}
|
|
||||||
href={
|
|
||||||
`/admin/competitions/${params.competitionId}/deliberation/${session.id}` as Route
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle>
|
|
||||||
{session.round?.name} - {session.category === 'BUSINESS_CONCEPT' ? 'Business Concept' : session.category === 'STARTUP' ? 'Startup' : session.category}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="mt-1">
|
|
||||||
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
{getStatusBadge(session.status)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>{session.participants?.length || 0} participants</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Tie break: {session.tieBreakMethod === 'TIE_RUNOFF' ? 'Runoff Vote' : session.tieBreakMethod === 'TIE_ADMIN_DECIDES' ? 'Admin Decides' : session.tieBreakMethod === 'SCORE_FALLBACK' ? 'Score Fallback' : session.tieBreakMethod}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sessions?.length === 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<p className="text-muted-foreground">No deliberation sessions yet</p>
|
|
||||||
<Button variant="link" onClick={() => setCreateDialogOpen(true)}>
|
|
||||||
Create your first session
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Create Session Dialog */}
|
|
||||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create Deliberation Session</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Set up a new deliberation session for final winner selection
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="round">Round *</Label>
|
|
||||||
<Select value={formData.roundId} onValueChange={(value) => setFormData({ ...formData, roundId: value })}>
|
|
||||||
<SelectTrigger id="round">
|
|
||||||
<SelectValue placeholder="Select round" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{rounds?.map((round: any) => (
|
|
||||||
<SelectItem key={round.id} value={round.id}>
|
|
||||||
{round.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="category">Category *</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.category}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData({ ...formData, category: value as 'STARTUP' | 'BUSINESS_CONCEPT' })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="category">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="STARTUP">Startup</SelectItem>
|
|
||||||
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="mode">Voting Mode *</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.mode}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
mode: value as 'SINGLE_WINNER_VOTE' | 'FULL_RANKING'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="mode">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="SINGLE_WINNER_VOTE">Single Winner Vote</SelectItem>
|
|
||||||
<SelectItem value="FULL_RANKING">Full Ranking</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="tieBreak">Tie Break Method *</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.tieBreakMethod}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
tieBreakMethod: value as 'TIE_RUNOFF' | 'TIE_ADMIN_DECIDES' | 'SCORE_FALLBACK'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="tieBreak">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="TIE_RUNOFF">Runoff Vote</SelectItem>
|
|
||||||
<SelectItem value="TIE_ADMIN_DECIDES">Admin Decides</SelectItem>
|
|
||||||
<SelectItem value="SCORE_FALLBACK">Score Fallback</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Participant Selection */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="juryGroup">Jury Group *</Label>
|
|
||||||
<Select
|
|
||||||
value={selectedJuryGroupId}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setSelectedJuryGroupId(value);
|
|
||||||
setFormData({ ...formData, participantUserIds: [] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="juryGroup">
|
|
||||||
<SelectValue placeholder="Select jury group" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{juryGroups.map((group: any) => (
|
|
||||||
<SelectItem key={group.id} value={group.id}>
|
|
||||||
{group.name} ({group._count?.members ?? 0} members)
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{juryMembers.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label>Participants ({formData.participantUserIds.length}/{juryMembers.length})</Label>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const allIds = juryMembers.map((m: any) => m.user.id);
|
|
||||||
const allSelected = allIds.every((id: string) => formData.participantUserIds.includes(id));
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
participantUserIds: allSelected ? [] : allIds,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{juryMembers.every((m: any) => formData.participantUserIds.includes(m.user.id))
|
|
||||||
? 'Deselect All'
|
|
||||||
: 'Select All'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-48 space-y-2 overflow-y-auto rounded-md border p-3">
|
|
||||||
{juryMembers.map((member: any) => (
|
|
||||||
<div key={member.id} className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`member-${member.user.id}`}
|
|
||||||
checked={formData.participantUserIds.includes(member.user.id)}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
participantUserIds: checked
|
|
||||||
? [...formData.participantUserIds, member.user.id]
|
|
||||||
: formData.participantUserIds.filter((id: string) => id !== member.user.id),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Label htmlFor={`member-${member.user.id}`} className="flex-1 font-normal">
|
|
||||||
{member.user.name || member.user.email}
|
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
|
||||||
{member.role === 'CHAIR' ? 'Chair' : member.role === 'OBSERVER' ? 'Observer' : 'Member'}
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="showCollective"
|
|
||||||
checked={formData.showCollectiveRankings}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setFormData({ ...formData, showCollectiveRankings: checked as boolean })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="showCollective" className="font-normal">
|
|
||||||
Show collective rankings during voting
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="showPrior"
|
|
||||||
checked={formData.showPriorJuryData}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setFormData({ ...formData, showPriorJuryData: checked as boolean })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="showPrior" className="font-normal">
|
|
||||||
Show prior jury evaluation data
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreateSession} disabled={createSessionMutation.isPending}>
|
|
||||||
{createSessionMutation.isPending ? 'Creating...' : 'Create Session'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
|
||||||
import { ArrowLeft } from 'lucide-react'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { JuryMembersTable } from '@/components/admin/jury/jury-members-table'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
|
|
||||||
export default function JuryGroupDetailPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const juryGroupId = params.juryGroupId as string
|
|
||||||
|
|
||||||
const { data: juryGroup, isLoading } = trpc.juryGroup.getById.useQuery(
|
|
||||||
{ id: juryGroupId },
|
|
||||||
{ refetchInterval: 30_000 }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
<Skeleton className="h-96 w-full" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!juryGroup) {
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
||||||
<p>Jury group not found</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
||||||
<Button variant="ghost" onClick={() => router.back()} className="mb-4" aria-label="Back to jury groups list">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back to Juries
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">{juryGroup.name}</h1>
|
|
||||||
<p className="text-muted-foreground">{juryGroup.slug}</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary" className="text-lg px-4 py-2">
|
|
||||||
{juryGroup.defaultCapMode}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="members" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="members">Members</TabsTrigger>
|
|
||||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="members" className="mt-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Jury Members</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Manage the members of this jury group
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<JuryMembersTable
|
|
||||||
juryGroupId={juryGroupId}
|
|
||||||
members={juryGroup.members}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="settings" className="mt-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Jury Group Settings</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
View and edit settings for this jury group
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Name</h3>
|
|
||||||
<p className="text-base font-medium">{juryGroup.name}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Slug</h3>
|
|
||||||
<p className="text-base font-medium">{juryGroup.slug}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Default Max Assignments</h3>
|
|
||||||
<p className="text-base font-medium">{juryGroup.defaultMaxAssignments}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Default Cap Mode</h3>
|
|
||||||
<Badge variant="secondary">{juryGroup.defaultCapMode}</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Soft Cap Buffer</h3>
|
|
||||||
<p className="text-base font-medium">{juryGroup.softCapBuffer}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Allow Juror Cap Adjustment</h3>
|
|
||||||
<Badge variant={juryGroup.allowJurorCapAdjustment ? 'default' : 'secondary'}>
|
|
||||||
{juryGroup.allowJurorCapAdjustment ? 'Yes' : 'No'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Allow Ratio Adjustment</h3>
|
|
||||||
<Badge variant={juryGroup.allowJurorRatioAdjustment ? 'default' : 'secondary'}>
|
|
||||||
{juryGroup.allowJurorRatioAdjustment ? 'Yes' : 'No'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Category Quotas Enabled</h3>
|
|
||||||
<Badge variant={juryGroup.categoryQuotasEnabled ? 'default' : 'secondary'}>
|
|
||||||
{juryGroup.categoryQuotasEnabled ? 'Yes' : 'No'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{juryGroup.description && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">Description</h3>
|
|
||||||
<p className="text-base">{juryGroup.description}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { ArrowLeft, Plus, Users } from 'lucide-react'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
export default function JuriesListPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const competitionId = params.competitionId as string
|
|
||||||
const utils = trpc.useUtils()
|
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: juryGroups, isLoading } = trpc.juryGroup.list.useQuery({ competitionId })
|
|
||||||
|
|
||||||
const createMutation = trpc.juryGroup.create.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.juryGroup.list.invalidate({ competitionId })
|
|
||||||
toast.success('Jury group created')
|
|
||||||
setCreateOpen(false)
|
|
||||||
setFormData({ name: '', description: '' })
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleCreate = () => {
|
|
||||||
if (!formData.name.trim()) {
|
|
||||||
toast.error('Name is required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const slug = formData.name
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-|-$/g, '')
|
|
||||||
createMutation.mutate({
|
|
||||||
competitionId,
|
|
||||||
name: formData.name.trim(),
|
|
||||||
slug,
|
|
||||||
description: formData.description.trim() || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<Skeleton key={i} className="h-40" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
className="mb-4"
|
|
||||||
aria-label="Back to competition details"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Jury Groups</h1>
|
|
||||||
<p className="text-muted-foreground">Manage jury groups and members for this competition</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => setCreateOpen(true)}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Create Jury Group
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{juryGroups && juryGroups.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<Users className="h-12 w-12 text-muted-foreground mb-4" />
|
|
||||||
<p className="text-muted-foreground text-center">No jury groups yet. Create one to get started.</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{juryGroups?.map((group) => (
|
|
||||||
<Link
|
|
||||||
key={group.id}
|
|
||||||
href={`/admin/competitions/${competitionId}/juries/${group.id}` as Route}
|
|
||||||
>
|
|
||||||
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
{group.name}
|
|
||||||
<Badge variant="secondary">{group.defaultCapMode}</Badge>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>{group.slug}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Members</span>
|
|
||||||
<span className="font-medium">{group._count.members}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Assignments</span>
|
|
||||||
<span className="font-medium">{group._count.assignments || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Default Max</span>
|
|
||||||
<span className="font-medium">{group.defaultMaxAssignments}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Create Jury Group Dialog */}
|
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create Jury Group</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Create a new jury group for this competition. You can add members after creation.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="jury-name">Name *</Label>
|
|
||||||
<Input
|
|
||||||
id="jury-name"
|
|
||||||
placeholder="e.g. Main Jury Panel"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="jury-description">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="jury-description"
|
|
||||||
placeholder="Optional description of this jury group's role"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreate} disabled={createMutation.isPending}>
|
|
||||||
{createMutation.isPending ? 'Creating...' : 'Create'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { use } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
import { LiveControlPanel } from '@/components/admin/live/live-control-panel';
|
|
||||||
import type { Route } from 'next';
|
|
||||||
|
|
||||||
export default function LiveFinalsPage({
|
|
||||||
params: paramsPromise
|
|
||||||
}: {
|
|
||||||
params: Promise<{ competitionId: string; roundId: string }>;
|
|
||||||
}) {
|
|
||||||
const params = use(paramsPromise);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => router.push(`/admin/competitions/${params.competitionId}` as Route)}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Live Finals Control</h1>
|
|
||||||
<p className="text-muted-foreground">Manage live ceremony presentation and voting</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LiveControlPanel roundId={params.roundId} competitionId={params.competitionId} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,599 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
ChevronDown,
|
|
||||||
Layers,
|
|
||||||
Users,
|
|
||||||
FolderKanban,
|
|
||||||
ClipboardList,
|
|
||||||
Settings,
|
|
||||||
MoreHorizontal,
|
|
||||||
Archive,
|
|
||||||
Loader2,
|
|
||||||
Plus,
|
|
||||||
CalendarDays,
|
|
||||||
Radio,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
|
|
||||||
|
|
||||||
const ROUND_TYPES = [
|
|
||||||
{ value: 'INTAKE', label: 'Intake' },
|
|
||||||
{ value: 'FILTERING', label: 'Filtering' },
|
|
||||||
{ value: 'EVALUATION', label: 'Evaluation' },
|
|
||||||
{ value: 'SUBMISSION', label: 'Submission' },
|
|
||||||
{ value: 'MENTORING', label: 'Mentoring' },
|
|
||||||
{ value: 'LIVE_FINAL', label: 'Live Final' },
|
|
||||||
{ value: 'DELIBERATION', label: 'Deliberation' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const statusConfig = {
|
|
||||||
DRAFT: {
|
|
||||||
label: 'Draft',
|
|
||||||
bgClass: 'bg-gray-100 text-gray-700',
|
|
||||||
dotClass: 'bg-gray-500',
|
|
||||||
},
|
|
||||||
ACTIVE: {
|
|
||||||
label: 'Active',
|
|
||||||
bgClass: 'bg-emerald-100 text-emerald-700',
|
|
||||||
dotClass: 'bg-emerald-500',
|
|
||||||
},
|
|
||||||
CLOSED: {
|
|
||||||
label: 'Closed',
|
|
||||||
bgClass: 'bg-blue-100 text-blue-700',
|
|
||||||
dotClass: 'bg-blue-500',
|
|
||||||
},
|
|
||||||
ARCHIVED: {
|
|
||||||
label: 'Archived',
|
|
||||||
bgClass: 'bg-muted text-muted-foreground',
|
|
||||||
dotClass: 'bg-muted-foreground',
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const roundTypeColors: Record<string, string> = {
|
|
||||||
INTAKE: 'bg-gray-100 text-gray-700',
|
|
||||||
FILTERING: 'bg-amber-100 text-amber-700',
|
|
||||||
EVALUATION: 'bg-blue-100 text-blue-700',
|
|
||||||
SUBMISSION: 'bg-purple-100 text-purple-700',
|
|
||||||
MENTORING: 'bg-teal-100 text-teal-700',
|
|
||||||
LIVE_FINAL: 'bg-red-100 text-red-700',
|
|
||||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CompetitionDetailPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const competitionId = params.competitionId as string
|
|
||||||
const utils = trpc.useUtils()
|
|
||||||
const [addRoundOpen, setAddRoundOpen] = useState(false)
|
|
||||||
const [roundForm, setRoundForm] = useState({
|
|
||||||
name: '',
|
|
||||||
roundType: '' as string,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: competition, isLoading } = trpc.competition.getById.useQuery(
|
|
||||||
{ id: competitionId },
|
|
||||||
{ refetchInterval: 30_000 }
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateMutation = trpc.competition.update.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.competition.getById.invalidate({ id: competitionId })
|
|
||||||
toast.success('Competition updated')
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const createRoundMutation = trpc.round.create.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.competition.getById.invalidate({ id: competitionId })
|
|
||||||
toast.success('Round created')
|
|
||||||
setAddRoundOpen(false)
|
|
||||||
setRoundForm({ name: '', roundType: '' })
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleStatusChange = (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => {
|
|
||||||
updateMutation.mutate({ id: competitionId, status: newStatus })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateRound = () => {
|
|
||||||
if (!roundForm.name.trim() || !roundForm.roundType) {
|
|
||||||
toast.error('Name and type are required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const slug = roundForm.name
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-|-$/g, '')
|
|
||||||
const nextOrder = competition?.rounds.length ?? 0
|
|
||||||
createRoundMutation.mutate({
|
|
||||||
competitionId,
|
|
||||||
name: roundForm.name.trim(),
|
|
||||||
slug,
|
|
||||||
roundType: roundForm.roundType as any,
|
|
||||||
sortOrder: nextOrder,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Skeleton className="h-8 w-8" />
|
|
||||||
<div>
|
|
||||||
<Skeleton className="h-6 w-48" />
|
|
||||||
<Skeleton className="h-4 w-32 mt-1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!competition) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link href={"/admin/competitions" as Route}>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competitions list">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Competition Not Found</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
The requested competition does not exist
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = competition.status as keyof typeof statusConfig
|
|
||||||
const config = statusConfig[status] || statusConfig.DRAFT
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div className="flex items-start gap-3 min-w-0">
|
|
||||||
<Link href={"/admin/competitions" as Route} className="mt-1 shrink-0">
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competitions list">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<h1 className="text-xl font-bold truncate">{competition.name}</h1>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-colors shrink-0',
|
|
||||||
config.bgClass,
|
|
||||||
'hover:opacity-80'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{config.label}
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
{(['DRAFT', 'ACTIVE', 'CLOSED'] as const).map((s) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={s}
|
|
||||||
onClick={() => handleStatusChange(s)}
|
|
||||||
disabled={competition.status === s || updateMutation.isPending}
|
|
||||||
>
|
|
||||||
{s.charAt(0) + s.slice(1).toLowerCase()}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleStatusChange('ARCHIVED')}
|
|
||||||
disabled={competition.status === 'ARCHIVED' || updateMutation.isPending}
|
|
||||||
>
|
|
||||||
<Archive className="h-4 w-4 mr-2" />
|
|
||||||
Archive
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground font-mono">{competition.slug}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" className="h-8 w-8" aria-label="More actions">
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={`/admin/competitions/${competitionId}/assignments` as Route}>
|
|
||||||
<ClipboardList className="h-4 w-4 mr-2" />
|
|
||||||
Assignments
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={`/admin/competitions/${competitionId}/deliberation` as Route}>
|
|
||||||
<Users className="h-4 w-4 mr-2" />
|
|
||||||
Deliberation
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleStatusChange('ARCHIVED')}
|
|
||||||
disabled={updateMutation.isPending}
|
|
||||||
>
|
|
||||||
{updateMutation.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Archive className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
Archive
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Cards */}
|
|
||||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-4 pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Layers className="h-4 w-4 text-blue-500" />
|
|
||||||
<span className="text-sm font-medium">Rounds</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold mt-1">{competition.rounds.filter((r: any) => !r.specialAwardId).length}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-4 pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Users className="h-4 w-4 text-purple-500" />
|
|
||||||
<span className="text-sm font-medium">Juries</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold mt-1">{competition.juryGroups.length}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-4 pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FolderKanban className="h-4 w-4 text-emerald-500" />
|
|
||||||
<span className="text-sm font-medium">Projects</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold mt-1">
|
|
||||||
{(competition as any).distinctProjectCount ?? 0}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-4 pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Settings className="h-4 w-4 text-amber-500" />
|
|
||||||
<span className="text-sm font-medium">Category</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-lg font-bold mt-1 truncate">{competition.categoryMode}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<Tabs defaultValue="overview" className="space-y-4">
|
|
||||||
<TabsList className="w-full sm:w-auto overflow-x-auto">
|
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
||||||
<TabsTrigger value="rounds">Rounds</TabsTrigger>
|
|
||||||
<TabsTrigger value="juries">Juries</TabsTrigger>
|
|
||||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* Overview Tab */}
|
|
||||||
<TabsContent value="overview" className="space-y-6">
|
|
||||||
<CompetitionTimeline
|
|
||||||
competitionId={competitionId}
|
|
||||||
rounds={competition.rounds.filter((r: any) => !r.specialAwardId)}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Rounds Tab */}
|
|
||||||
<TabsContent value="rounds" className="space-y-4">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<h2 className="text-lg font-semibold">Rounds ({competition.rounds.filter((r: any) => !r.specialAwardId).length})</h2>
|
|
||||||
<Button size="sm" variant="outline" className="w-full sm:w-auto" onClick={() => setAddRoundOpen(true)}>
|
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
|
||||||
Add Round
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{competition.rounds.filter((r: any) => !r.specialAwardId).length === 0 ? (
|
|
||||||
<Card className="border-dashed">
|
|
||||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
No rounds configured. Add rounds to define the competition flow.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{competition.rounds.filter((r: any) => !r.specialAwardId).map((round: any, index: number) => {
|
|
||||||
const projectCount = round._count?.projectRoundStates ?? 0
|
|
||||||
const assignmentCount = round._count?.assignments ?? 0
|
|
||||||
const statusLabel = round.status.replace('ROUND_', '')
|
|
||||||
const statusColors: Record<string, string> = {
|
|
||||||
DRAFT: 'bg-gray-100 text-gray-600',
|
|
||||||
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
|
||||||
CLOSED: 'bg-blue-100 text-blue-700',
|
|
||||||
ARCHIVED: 'bg-muted text-muted-foreground',
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={round.id}
|
|
||||||
href={`/admin/rounds/${round.id}` as Route}
|
|
||||||
>
|
|
||||||
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
|
|
||||||
<CardContent className="pt-4 pb-3 space-y-3">
|
|
||||||
{/* Top: number + name + badges */}
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-semibold truncate">{round.name}</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className={cn(
|
|
||||||
'text-[10px]',
|
|
||||||
roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{round.roundType.replace(/_/g, ' ')}
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn('text-[10px]', statusColors[statusLabel])}
|
|
||||||
>
|
|
||||||
{statusLabel}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats row */}
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<Layers className="h-3.5 w-3.5" />
|
|
||||||
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
{(round.roundType === 'EVALUATION' || round.roundType === 'FILTERING') && (
|
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<ClipboardList className="h-3.5 w-3.5" />
|
|
||||||
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dates */}
|
|
||||||
{(round.windowOpenAt || round.windowCloseAt) && (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span>
|
|
||||||
{round.windowOpenAt
|
|
||||||
? new Date(round.windowOpenAt).toLocaleDateString()
|
|
||||||
: '?'}
|
|
||||||
{' \u2014 '}
|
|
||||||
{round.windowCloseAt
|
|
||||||
? new Date(round.windowCloseAt).toLocaleDateString()
|
|
||||||
: '?'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Jury group */}
|
|
||||||
{round.juryGroup && (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
<Users className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="truncate">{round.juryGroup.name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Live Control link for LIVE_FINAL rounds */}
|
|
||||||
{round.roundType === 'LIVE_FINAL' && (
|
|
||||||
<Link
|
|
||||||
href={`/admin/competitions/${competitionId}/live/${round.id}` as Route}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Button size="sm" variant="outline" className="w-full text-xs gap-1.5">
|
|
||||||
<Radio className="h-3.5 w-3.5" />
|
|
||||||
Live Control
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Juries Tab */}
|
|
||||||
<TabsContent value="juries" className="space-y-4">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<h2 className="text-lg font-semibold">Jury Groups ({competition.juryGroups.length})</h2>
|
|
||||||
<Link href={`/admin/competitions/${competitionId}/juries` as Route}>
|
|
||||||
<Button size="sm" variant="outline" className="w-full sm:w-auto">
|
|
||||||
<Users className="h-4 w-4 mr-1" />
|
|
||||||
Manage Juries
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{competition.juryGroups.length === 0 ? (
|
|
||||||
<Card className="border-dashed">
|
|
||||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
No jury groups configured. Create jury groups to assign evaluators.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{competition.juryGroups.map((group) => (
|
|
||||||
<Link
|
|
||||||
key={group.id}
|
|
||||||
href={`/admin/competitions/${competitionId}/juries/${group.id}` as Route}
|
|
||||||
>
|
|
||||||
<Card className="hover:shadow-sm transition-shadow cursor-pointer h-full">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm">{group.name}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
||||||
<span>{group._count.members} members</span>
|
|
||||||
<Badge variant="outline" className="text-[10px]">
|
|
||||||
Cap: {group.defaultCapMode}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Settings Tab */}
|
|
||||||
<TabsContent value="settings" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Competition Settings</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-muted-foreground">Category Mode</label>
|
|
||||||
<p className="text-sm mt-1">{competition.categoryMode}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-muted-foreground">Startup Finalists</label>
|
|
||||||
<p className="text-sm mt-1">{competition.startupFinalistCount}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-muted-foreground">Concept Finalists</label>
|
|
||||||
<p className="text-sm mt-1">{competition.conceptFinalistCount}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-muted-foreground">Notifications</label>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-1">
|
|
||||||
{competition.notifyOnDeadlineApproach && (
|
|
||||||
<Badge variant="secondary" className="text-[10px]">Deadline Approach</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{competition.deadlineReminderDays && (
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-muted-foreground">
|
|
||||||
Reminder Days
|
|
||||||
</label>
|
|
||||||
<p className="text-sm mt-1">
|
|
||||||
{(competition.deadlineReminderDays as number[]).join(', ')} days before deadline
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* Add Round Dialog */}
|
|
||||||
<Dialog open={addRoundOpen} onOpenChange={setAddRoundOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add Round</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Add a new round to this competition. It will be appended to the current round sequence.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="round-name">Name *</Label>
|
|
||||||
<Input
|
|
||||||
id="round-name"
|
|
||||||
placeholder="e.g. Initial Screening"
|
|
||||||
value={roundForm.name}
|
|
||||||
onChange={(e) => setRoundForm({ ...roundForm, name: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="round-type">Round Type *</Label>
|
|
||||||
<Select
|
|
||||||
value={roundForm.roundType}
|
|
||||||
onValueChange={(value) => setRoundForm({ ...roundForm, roundType: value })}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="round-type">
|
|
||||||
<SelectValue placeholder="Select type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{ROUND_TYPES.map((rt) => (
|
|
||||||
<SelectItem key={rt.value} value={rt.value}>
|
|
||||||
{rt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setAddRoundOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreateRound} disabled={createRoundMutation.isPending}>
|
|
||||||
{createRoundMutation.isPending ? 'Creating...' : 'Create Round'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { SidebarStepper } from '@/components/ui/sidebar-stepper'
|
|
||||||
import type { StepConfig } from '@/components/ui/sidebar-stepper'
|
|
||||||
import { ArrowLeft } from 'lucide-react'
|
|
||||||
import { BasicsSection } from '@/components/admin/competition/sections/basics-section'
|
|
||||||
import { RoundsSection } from '@/components/admin/competition/sections/rounds-section'
|
|
||||||
import { JuryGroupsSection } from '@/components/admin/competition/sections/jury-groups-section'
|
|
||||||
import { ReviewSection } from '@/components/admin/competition/sections/review-section'
|
|
||||||
import { useEdition } from '@/contexts/edition-context'
|
|
||||||
|
|
||||||
type WizardRound = {
|
|
||||||
tempId: string
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
roundType: string
|
|
||||||
sortOrder: number
|
|
||||||
configJson: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
type WizardJuryGroup = {
|
|
||||||
tempId: string
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
defaultMaxAssignments: number
|
|
||||||
defaultCapMode: string
|
|
||||||
sortOrder: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type WizardState = {
|
|
||||||
programId: string
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
categoryMode: string
|
|
||||||
startupFinalistCount: number
|
|
||||||
conceptFinalistCount: number
|
|
||||||
notifyOnRoundAdvance: boolean
|
|
||||||
notifyOnDeadlineApproach: boolean
|
|
||||||
deadlineReminderDays: number[]
|
|
||||||
rounds: WizardRound[]
|
|
||||||
juryGroups: WizardJuryGroup[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultRounds: WizardRound[] = [
|
|
||||||
{
|
|
||||||
tempId: crypto.randomUUID(),
|
|
||||||
name: 'Intake',
|
|
||||||
slug: 'intake',
|
|
||||||
roundType: 'INTAKE',
|
|
||||||
sortOrder: 0,
|
|
||||||
configJson: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tempId: crypto.randomUUID(),
|
|
||||||
name: 'Filtering',
|
|
||||||
slug: 'filtering',
|
|
||||||
roundType: 'FILTERING',
|
|
||||||
sortOrder: 1,
|
|
||||||
configJson: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tempId: crypto.randomUUID(),
|
|
||||||
name: 'Evaluation (Jury 1)',
|
|
||||||
slug: 'evaluation-jury-1',
|
|
||||||
roundType: 'EVALUATION',
|
|
||||||
sortOrder: 2,
|
|
||||||
configJson: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tempId: crypto.randomUUID(),
|
|
||||||
name: 'Submission',
|
|
||||||
slug: 'submission',
|
|
||||||
roundType: 'SUBMISSION',
|
|
||||||
sortOrder: 3,
|
|
||||||
configJson: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tempId: crypto.randomUUID(),
|
|
||||||
name: 'Evaluation (Jury 2)',
|
|
||||||
slug: 'evaluation-jury-2',
|
|
||||||
roundType: 'EVALUATION',
|
|
||||||
sortOrder: 4,
|
|
||||||
configJson: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tempId: crypto.randomUUID(),
|
|
||||||
name: 'Mentoring',
|
|
||||||
slug: 'mentoring',
|
|
||||||
roundType: 'MENTORING',
|
|
||||||
sortOrder: 5,
|
|
||||||
configJson: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tempId: crypto.randomUUID(),
|
|
||||||
name: 'Live Final',
|
|
||||||
slug: 'live-final',
|
|
||||||
roundType: 'LIVE_FINAL',
|
|
||||||
sortOrder: 6,
|
|
||||||
configJson: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tempId: crypto.randomUUID(),
|
|
||||||
name: 'Deliberation',
|
|
||||||
slug: 'deliberation',
|
|
||||||
roundType: 'DELIBERATION',
|
|
||||||
sortOrder: 7,
|
|
||||||
configJson: {},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function NewCompetitionPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const { currentEdition } = useEdition()
|
|
||||||
const paramProgramId = searchParams.get('programId')
|
|
||||||
const programId = paramProgramId || currentEdition?.id || ''
|
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState(0)
|
|
||||||
const [isDirty, setIsDirty] = useState(false)
|
|
||||||
|
|
||||||
const [state, setState] = useState<WizardState>({
|
|
||||||
programId,
|
|
||||||
name: '',
|
|
||||||
slug: '',
|
|
||||||
categoryMode: 'SHARED',
|
|
||||||
startupFinalistCount: 3,
|
|
||||||
conceptFinalistCount: 3,
|
|
||||||
notifyOnRoundAdvance: true,
|
|
||||||
notifyOnDeadlineApproach: true,
|
|
||||||
deadlineReminderDays: [7, 3, 1],
|
|
||||||
rounds: defaultRounds,
|
|
||||||
juryGroups: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (programId) {
|
|
||||||
setState((prev) => ({ ...prev, programId }))
|
|
||||||
}
|
|
||||||
}, [programId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
||||||
if (isDirty) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.returnValue = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
}, [isDirty])
|
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
|
||||||
const createCompetitionMutation = trpc.competition.create.useMutation()
|
|
||||||
const createRoundMutation = trpc.round.create.useMutation()
|
|
||||||
const createJuryGroupMutation = trpc.juryGroup.create.useMutation()
|
|
||||||
|
|
||||||
const handleStateChange = (updates: Partial<WizardState>) => {
|
|
||||||
setState((prev) => ({ ...prev, ...updates }))
|
|
||||||
setIsDirty(true)
|
|
||||||
|
|
||||||
// Auto-generate slug from name if name changed
|
|
||||||
if (updates.name !== undefined && updates.slug === undefined) {
|
|
||||||
const autoSlug = updates.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
|
||||||
setState((prev) => ({ ...prev, slug: autoSlug }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!state.name.trim()) {
|
|
||||||
toast.error('Competition name is required')
|
|
||||||
setCurrentStep(0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.slug.trim()) {
|
|
||||||
toast.error('Competition slug is required')
|
|
||||||
setCurrentStep(0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.rounds.length === 0) {
|
|
||||||
toast.error('At least one round is required')
|
|
||||||
setCurrentStep(1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create competition
|
|
||||||
const competition = await createCompetitionMutation.mutateAsync({
|
|
||||||
programId: state.programId,
|
|
||||||
name: state.name,
|
|
||||||
slug: state.slug,
|
|
||||||
categoryMode: state.categoryMode,
|
|
||||||
startupFinalistCount: state.startupFinalistCount,
|
|
||||||
conceptFinalistCount: state.conceptFinalistCount,
|
|
||||||
notifyOnRoundAdvance: state.notifyOnRoundAdvance,
|
|
||||||
notifyOnDeadlineApproach: state.notifyOnDeadlineApproach,
|
|
||||||
deadlineReminderDays: state.deadlineReminderDays,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create rounds
|
|
||||||
for (const round of state.rounds) {
|
|
||||||
await createRoundMutation.mutateAsync({
|
|
||||||
competitionId: competition.id,
|
|
||||||
name: round.name,
|
|
||||||
slug: round.slug,
|
|
||||||
roundType: round.roundType as any,
|
|
||||||
sortOrder: round.sortOrder,
|
|
||||||
configJson: round.configJson,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create jury groups
|
|
||||||
for (const group of state.juryGroups) {
|
|
||||||
await createJuryGroupMutation.mutateAsync({
|
|
||||||
competitionId: competition.id,
|
|
||||||
name: group.name,
|
|
||||||
slug: group.slug,
|
|
||||||
defaultMaxAssignments: group.defaultMaxAssignments,
|
|
||||||
defaultCapMode: group.defaultCapMode as any,
|
|
||||||
sortOrder: group.sortOrder,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Competition created successfully')
|
|
||||||
setIsDirty(false)
|
|
||||||
utils.competition.list.invalidate()
|
|
||||||
router.push(`/admin/competitions/${competition.id}` as Route)
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.error(err.message || 'Failed to create competition')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const steps: StepConfig[] = [
|
|
||||||
{
|
|
||||||
title: 'Basics',
|
|
||||||
description: 'Name and settings',
|
|
||||||
isValid: !!state.name && !!state.slug,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Rounds',
|
|
||||||
description: 'Configure rounds',
|
|
||||||
isValid: state.rounds.length > 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Jury Groups',
|
|
||||||
description: 'Add jury groups',
|
|
||||||
isValid: true, // Optional
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Review',
|
|
||||||
description: 'Confirm and create',
|
|
||||||
isValid: !!state.name && !!state.slug && state.rounds.length > 0,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const canSubmit = steps.every((s) => s.isValid)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link href={'/admin/competitions' as Route}>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competitions list">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">New Competition</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Create a multi-round competition workflow
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Wizard */}
|
|
||||||
<SidebarStepper
|
|
||||||
steps={steps}
|
|
||||||
currentStep={currentStep}
|
|
||||||
onStepChange={setCurrentStep}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
isSubmitting={
|
|
||||||
createCompetitionMutation.isPending ||
|
|
||||||
createRoundMutation.isPending ||
|
|
||||||
createJuryGroupMutation.isPending
|
|
||||||
}
|
|
||||||
submitLabel="Create Competition"
|
|
||||||
canSubmit={canSubmit}
|
|
||||||
>
|
|
||||||
<BasicsSection state={state} onChange={handleStateChange} />
|
|
||||||
<RoundsSection rounds={state.rounds} onChange={(rounds) => handleStateChange({ rounds })} />
|
|
||||||
<JuryGroupsSection
|
|
||||||
juryGroups={state.juryGroups}
|
|
||||||
onChange={(juryGroups) => handleStateChange({ juryGroups })}
|
|
||||||
/>
|
|
||||||
<ReviewSection state={state} />
|
|
||||||
</SidebarStepper>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import Link from 'next/link'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
Medal,
|
|
||||||
Calendar,
|
|
||||||
Users,
|
|
||||||
Layers,
|
|
||||||
FileBox,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
|
||||||
import { useEdition } from '@/contexts/edition-context'
|
|
||||||
|
|
||||||
const statusConfig = {
|
|
||||||
DRAFT: {
|
|
||||||
label: 'Draft',
|
|
||||||
bgClass: 'bg-gray-100 text-gray-700',
|
|
||||||
dotClass: 'bg-gray-500',
|
|
||||||
},
|
|
||||||
ACTIVE: {
|
|
||||||
label: 'Active',
|
|
||||||
bgClass: 'bg-emerald-100 text-emerald-700',
|
|
||||||
dotClass: 'bg-emerald-500',
|
|
||||||
},
|
|
||||||
CLOSED: {
|
|
||||||
label: 'Closed',
|
|
||||||
bgClass: 'bg-blue-100 text-blue-700',
|
|
||||||
dotClass: 'bg-blue-500',
|
|
||||||
},
|
|
||||||
ARCHIVED: {
|
|
||||||
label: 'Archived',
|
|
||||||
bgClass: 'bg-muted text-muted-foreground',
|
|
||||||
dotClass: 'bg-muted-foreground',
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export default function CompetitionListPage() {
|
|
||||||
const { currentEdition } = useEdition()
|
|
||||||
const programId = currentEdition?.id
|
|
||||||
|
|
||||||
const { data: competitions, isLoading } = trpc.competition.list.useQuery(
|
|
||||||
{ programId: programId! },
|
|
||||||
{ enabled: !!programId, refetchInterval: 30_000 }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!programId) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Competitions</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Select an edition to view competitions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<Calendar className="h-12 w-12 text-muted-foreground/50" />
|
|
||||||
<p className="mt-2 font-medium">No Edition Selected</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Select an edition from the sidebar to view its competitions
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 px-4 sm:px-0">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Competitions</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Manage competitions for {currentEdition?.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
|
|
||||||
<Button size="sm" className="w-full sm:w-auto">
|
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
|
||||||
New Competition
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading */}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<Card key={i}>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-5 w-32" />
|
|
||||||
<Skeleton className="h-4 w-20 mt-1" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className="h-4 w-3/4 mt-2" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty State */}
|
|
||||||
{!isLoading && (!competitions || competitions.length === 0) && (
|
|
||||||
<Card className="border-2 border-dashed">
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
|
||||||
<div className="rounded-full bg-primary/10 p-4 mb-4">
|
|
||||||
<Medal className="h-10 w-10 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold mb-2">No Competitions Yet</h3>
|
|
||||||
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
|
||||||
Competitions organize your multi-round evaluation workflow with jury groups,
|
|
||||||
submission windows, and scoring. Create your first competition to get started.
|
|
||||||
</p>
|
|
||||||
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
|
|
||||||
<Button>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Create Your First Competition
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Competition Cards */}
|
|
||||||
{competitions && competitions.length > 0 && (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{competitions.map((competition) => {
|
|
||||||
const status = competition.status as keyof typeof statusConfig
|
|
||||||
const config = statusConfig[status] || statusConfig.DRAFT
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link key={competition.id} href={`/admin/competitions/${competition.id}` as Route}>
|
|
||||||
<Card className="group cursor-pointer hover:shadow-md transition-shadow h-full flex flex-col">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<CardTitle className="text-base leading-tight">
|
|
||||||
{competition.name}
|
|
||||||
</CardTitle>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 font-mono">
|
|
||||||
{competition.slug}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className={cn(
|
|
||||||
'text-[10px] shrink-0 flex items-center gap-1.5',
|
|
||||||
config.bgClass
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn('h-1.5 w-1.5 rounded-full', config.dotClass)} />
|
|
||||||
{config.label}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="mt-auto">
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Layers className="h-3.5 w-3.5" />
|
|
||||||
<span>{competition._count.rounds} rounds</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Users className="h-3.5 w-3.5" />
|
|
||||||
<span>{competition._count.juryGroups} juries</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<FileBox className="h-3.5 w-3.5" />
|
|
||||||
<span>{competition._count.submissionWindows} windows</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
|
||||||
Updated {formatDistanceToNow(new Date(competition.updatedAt))} ago
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -9,6 +10,17 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Upload,
|
Upload,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
Settings,
|
||||||
|
ClipboardCheck,
|
||||||
|
Users,
|
||||||
|
Send,
|
||||||
|
FileDown,
|
||||||
|
Calendar,
|
||||||
|
Eye,
|
||||||
|
Presentation,
|
||||||
|
Vote,
|
||||||
|
Play,
|
||||||
|
Lock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { GeographicSummaryCard } from '@/components/charts'
|
import { GeographicSummaryCard } from '@/components/charts'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
@@ -29,6 +41,77 @@ type DashboardContentProps = {
|
|||||||
sessionName: string
|
sessionName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QuickAction = {
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
icon: React.ElementType
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContextualActions(
|
||||||
|
activeRound: { id: string; roundType: string } | null
|
||||||
|
): QuickAction[] {
|
||||||
|
if (!activeRound) {
|
||||||
|
return [
|
||||||
|
{ label: 'Rounds', href: '/admin/rounds', icon: CircleDot },
|
||||||
|
{ label: 'Import', href: '/admin/projects/new', icon: Upload },
|
||||||
|
{ label: 'Invite', href: '/admin/members', icon: UserPlus },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundHref = `/admin/rounds/${activeRound.id}`
|
||||||
|
|
||||||
|
switch (activeRound.roundType) {
|
||||||
|
case 'INTAKE':
|
||||||
|
return [
|
||||||
|
{ label: 'Import Projects', href: '/admin/projects/new', icon: Upload },
|
||||||
|
{ label: 'Review', href: roundHref, icon: ClipboardCheck },
|
||||||
|
{ label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
|
||||||
|
]
|
||||||
|
case 'FILTERING':
|
||||||
|
return [
|
||||||
|
{ label: 'Run Screening', href: roundHref, icon: ClipboardCheck },
|
||||||
|
{ label: 'Review Results', href: `${roundHref}?tab=filtering`, icon: Eye },
|
||||||
|
{ label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
|
||||||
|
]
|
||||||
|
case 'EVALUATION':
|
||||||
|
return [
|
||||||
|
{ label: 'Assignments', href: `${roundHref}?tab=assignments`, icon: Users },
|
||||||
|
{ label: 'Send Reminders', href: `${roundHref}?tab=assignments`, icon: Send },
|
||||||
|
{ label: 'Export', href: roundHref, icon: FileDown },
|
||||||
|
]
|
||||||
|
case 'SUBMISSION':
|
||||||
|
return [
|
||||||
|
{ label: 'Submissions', href: roundHref, icon: ClipboardCheck },
|
||||||
|
{ label: 'Deadlines', href: `${roundHref}?tab=config`, icon: Calendar },
|
||||||
|
{ label: 'Status', href: `${roundHref}?tab=projects`, icon: Eye },
|
||||||
|
]
|
||||||
|
case 'MENTORING':
|
||||||
|
return [
|
||||||
|
{ label: 'Mentors', href: `${roundHref}?tab=projects`, icon: Users },
|
||||||
|
{ label: 'Progress', href: roundHref, icon: Eye },
|
||||||
|
{ label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
|
||||||
|
]
|
||||||
|
case 'LIVE_FINAL':
|
||||||
|
return [
|
||||||
|
{ label: 'Live Control', href: roundHref, icon: Presentation },
|
||||||
|
{ label: 'Results', href: `${roundHref}?tab=projects`, icon: Vote },
|
||||||
|
{ label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
|
||||||
|
]
|
||||||
|
case 'DELIBERATION':
|
||||||
|
return [
|
||||||
|
{ label: 'Sessions', href: roundHref, icon: Play },
|
||||||
|
{ label: 'Results', href: `${roundHref}?tab=projects`, icon: Eye },
|
||||||
|
{ label: 'Lock Results', href: roundHref, icon: Lock },
|
||||||
|
]
|
||||||
|
default:
|
||||||
|
return [
|
||||||
|
{ label: 'Rounds', href: '/admin/rounds', icon: CircleDot },
|
||||||
|
{ label: 'Import', href: '/admin/projects/new', icon: Upload },
|
||||||
|
{ label: 'Invite', href: '/admin/members', icon: UserPlus },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardContent({ editionId, sessionName }: DashboardContentProps) {
|
export function DashboardContent({ editionId, sessionName }: DashboardContentProps) {
|
||||||
const { data, isLoading, error } = trpc.dashboard.getStats.useQuery(
|
const { data, isLoading, error } = trpc.dashboard.getStats.useQuery(
|
||||||
{ editionId },
|
{ editionId },
|
||||||
@@ -38,6 +121,10 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||||||
{ editionId, limit: 8 },
|
{ editionId, limit: 8 },
|
||||||
{ enabled: !!editionId, refetchInterval: 30_000 }
|
{ enabled: !!editionId, refetchInterval: 30_000 }
|
||||||
)
|
)
|
||||||
|
const { data: liveActivity } = trpc.dashboard.getRecentActivity.useQuery(
|
||||||
|
{ limit: 8 },
|
||||||
|
{ enabled: !!editionId, refetchInterval: 5_000 }
|
||||||
|
)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <DashboardSkeleton />
|
return <DashboardSkeleton />
|
||||||
@@ -83,6 +170,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||||||
evaluationStats,
|
evaluationStats,
|
||||||
totalAssignments,
|
totalAssignments,
|
||||||
latestProjects,
|
latestProjects,
|
||||||
|
recentlyActiveProjects,
|
||||||
categoryBreakdown,
|
categoryBreakdown,
|
||||||
oceanIssueBreakdown,
|
oceanIssueBreakdown,
|
||||||
recentActivity,
|
recentActivity,
|
||||||
@@ -92,6 +180,17 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||||||
? pipelineRounds.find((r) => r.id === activeRoundId) ?? null
|
? pipelineRounds.find((r) => r.id === activeRoundId) ?? null
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
// Find next draft round for summary panel
|
||||||
|
const lastActiveSortOrder = Math.max(
|
||||||
|
...pipelineRounds.filter((r) => r.status === 'ROUND_ACTIVE').map((r) => r.sortOrder),
|
||||||
|
-1
|
||||||
|
)
|
||||||
|
const nextDraftRound = pipelineRounds.find(
|
||||||
|
(r) => r.status === 'ROUND_DRAFT' && r.sortOrder > lastActiveSortOrder
|
||||||
|
) ?? null
|
||||||
|
|
||||||
|
const quickActions = getContextualActions(activeRound)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
@@ -109,25 +208,15 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||||||
Welcome back, {sessionName}
|
Welcome back, {sessionName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Link href="/admin/rounds">
|
{quickActions.map((action) => (
|
||||||
<Button size="sm" variant="outline">
|
<Link key={action.label} href={action.href as Route}>
|
||||||
<CircleDot className="mr-1.5 h-3.5 w-3.5" />
|
<Button size="sm" variant="outline">
|
||||||
Rounds
|
<action.icon className="mr-1.5 h-3.5 w-3.5" />
|
||||||
</Button>
|
{action.label}
|
||||||
</Link>
|
</Button>
|
||||||
<Link href="/admin/projects/new">
|
</Link>
|
||||||
<Button size="sm" variant="outline">
|
))}
|
||||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
Import
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Link href="/admin/members">
|
|
||||||
<Button size="sm" variant="outline">
|
|
||||||
<UserPlus className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
Invite
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -147,6 +236,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||||||
totalAssignments={totalAssignments}
|
totalAssignments={totalAssignments}
|
||||||
evaluationStats={evaluationStats}
|
evaluationStats={evaluationStats}
|
||||||
actionsCount={nextActions.length}
|
actionsCount={nextActions.length}
|
||||||
|
nextDraftRound={nextDraftRound ? { name: nextDraftRound.name, roundType: nextDraftRound.roundType } : null}
|
||||||
/>
|
/>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
@@ -161,7 +251,11 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<AnimatedCard index={3}>
|
<AnimatedCard index={3}>
|
||||||
<ProjectListCompact projects={latestProjects} />
|
<ProjectListCompact
|
||||||
|
projects={latestProjects}
|
||||||
|
activeProjects={recentlyActiveProjects}
|
||||||
|
mode={activeRound && activeRound.roundType !== 'INTAKE' ? 'active' : 'recent'}
|
||||||
|
/>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
{recentEvals && recentEvals.length > 0 && (
|
{recentEvals && recentEvals.length > 0 && (
|
||||||
@@ -178,7 +272,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
<AnimatedCard index={6}>
|
<AnimatedCard index={6}>
|
||||||
<ActivityFeed activity={recentActivity} />
|
<ActivityFeed activity={liveActivity ?? recentActivity} />
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn, formatEnumLabel } from '@/lib/utils'
|
||||||
import { Plus, Scale, Users, Loader2 } from 'lucide-react'
|
import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot } from 'lucide-react'
|
||||||
|
|
||||||
const capModeLabels = {
|
const capModeLabels = {
|
||||||
HARD: 'Hard Cap',
|
HARD: 'Hard Cap',
|
||||||
@@ -267,33 +267,82 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
|
|||||||
No jury groups configured for this competition.
|
No jury groups configured for this competition.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="space-y-3">
|
||||||
{juryGroups.map((group) => (
|
{juryGroups.map((group) => (
|
||||||
<Link key={group.id} href={`/admin/juries/${group.id}` as Route}>
|
<Link key={group.id} href={`/admin/juries/${group.id}` as Route}>
|
||||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md cursor-pointer">
|
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md cursor-pointer group">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
|
{/* Header row */}
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h3 className="font-semibold text-sm line-clamp-1">{group.name}</h3>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Badge
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-blue/10 shrink-0">
|
||||||
variant="secondary"
|
<Scale className="h-4 w-4 text-brand-blue" />
|
||||||
className={cn('text-[10px] shrink-0', capModeColors[group.defaultCapMode as keyof typeof capModeColors])}
|
</div>
|
||||||
>
|
<div className="min-w-0">
|
||||||
{capModeLabels[group.defaultCapMode as keyof typeof capModeLabels]}
|
<h3 className="font-semibold text-sm line-clamp-1 group-hover:text-brand-blue transition-colors">
|
||||||
</Badge>
|
{group.name}
|
||||||
</div>
|
</h3>
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
<div className="flex items-center gap-1">
|
{group._count.members} member{group._count.members !== 1 ? 's' : ''}
|
||||||
<Users className="h-3 w-3" />
|
{' · '}
|
||||||
<span>{group._count.members} members</span>
|
{group._count.assignments} assignment{group._count.assignments !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{group._count.assignments} assignments
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn('text-[10px]', capModeColors[group.defaultCapMode as keyof typeof capModeColors])}
|
||||||
|
>
|
||||||
|
{capModeLabels[group.defaultCapMode as keyof typeof capModeLabels]}
|
||||||
|
</Badge>
|
||||||
|
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-brand-blue transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Default max: {group.defaultMaxAssignments}
|
{/* Round assignments */}
|
||||||
</div>
|
{(group as any).rounds?.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{(group as any).rounds.map((r: any) => (
|
||||||
|
<Badge
|
||||||
|
key={r.id}
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] gap-1',
|
||||||
|
r.status === 'ROUND_ACTIVE' && 'border-blue-300 bg-blue-50 text-blue-700',
|
||||||
|
r.status === 'ROUND_CLOSED' && 'border-emerald-300 bg-emerald-50 text-emerald-700',
|
||||||
|
r.status === 'ROUND_DRAFT' && 'border-slate-200 text-slate-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CircleDot className="h-2.5 w-2.5" />
|
||||||
|
{r.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Member preview */}
|
||||||
|
{(group as any).members?.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="flex -space-x-1.5">
|
||||||
|
{(group as any).members.slice(0, 5).map((m: any) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="h-6 w-6 rounded-full bg-brand-blue/10 border-2 border-white flex items-center justify-center text-[9px] font-semibold text-brand-blue"
|
||||||
|
title={m.user?.name || m.user?.email}
|
||||||
|
>
|
||||||
|
{(m.user?.name || m.user?.email || '?').charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{group._count.members > 5 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
+{group._count.members - 5} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
@@ -8,15 +8,11 @@ import { trpc } from '@/lib/trpc/client'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
Card,
|
import { Separator } from '@/components/ui/separator'
|
||||||
CardContent,
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
CardDescription,
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -24,6 +20,14 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -35,46 +39,62 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Save,
|
Save,
|
||||||
Loader2,
|
Loader2,
|
||||||
FileText,
|
Settings,
|
||||||
Video,
|
|
||||||
Link as LinkIcon,
|
|
||||||
File,
|
|
||||||
Trash2,
|
|
||||||
Eye,
|
Eye,
|
||||||
|
Trash2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Dynamically import BlockEditor to avoid SSR issues
|
// Dynamically import editors to avoid SSR issues
|
||||||
const BlockEditor = dynamic(
|
const BlockEditor = dynamic(
|
||||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
<div className="mx-auto max-w-3xl min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const resourceTypeOptions = [
|
const ResourceRenderer = dynamic(
|
||||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
{
|
||||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
ssr: false,
|
||||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
loading: () => (
|
||||||
{ value: 'OTHER', label: 'Other', icon: File },
|
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const ROLE_OPTIONS = [
|
||||||
|
{ value: 'JURY_MEMBER', label: 'Jury Members' },
|
||||||
|
{ value: 'MENTOR', label: 'Mentors' },
|
||||||
|
{ value: 'OBSERVER', label: 'Observers' },
|
||||||
|
{ value: 'APPLICANT', label: 'Applicants' },
|
||||||
|
{ value: 'AWARD_MASTER', label: 'Award Masters' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const cohortOptions = [
|
type AccessRule =
|
||||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
| { type: 'everyone' }
|
||||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
| { type: 'roles'; roles: string[] }
|
||||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
| { type: 'jury_group'; juryGroupIds: string[] }
|
||||||
]
|
| { type: 'round'; roundIds: string[] }
|
||||||
|
|
||||||
|
function parseAccessJson(accessJson: unknown): { mode: 'everyone' | 'roles'; roles: string[] } {
|
||||||
|
if (!accessJson || !Array.isArray(accessJson) || accessJson.length === 0) {
|
||||||
|
return { mode: 'everyone', roles: [] }
|
||||||
|
}
|
||||||
|
const firstRule = accessJson[0] as AccessRule
|
||||||
|
if (firstRule.type === 'roles' && 'roles' in firstRule) {
|
||||||
|
return { mode: 'roles', roles: firstRule.roles }
|
||||||
|
}
|
||||||
|
return { mode: 'everyone', roles: [] }
|
||||||
|
}
|
||||||
|
|
||||||
export default function EditLearningResourcePage() {
|
export default function EditLearningResourcePage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -89,11 +109,14 @@ export default function EditLearningResourcePage() {
|
|||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [contentJson, setContentJson] = useState<string>('')
|
const [contentJson, setContentJson] = useState<string>('')
|
||||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
|
||||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
|
||||||
const [externalUrl, setExternalUrl] = useState('')
|
const [externalUrl, setExternalUrl] = useState('')
|
||||||
const [isPublished, setIsPublished] = useState(false)
|
const [isPublished, setIsPublished] = useState(false)
|
||||||
const [programId, setProgramId] = useState<string | null>(null)
|
const [programId, setProgramId] = useState<string | null>(null)
|
||||||
|
const [previewing, setPreviewing] = useState(false)
|
||||||
|
|
||||||
|
// Access rules state
|
||||||
|
const [accessMode, setAccessMode] = useState<'everyone' | 'roles'>('everyone')
|
||||||
|
const [selectedRoles, setSelectedRoles] = useState<string[]>([])
|
||||||
|
|
||||||
// API
|
// API
|
||||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||||
@@ -115,11 +138,13 @@ export default function EditLearningResourcePage() {
|
|||||||
setTitle(resource.title)
|
setTitle(resource.title)
|
||||||
setDescription(resource.description || '')
|
setDescription(resource.description || '')
|
||||||
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
|
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
|
||||||
setResourceType(resource.resourceType)
|
|
||||||
setCohortLevel(resource.cohortLevel)
|
|
||||||
setExternalUrl(resource.externalUrl || '')
|
setExternalUrl(resource.externalUrl || '')
|
||||||
setIsPublished(resource.isPublished)
|
setIsPublished(resource.isPublished)
|
||||||
setProgramId(resource.programId)
|
setProgramId(resource.programId)
|
||||||
|
|
||||||
|
const { mode, roles } = parseAccessJson(resource.accessJson)
|
||||||
|
setAccessMode(mode)
|
||||||
|
setSelectedRoles(roles)
|
||||||
}
|
}
|
||||||
}, [resource])
|
}, [resource])
|
||||||
|
|
||||||
@@ -134,74 +159,88 @@ export default function EditLearningResourcePage() {
|
|||||||
await fetch(url, {
|
await fetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: file,
|
body: file,
|
||||||
headers: {
|
headers: { 'Content-Type': file.type },
|
||||||
'Content-Type': file.type,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to upload file')
|
toast.error('Failed to upload file')
|
||||||
throw error
|
throw new Error('Upload failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const buildAccessJson = (): AccessRule[] | null => {
|
||||||
|
if (accessMode === 'everyone') return null
|
||||||
|
if (accessMode === 'roles' && selectedRoles.length > 0) {
|
||||||
|
return [{ type: 'roles', roles: selectedRoles }]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
toast.error('Please enter a title')
|
toast.error('Please enter a title')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceType === 'LINK' && !externalUrl) {
|
|
||||||
toast.error('Please enter an external URL')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateResource.mutateAsync({
|
await updateResource.mutateAsync({
|
||||||
id: resourceId,
|
id: resourceId,
|
||||||
|
programId,
|
||||||
title,
|
title,
|
||||||
description: description || undefined,
|
description: description || null,
|
||||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
accessJson: buildAccessJson(),
|
||||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
|
||||||
externalUrl: externalUrl || null,
|
externalUrl: externalUrl || null,
|
||||||
isPublished,
|
isPublished,
|
||||||
})
|
})
|
||||||
|
|
||||||
toast.success('Resource updated successfully')
|
toast.success('Resource updated')
|
||||||
router.push('/admin/learning')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
|
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
|
||||||
}
|
}
|
||||||
}
|
}, [title, description, contentJson, externalUrl, isPublished, programId, accessMode, selectedRoles, resourceId])
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteResource.mutateAsync({ id: resourceId })
|
await deleteResource.mutateAsync({ id: resourceId })
|
||||||
toast.success('Resource deleted successfully')
|
toast.success('Resource deleted')
|
||||||
router.push('/admin/learning')
|
router.push('/admin/learning')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
|
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+S save
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [handleSubmit])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex min-h-screen flex-col">
|
||||||
<div className="flex items-center gap-4">
|
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2">
|
||||||
<Skeleton className="h-9 w-40" />
|
<Skeleton className="h-8 w-20" />
|
||||||
</div>
|
<div className="flex gap-2">
|
||||||
<Skeleton className="h-8 w-64" />
|
<Skeleton className="h-8 w-20" />
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<Skeleton className="h-8 w-20" />
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<Skeleton className="h-8 w-16" />
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
<Skeleton className="h-96 w-full" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6">
|
</div>
|
||||||
<Skeleton className="h-48 w-full" />
|
<div className="flex-1 px-4 py-8">
|
||||||
<Skeleton className="h-32 w-full" />
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
<Skeleton className="h-10 w-2/3" />
|
||||||
|
<Skeleton className="h-6 w-1/3" />
|
||||||
|
<Skeleton className="h-px w-full" />
|
||||||
|
<Skeleton className="h-96 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,7 +249,7 @@ export default function EditLearningResourcePage() {
|
|||||||
|
|
||||||
if (error || !resource) {
|
if (error || !resource) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 p-6">
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Resource not found</AlertTitle>
|
<AlertTitle>Resource not found</AlertTitle>
|
||||||
@@ -229,253 +268,250 @@ export default function EditLearningResourcePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* Header */}
|
{/* Sticky toolbar */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href="/admin/learning">
|
<Link href="/admin/learning">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Learning Hub
|
Back
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-center gap-2">
|
||||||
<div>
|
<Button
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Edit Resource</h1>
|
variant={previewing ? 'default' : 'outline'}
|
||||||
<p className="text-muted-foreground">
|
size="sm"
|
||||||
Update this learning resource
|
onClick={() => setPreviewing(!previewing)}
|
||||||
</p>
|
>
|
||||||
</div>
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
<AlertDialog>
|
{previewing ? 'Edit' : 'Preview'}
|
||||||
<AlertDialogTrigger asChild>
|
</Button>
|
||||||
<Button variant="outline" className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Are you sure you want to delete "{resource.title}"? This action
|
|
||||||
cannot be undone.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={handleDelete}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
{deleteResource.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : null}
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<Sheet>
|
||||||
{/* Main content */}
|
<SheetTrigger asChild>
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<Button variant="outline" size="sm">
|
||||||
{/* Basic Info */}
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
<Card>
|
Settings
|
||||||
<CardHeader>
|
</Button>
|
||||||
<CardTitle>Resource Details</CardTitle>
|
</SheetTrigger>
|
||||||
<CardDescription>
|
<SheetContent className="overflow-y-auto">
|
||||||
Basic information about this resource
|
<SheetHeader>
|
||||||
</CardDescription>
|
<SheetTitle>Resource Settings</SheetTitle>
|
||||||
</CardHeader>
|
<SheetDescription>
|
||||||
<CardContent className="space-y-4">
|
Configure publishing, access, and metadata
|
||||||
<div className="space-y-2">
|
</SheetDescription>
|
||||||
<Label htmlFor="title">Title *</Label>
|
</SheetHeader>
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="e.g., Ocean Conservation Best Practices"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="mt-6 space-y-6">
|
||||||
<Label htmlFor="description">Short Description</Label>
|
{/* Publish toggle */}
|
||||||
<Textarea
|
<div className="flex items-center justify-between">
|
||||||
id="description"
|
<div>
|
||||||
value={description}
|
<Label>Published</Label>
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
<p className="text-sm text-muted-foreground">
|
||||||
placeholder="Brief description of this resource"
|
Make visible to users
|
||||||
rows={2}
|
</p>
|
||||||
maxLength={500}
|
</div>
|
||||||
/>
|
<Switch
|
||||||
</div>
|
checked={isPublished}
|
||||||
|
onCheckedChange={setIsPublished}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<Separator />
|
||||||
|
|
||||||
|
{/* Program */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type">Resource Type</Label>
|
<Label>Program</Label>
|
||||||
<Select value={resourceType} onValueChange={setResourceType}>
|
<Select
|
||||||
<SelectTrigger id="type">
|
value={programId || 'global'}
|
||||||
<SelectValue />
|
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select program" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{resourceTypeOptions.map((option) => (
|
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
{programs?.map((program) => (
|
||||||
<div className="flex items-center gap-2">
|
<SelectItem key={program.id} value={program.id}>
|
||||||
<option.icon className="h-4 w-4" />
|
{program.year} Edition
|
||||||
{option.label}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Separator />
|
||||||
<Label htmlFor="cohort">Access Level</Label>
|
|
||||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
{/* Access Rules */}
|
||||||
<SelectTrigger id="cohort">
|
<div className="space-y-3">
|
||||||
|
<Label>Access Rules</Label>
|
||||||
|
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
|
||||||
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{cohortOptions.map((option) => (
|
<SelectItem value="everyone">Everyone</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem value="roles">By Role</SelectItem>
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{resourceType === 'LINK' && (
|
{accessMode === 'roles' && (
|
||||||
|
<div className="space-y-2 rounded-lg border p-3">
|
||||||
|
{ROLE_OPTIONS.map((role) => (
|
||||||
|
<label key={role.value} className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRoles.includes(role.value)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setSelectedRoles(
|
||||||
|
checked
|
||||||
|
? [...selectedRoles, role.value]
|
||||||
|
: selectedRoles.filter((r) => r !== role.value)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{role.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* External URL */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="url">External URL *</Label>
|
<Label>External URL</Label>
|
||||||
<Input
|
<Input
|
||||||
id="url"
|
|
||||||
type="url"
|
type="url"
|
||||||
value={externalUrl}
|
value={externalUrl}
|
||||||
onChange={(e) => setExternalUrl(e.target.value)}
|
onChange={(e) => setExternalUrl(e.target.value)}
|
||||||
placeholder="https://example.com/resource"
|
placeholder="https://example.com/resource"
|
||||||
/>
|
/>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">
|
||||||
)}
|
Optional link to an external resource
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Content Editor */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Content</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Rich text content with images and videos. Type / for commands.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<BlockEditor
|
|
||||||
key={resourceId}
|
|
||||||
initialContent={contentJson || undefined}
|
|
||||||
onChange={setContentJson}
|
|
||||||
onUploadFile={handleUploadFile}
|
|
||||||
className="min-h-[300px]"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Publish Settings */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Publish Settings</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="published">Published</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Make this resource visible to jury members
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
|
||||||
id="published"
|
|
||||||
checked={isPublished}
|
|
||||||
onCheckedChange={setIsPublished}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Separator />
|
||||||
<Label htmlFor="program">Program</Label>
|
|
||||||
<Select
|
|
||||||
value={programId || 'global'}
|
|
||||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="program">
|
|
||||||
<SelectValue placeholder="Select program" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
|
||||||
{programs?.map((program) => (
|
|
||||||
<SelectItem key={program.id} value={program.id}>
|
|
||||||
{program.year} Edition
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Statistics */}
|
{/* Statistics */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<Card>
|
<div className="space-y-2">
|
||||||
<CardHeader>
|
<Label>Statistics</Label>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="grid grid-cols-2 gap-4 rounded-lg border p-3">
|
||||||
<Eye className="h-5 w-5" />
|
<div>
|
||||||
Statistics
|
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
||||||
</CardTitle>
|
<p className="text-xs text-muted-foreground">Total views</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
||||||
<div>
|
<p className="text-xs text-muted-foreground">Unique users</p>
|
||||||
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Total views</p>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">Unique users</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-destructive">Danger Zone</Label>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete Resource
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete "{resource.title}"?
|
||||||
|
This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{deleteResource.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={updateResource.isPending || !title.trim()}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{updateResource.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild className="w-full">
|
|
||||||
<Link href="/admin/learning">Cancel</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</SheetContent>
|
||||||
</Card>
|
</Sheet>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={updateResource.isPending || !title.trim()}
|
||||||
|
>
|
||||||
|
{updateResource.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="flex-1 px-4 py-8">
|
||||||
|
{previewing ? (
|
||||||
|
<ResourceRenderer
|
||||||
|
title={title || 'Untitled'}
|
||||||
|
description={description || null}
|
||||||
|
contentJson={contentJson ? JSON.parse(contentJson) : null}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
{/* Inline title */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Untitled"
|
||||||
|
className="w-full border-0 bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 focus:outline-none sm:text-4xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Inline description */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Add a description..."
|
||||||
|
className="w-full border-0 bg-transparent text-lg text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<hr className="border-border" />
|
||||||
|
|
||||||
|
{/* Block editor */}
|
||||||
|
<BlockEditor
|
||||||
|
key={resourceId}
|
||||||
|
initialContent={contentJson || undefined}
|
||||||
|
onChange={setContentJson}
|
||||||
|
onUploadFile={handleUploadFile}
|
||||||
|
className="min-h-[400px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
@@ -8,15 +8,9 @@ import { trpc } from '@/lib/trpc/client'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
Card,
|
import { Separator } from '@/components/ui/separator'
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -24,33 +18,57 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ArrowLeft, Save, Loader2, FileText, Video, Link as LinkIcon, File } from 'lucide-react'
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Save,
|
||||||
|
Loader2,
|
||||||
|
Settings,
|
||||||
|
Eye,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Dynamically import BlockEditor to avoid SSR issues
|
// Dynamically import editors to avoid SSR issues
|
||||||
const BlockEditor = dynamic(
|
const BlockEditor = dynamic(
|
||||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
<div className="mx-auto max-w-3xl min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const resourceTypeOptions = [
|
const ResourceRenderer = dynamic(
|
||||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
{
|
||||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
ssr: false,
|
||||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
loading: () => (
|
||||||
{ value: 'OTHER', label: 'Other', icon: File },
|
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const ROLE_OPTIONS = [
|
||||||
|
{ value: 'JURY_MEMBER', label: 'Jury Members' },
|
||||||
|
{ value: 'MENTOR', label: 'Mentors' },
|
||||||
|
{ value: 'OBSERVER', label: 'Observers' },
|
||||||
|
{ value: 'APPLICANT', label: 'Applicants' },
|
||||||
|
{ value: 'AWARD_MASTER', label: 'Award Masters' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const cohortOptions = [
|
type AccessRule =
|
||||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
| { type: 'everyone' }
|
||||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
| { type: 'roles'; roles: string[] }
|
||||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
| { type: 'jury_group'; juryGroupIds: string[] }
|
||||||
]
|
| { type: 'round'; roundIds: string[] }
|
||||||
|
|
||||||
export default function NewLearningResourcePage() {
|
export default function NewLearningResourcePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -59,14 +77,17 @@ export default function NewLearningResourcePage() {
|
|||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [contentJson, setContentJson] = useState<string>('')
|
const [contentJson, setContentJson] = useState<string>('')
|
||||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
|
||||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
|
||||||
const [externalUrl, setExternalUrl] = useState('')
|
const [externalUrl, setExternalUrl] = useState('')
|
||||||
const [isPublished, setIsPublished] = useState(false)
|
const [isPublished, setIsPublished] = useState(false)
|
||||||
|
const [programId, setProgramId] = useState<string | null>(null)
|
||||||
|
const [previewing, setPreviewing] = useState(false)
|
||||||
|
|
||||||
|
// Access rules state
|
||||||
|
const [accessMode, setAccessMode] = useState<'everyone' | 'roles'>('everyone')
|
||||||
|
const [selectedRoles, setSelectedRoles] = useState<string[]>([])
|
||||||
|
|
||||||
// API
|
// API
|
||||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||||
const [programId, setProgramId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const createResource = trpc.learningResource.create.useMutation({
|
const createResource = trpc.learningResource.create.useMutation({
|
||||||
@@ -82,43 +103,41 @@ export default function NewLearningResourcePage() {
|
|||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Upload to MinIO
|
|
||||||
await fetch(url, {
|
await fetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: file,
|
body: file,
|
||||||
headers: {
|
headers: { 'Content-Type': file.type },
|
||||||
'Content-Type': file.type,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Return the MinIO URL
|
|
||||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to upload file')
|
toast.error('Failed to upload file')
|
||||||
throw error
|
throw new Error('Upload failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const buildAccessJson = (): AccessRule[] | null => {
|
||||||
|
if (accessMode === 'everyone') return null
|
||||||
|
if (accessMode === 'roles' && selectedRoles.length > 0) {
|
||||||
|
return [{ type: 'roles', roles: selectedRoles }]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
toast.error('Please enter a title')
|
toast.error('Please enter a title')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceType === 'LINK' && !externalUrl) {
|
|
||||||
toast.error('Please enter an external URL')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createResource.mutateAsync({
|
await createResource.mutateAsync({
|
||||||
programId,
|
programId,
|
||||||
title,
|
title,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
accessJson: buildAccessJson(),
|
||||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
|
||||||
externalUrl: externalUrl || undefined,
|
externalUrl: externalUrl || undefined,
|
||||||
isPublished,
|
isPublished,
|
||||||
})
|
})
|
||||||
@@ -128,200 +147,205 @@ export default function NewLearningResourcePage() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
|
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
|
||||||
}
|
}
|
||||||
}
|
}, [title, description, contentJson, externalUrl, isPublished, programId, accessMode, selectedRoles])
|
||||||
|
|
||||||
|
// Ctrl+S save
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [handleSubmit])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* Header */}
|
{/* Sticky toolbar */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href="/admin/learning">
|
<Link href="/admin/learning">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Learning Hub
|
Back
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Add Resource</h1>
|
<Button
|
||||||
<p className="text-muted-foreground">
|
variant={previewing ? 'default' : 'outline'}
|
||||||
Create a new learning resource for jury members
|
size="sm"
|
||||||
</p>
|
onClick={() => setPreviewing(!previewing)}
|
||||||
</div>
|
>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
{previewing ? 'Edit' : 'Preview'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<Sheet>
|
||||||
{/* Main content */}
|
<SheetTrigger asChild>
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<Button variant="outline" size="sm">
|
||||||
{/* Basic Info */}
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
<Card>
|
Settings
|
||||||
<CardHeader>
|
</Button>
|
||||||
<CardTitle>Resource Details</CardTitle>
|
</SheetTrigger>
|
||||||
<CardDescription>
|
<SheetContent className="overflow-y-auto">
|
||||||
Basic information about this resource
|
<SheetHeader>
|
||||||
</CardDescription>
|
<SheetTitle>Resource Settings</SheetTitle>
|
||||||
</CardHeader>
|
<SheetDescription>
|
||||||
<CardContent className="space-y-4">
|
Configure publishing, access, and metadata
|
||||||
<div className="space-y-2">
|
</SheetDescription>
|
||||||
<Label htmlFor="title">Title *</Label>
|
</SheetHeader>
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="e.g., Ocean Conservation Best Practices"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="mt-6 space-y-6">
|
||||||
<Label htmlFor="description">Short Description</Label>
|
{/* Publish toggle */}
|
||||||
<Textarea
|
<div className="flex items-center justify-between">
|
||||||
id="description"
|
<div>
|
||||||
value={description}
|
<Label>Published</Label>
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
<p className="text-sm text-muted-foreground">
|
||||||
placeholder="Brief description of this resource"
|
Make visible to users
|
||||||
rows={2}
|
</p>
|
||||||
maxLength={500}
|
</div>
|
||||||
/>
|
<Switch
|
||||||
</div>
|
checked={isPublished}
|
||||||
|
onCheckedChange={setIsPublished}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<Separator />
|
||||||
|
|
||||||
|
{/* Program */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type">Resource Type</Label>
|
<Label>Program</Label>
|
||||||
<Select value={resourceType} onValueChange={setResourceType}>
|
<Select
|
||||||
<SelectTrigger id="type">
|
value={programId || 'global'}
|
||||||
<SelectValue />
|
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select program" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{resourceTypeOptions.map((option) => (
|
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
{programs?.map((program) => (
|
||||||
<div className="flex items-center gap-2">
|
<SelectItem key={program.id} value={program.id}>
|
||||||
<option.icon className="h-4 w-4" />
|
{program.year} Edition
|
||||||
{option.label}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Separator />
|
||||||
<Label htmlFor="cohort">Access Level</Label>
|
|
||||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
{/* Access Rules */}
|
||||||
<SelectTrigger id="cohort">
|
<div className="space-y-3">
|
||||||
|
<Label>Access Rules</Label>
|
||||||
|
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
|
||||||
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{cohortOptions.map((option) => (
|
<SelectItem value="everyone">Everyone</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem value="roles">By Role</SelectItem>
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{resourceType === 'LINK' && (
|
{accessMode === 'roles' && (
|
||||||
|
<div className="space-y-2 rounded-lg border p-3">
|
||||||
|
{ROLE_OPTIONS.map((role) => (
|
||||||
|
<label key={role.value} className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRoles.includes(role.value)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setSelectedRoles(
|
||||||
|
checked
|
||||||
|
? [...selectedRoles, role.value]
|
||||||
|
: selectedRoles.filter((r) => r !== role.value)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{role.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* External URL */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="url">External URL *</Label>
|
<Label>External URL</Label>
|
||||||
<Input
|
<Input
|
||||||
id="url"
|
|
||||||
type="url"
|
type="url"
|
||||||
value={externalUrl}
|
value={externalUrl}
|
||||||
onChange={(e) => setExternalUrl(e.target.value)}
|
onChange={(e) => setExternalUrl(e.target.value)}
|
||||||
placeholder="https://example.com/resource"
|
placeholder="https://example.com/resource"
|
||||||
/>
|
/>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">
|
||||||
)}
|
Optional link to an external resource
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Content Editor */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Content</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Rich text content with images and videos. Type / for commands.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<BlockEditor
|
|
||||||
initialContent={contentJson || undefined}
|
|
||||||
onChange={setContentJson}
|
|
||||||
onUploadFile={handleUploadFile}
|
|
||||||
className="min-h-[300px]"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Publish Settings */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Publish Settings</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="published">Published</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Make this resource visible to jury members
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
|
||||||
id="published"
|
|
||||||
checked={isPublished}
|
|
||||||
onCheckedChange={setIsPublished}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Button
|
||||||
<Label htmlFor="program">Program</Label>
|
size="sm"
|
||||||
<Select
|
onClick={handleSubmit}
|
||||||
value={programId || 'global'}
|
disabled={createResource.isPending || !title.trim()}
|
||||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
>
|
||||||
>
|
{createResource.isPending ? (
|
||||||
<SelectTrigger id="program">
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<SelectValue placeholder="Select program" />
|
) : (
|
||||||
</SelectTrigger>
|
<Save className="mr-2 h-4 w-4" />
|
||||||
<SelectContent>
|
)}
|
||||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
Save
|
||||||
{programs?.map((program) => (
|
</Button>
|
||||||
<SelectItem key={program.id} value={program.id}>
|
|
||||||
{program.year} Edition
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={createResource.isPending || !title.trim()}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{createResource.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Create Resource
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild className="w-full">
|
|
||||||
<Link href="/admin/learning">Cancel</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="flex-1 px-4 py-8">
|
||||||
|
{previewing ? (
|
||||||
|
<ResourceRenderer
|
||||||
|
title={title || 'Untitled'}
|
||||||
|
description={description || null}
|
||||||
|
contentJson={contentJson ? JSON.parse(contentJson) : null}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
{/* Inline title */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Untitled"
|
||||||
|
autoFocus
|
||||||
|
className="w-full border-0 bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 focus:outline-none sm:text-4xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Inline description */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Add a description..."
|
||||||
|
className="w-full border-0 bg-transparent text-lg text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<hr className="border-border" />
|
||||||
|
|
||||||
|
{/* Block editor */}
|
||||||
|
<BlockEditor
|
||||||
|
onChange={setContentJson}
|
||||||
|
onUploadFile={handleUploadFile}
|
||||||
|
className="min-h-[400px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -22,48 +23,212 @@ import {
|
|||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
FileText,
|
FileText,
|
||||||
Video,
|
|
||||||
Link as LinkIcon,
|
|
||||||
File,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Search,
|
Search,
|
||||||
|
GripVertical,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
const resourceTypeIcons = {
|
type Resource = {
|
||||||
PDF: FileText,
|
id: string
|
||||||
VIDEO: Video,
|
title: string
|
||||||
DOCUMENT: File,
|
description: string | null
|
||||||
LINK: LinkIcon,
|
isPublished: boolean
|
||||||
OTHER: File,
|
sortOrder: number
|
||||||
|
externalUrl: string | null
|
||||||
|
objectKey: string | null
|
||||||
|
contentJson: unknown
|
||||||
|
accessJson: unknown
|
||||||
|
_count: { accessLogs: number }
|
||||||
|
program: { id: string; name: string; year: number } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const cohortColors: Record<string, string> = {
|
function getAccessSummary(accessJson: unknown): string {
|
||||||
ALL: 'bg-gray-100 text-gray-800',
|
if (!accessJson || !Array.isArray(accessJson) || accessJson.length === 0) {
|
||||||
SEMIFINALIST: 'bg-blue-100 text-blue-800',
|
return 'Everyone'
|
||||||
FINALIST: 'bg-purple-100 text-purple-800',
|
}
|
||||||
|
const rule = accessJson[0] as { type: string; roles?: string[] }
|
||||||
|
if (rule.type === 'everyone') return 'Everyone'
|
||||||
|
if (rule.type === 'roles' && rule.roles) {
|
||||||
|
if (rule.roles.length === 1) return rule.roles[0].replace('_', ' ').toLowerCase()
|
||||||
|
return `${rule.roles.length} roles`
|
||||||
|
}
|
||||||
|
if (rule.type === 'jury_group') return 'Jury groups'
|
||||||
|
if (rule.type === 'round') return 'By round'
|
||||||
|
return 'Custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableResourceCard({
|
||||||
|
resource,
|
||||||
|
onTogglePublished,
|
||||||
|
}: {
|
||||||
|
resource: Resource
|
||||||
|
onTogglePublished: (id: string, published: boolean) => void
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: resource.id })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={isDragging ? 'opacity-50 shadow-lg' : ''}
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-center gap-3 py-3">
|
||||||
|
{/* Drag handle */}
|
||||||
|
<button
|
||||||
|
className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Drag to reorder"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title & meta */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium truncate">{resource.title}</h3>
|
||||||
|
{!resource.isPublished && (
|
||||||
|
<Badge variant="secondary" className="text-xs">Draft</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||||
|
<span className="capitalize">{getAccessSummary(resource.accessJson)}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{resource._count.accessLogs} views</span>
|
||||||
|
{resource.program && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{resource.program.year}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick publish toggle */}
|
||||||
|
<Switch
|
||||||
|
checked={resource.isPublished}
|
||||||
|
onCheckedChange={(checked) => onTogglePublished(resource.id, checked)}
|
||||||
|
aria-label={resource.isPublished ? 'Unpublish' : 'Publish'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{resource.externalUrl && (
|
||||||
|
<a
|
||||||
|
href={resource.externalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<Link href={`/admin/learning/${resource.id}`}>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LearningHubPage() {
|
export default function LearningHubPage() {
|
||||||
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 50 })
|
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 100 })
|
||||||
const resources = data?.data
|
const resources = (data?.data || []) as Resource[]
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const debouncedSearch = useDebounce(search, 300)
|
const debouncedSearch = useDebounce(search, 300)
|
||||||
const [typeFilter, setTypeFilter] = useState('all')
|
const [publishedFilter, setPublishedFilter] = useState('all')
|
||||||
const [cohortFilter, setCohortFilter] = useState('all')
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const reorderMutation = trpc.learningResource.reorder.useMutation({
|
||||||
|
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||||
|
})
|
||||||
|
const updateMutation = trpc.learningResource.update.useMutation({
|
||||||
|
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const filteredResources = useMemo(() => {
|
const filteredResources = useMemo(() => {
|
||||||
if (!resources) return []
|
|
||||||
return resources.filter((resource) => {
|
return resources.filter((resource) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
!debouncedSearch ||
|
!debouncedSearch ||
|
||||||
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
|
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||||
const matchesType = typeFilter === 'all' || resource.resourceType === typeFilter
|
const matchesPublished =
|
||||||
const matchesCohort = cohortFilter === 'all' || resource.cohortLevel === cohortFilter
|
publishedFilter === 'all' ||
|
||||||
return matchesSearch && matchesType && matchesCohort
|
(publishedFilter === 'published' && resource.isPublished) ||
|
||||||
|
(publishedFilter === 'draft' && !resource.isPublished)
|
||||||
|
return matchesSearch && matchesPublished
|
||||||
})
|
})
|
||||||
}, [resources, debouncedSearch, typeFilter, cohortFilter])
|
}, [resources, debouncedSearch, publishedFilter])
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id) return
|
||||||
|
|
||||||
|
const oldIndex = filteredResources.findIndex((r) => r.id === active.id)
|
||||||
|
const newIndex = filteredResources.findIndex((r) => r.id === over.id)
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return
|
||||||
|
|
||||||
|
const reordered = arrayMove(filteredResources, oldIndex, newIndex)
|
||||||
|
const items = reordered.map((r, i) => ({ id: r.id, sortOrder: i }))
|
||||||
|
|
||||||
|
reorderMutation.mutate({ items }, {
|
||||||
|
onError: () => toast.error('Failed to reorder'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTogglePublished = (id: string, published: boolean) => {
|
||||||
|
updateMutation.mutate({ id, isPublished: published }, {
|
||||||
|
onSuccess: () => toast.success(published ? 'Published' : 'Unpublished'),
|
||||||
|
onError: () => toast.error('Failed to update'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -75,25 +240,20 @@ export default function LearningHubPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-9 w-32" />
|
<Skeleton className="h-9 w-32" />
|
||||||
</div>
|
</div>
|
||||||
{/* Toolbar skeleton */}
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<Skeleton className="h-10 flex-1" />
|
<Skeleton className="h-10 flex-1" />
|
||||||
<div className="flex items-center gap-2">
|
<Skeleton className="h-10 w-[160px]" />
|
||||||
<Skeleton className="h-10 w-[160px]" />
|
|
||||||
<Skeleton className="h-10 w-[160px]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/* Resource list skeleton */}
|
<div className="grid gap-3">
|
||||||
<div className="grid gap-4">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<Card key={i}>
|
<Card key={i}>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
<Skeleton className="h-4 w-4" />
|
||||||
|
<Skeleton className="h-9 w-9 rounded-lg" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<Skeleton className="h-5 w-48" />
|
<Skeleton className="h-5 w-48" />
|
||||||
<Skeleton className="h-4 w-32" />
|
<Skeleton className="h-3 w-32" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-8 w-8 rounded" />
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -109,7 +269,7 @@ export default function LearningHubPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Learning Hub</h1>
|
<h1 className="text-2xl font-bold">Learning Hub</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Manage educational resources for jury members
|
Manage educational resources for program participants
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/learning/new">
|
<Link href="/admin/learning/new">
|
||||||
@@ -131,92 +291,49 @@ export default function LearningHubPage() {
|
|||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<Select value={publishedFilter} onValueChange={setPublishedFilter}>
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
<SelectTrigger className="w-[160px]">
|
||||||
<SelectTrigger className="w-[160px]">
|
<SelectValue placeholder="All" />
|
||||||
<SelectValue placeholder="All types" />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
<SelectContent>
|
<SelectItem value="all">All</SelectItem>
|
||||||
<SelectItem value="all">All types</SelectItem>
|
<SelectItem value="published">Published</SelectItem>
|
||||||
<SelectItem value="PDF">PDF</SelectItem>
|
<SelectItem value="draft">Drafts</SelectItem>
|
||||||
<SelectItem value="VIDEO">Video</SelectItem>
|
</SelectContent>
|
||||||
<SelectItem value="DOCUMENT">Document</SelectItem>
|
</Select>
|
||||||
<SelectItem value="LINK">Link</SelectItem>
|
|
||||||
<SelectItem value="OTHER">Other</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select value={cohortFilter} onValueChange={setCohortFilter}>
|
|
||||||
<SelectTrigger className="w-[160px]">
|
|
||||||
<SelectValue placeholder="All cohorts" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All cohorts</SelectItem>
|
|
||||||
<SelectItem value="ALL">All (cohort)</SelectItem>
|
|
||||||
<SelectItem value="SEMIFINALIST">Semifinalist</SelectItem>
|
|
||||||
<SelectItem value="FINALIST">Finalist</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
{resources && (
|
{resources.length > 0 && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{filteredResources.length} of {resources.length} resources
|
{filteredResources.length} of {resources.length} resources
|
||||||
|
{reorderMutation.isPending && ' · Saving order...'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Resource List */}
|
{/* Resource List with DnD */}
|
||||||
{filteredResources.length > 0 ? (
|
{filteredResources.length > 0 ? (
|
||||||
<div className="grid gap-4">
|
<DndContext
|
||||||
{filteredResources.map((resource) => {
|
sensors={sensors}
|
||||||
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
|
collisionDetection={closestCenter}
|
||||||
return (
|
onDragEnd={handleDragEnd}
|
||||||
<Card key={resource.id}>
|
>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<SortableContext
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
items={filteredResources.map((r) => r.id)}
|
||||||
<Icon className="h-5 w-5" />
|
strategy={verticalListSortingStrategy}
|
||||||
</div>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="grid gap-3">
|
||||||
<div className="flex items-center gap-2">
|
{filteredResources.map((resource) => (
|
||||||
<h3 className="font-medium truncate">{resource.title}</h3>
|
<SortableResourceCard
|
||||||
{!resource.isPublished && (
|
key={resource.id}
|
||||||
<Badge variant="secondary">Draft</Badge>
|
resource={resource}
|
||||||
)}
|
onTogglePublished={handleTogglePublished}
|
||||||
</div>
|
/>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
))}
|
||||||
<Badge className={cohortColors[resource.cohortLevel] || ''} variant="outline">
|
</div>
|
||||||
{resource.cohortLevel}
|
</SortableContext>
|
||||||
</Badge>
|
</DndContext>
|
||||||
<span>{resource.resourceType}</span>
|
) : resources.length > 0 ? (
|
||||||
<span>-</span>
|
|
||||||
<span>{resource._count.accessLogs} views</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{resource.externalUrl && (
|
|
||||||
<a
|
|
||||||
href={resource.externalUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<Link href={`/admin/learning/${resource.id}`}>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : resources && resources.length > 0 ? (
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||||
|
|||||||
10
src/app/(admin)/admin/mentors/[id]/page.tsx
Normal file
10
src/app/(admin)/admin/mentors/[id]/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default async function MentorDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
redirect(`/admin/members/${id}`)
|
||||||
|
}
|
||||||
5
src/app/(admin)/admin/mentors/page.tsx
Normal file
5
src/app/(admin)/admin/mentors/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function MentorsPage() {
|
||||||
|
redirect('/admin/members')
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
|||||||
|
|
||||||
if (!editionId) {
|
if (!editionId) {
|
||||||
const defaultEdition = await prisma.program.findFirst({
|
const defaultEdition = await prisma.program.findFirst({
|
||||||
where: { status: 'ACTIVE', isTest: false },
|
where: { status: 'ACTIVE' },
|
||||||
orderBy: { year: 'desc' },
|
orderBy: { year: 'desc' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
@@ -38,7 +38,6 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
|||||||
|
|
||||||
if (!editionId) {
|
if (!editionId) {
|
||||||
const anyEdition = await prisma.program.findFirst({
|
const anyEdition = await prisma.program.findFirst({
|
||||||
where: { isTest: false },
|
|
||||||
orderBy: { year: 'desc' },
|
orderBy: { year: 'desc' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { ArrowLeft, GraduationCap, Pencil, Plus } from 'lucide-react'
|
import { ArrowLeft, Pencil, Plus } from 'lucide-react'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
|
import { CompetitionSettings } from '@/components/admin/program/competition-settings'
|
||||||
|
|
||||||
interface ProgramDetailPageProps {
|
interface ProgramDetailPageProps {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
@@ -65,20 +66,12 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<Button variant="outline" asChild>
|
||||||
<Button variant="outline" asChild>
|
<Link href={`/admin/programs/${id}/edit`}>
|
||||||
<Link href={`/admin/programs/${id}/mentorship` as Route}>
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
<GraduationCap className="mr-2 h-4 w-4" />
|
Edit
|
||||||
Mentorship
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link href={`/admin/programs/${id}/edit`}>
|
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{program.description && (
|
{program.description && (
|
||||||
@@ -92,6 +85,24 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const comp = (program as any).competitions?.[0]
|
||||||
|
if (!comp) return null
|
||||||
|
return (
|
||||||
|
<CompetitionSettings
|
||||||
|
competitionId={comp.id}
|
||||||
|
initialSettings={{
|
||||||
|
categoryMode: comp.categoryMode ?? 'SHARED',
|
||||||
|
startupFinalistCount: comp.startupFinalistCount ?? 3,
|
||||||
|
conceptFinalistCount: comp.conceptFinalistCount ?? 3,
|
||||||
|
notifyOnRoundAdvance: comp.notifyOnRoundAdvance ?? true,
|
||||||
|
notifyOnDeadlineApproach: comp.notifyOnDeadlineApproach ?? true,
|
||||||
|
deadlineReminderDays: comp.deadlineReminderDays ?? [7, 3, 1],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -101,9 +112,9 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href={`/admin/competitions?programId=${id}` as Route}>
|
<Link href={'/admin/rounds' as Route}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Manage Competitions
|
Manage Rounds
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import { formatDateOnly } from '@/lib/utils'
|
|||||||
|
|
||||||
async function ProgramsContent() {
|
async function ProgramsContent() {
|
||||||
const programs = await prisma.program.findMany({
|
const programs = await prisma.program.findMany({
|
||||||
where: { isTest: false },
|
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
|
||||||
include: {
|
include: {
|
||||||
competitions: {
|
competitions: {
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -637,7 +637,7 @@ export default function ProjectsPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link href="/admin/projects/pool">
|
<Link href="/admin/projects?hasAssign=false">
|
||||||
<Layers className="mr-2 h-4 w-4" />
|
<Layers className="mr-2 h-4 w-4" />
|
||||||
Assign to Round
|
Assign to Round
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,558 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react'
|
|
||||||
import { useSearchParams } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { useEdition } from '@/contexts/edition-context'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
DialogDescription,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { ArrowLeft, ChevronLeft, ChevronRight, Loader2, X, Layers, Info } from 'lucide-react'
|
|
||||||
|
|
||||||
const roundTypeColors: Record<string, string> = {
|
|
||||||
INTAKE: 'bg-gray-100 text-gray-700',
|
|
||||||
FILTERING: 'bg-amber-100 text-amber-700',
|
|
||||||
EVALUATION: 'bg-blue-100 text-blue-700',
|
|
||||||
SUBMISSION: 'bg-purple-100 text-purple-700',
|
|
||||||
MENTORING: 'bg-teal-100 text-teal-700',
|
|
||||||
LIVE_FINAL: 'bg-red-100 text-red-700',
|
|
||||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectPoolPage() {
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const { currentEdition, isLoading: editionLoading } = useEdition()
|
|
||||||
|
|
||||||
// URL params for deep-linking context
|
|
||||||
const urlRoundId = searchParams.get('roundId') || ''
|
|
||||||
const urlCompetitionId = searchParams.get('competitionId') || ''
|
|
||||||
|
|
||||||
// Auto-select programId from edition
|
|
||||||
const programId = currentEdition?.id || ''
|
|
||||||
|
|
||||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
|
|
||||||
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
|
|
||||||
const [assignAllDialogOpen, setAssignAllDialogOpen] = useState(false)
|
|
||||||
const [targetRoundId, setTargetRoundId] = useState<string>(urlRoundId)
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
|
||||||
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
|
|
||||||
const [showUnassignedOnly, setShowUnassignedOnly] = useState(false)
|
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
|
||||||
const perPage = 50
|
|
||||||
|
|
||||||
// Pre-select target round from URL param
|
|
||||||
useEffect(() => {
|
|
||||||
if (urlRoundId) setTargetRoundId(urlRoundId)
|
|
||||||
}, [urlRoundId])
|
|
||||||
|
|
||||||
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
|
|
||||||
{
|
|
||||||
programId,
|
|
||||||
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
|
|
||||||
search: searchQuery || undefined,
|
|
||||||
unassignedOnly: showUnassignedOnly,
|
|
||||||
excludeRoundId: urlRoundId || undefined,
|
|
||||||
page: currentPage,
|
|
||||||
perPage,
|
|
||||||
},
|
|
||||||
{ enabled: !!programId }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Load rounds from program (flattened from all competitions, now with competitionId)
|
|
||||||
const { data: programData, isLoading: isLoadingRounds } = trpc.program.get.useQuery(
|
|
||||||
{ id: programId },
|
|
||||||
{ enabled: !!programId }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Get round name for context banner
|
|
||||||
const allRounds = useMemo(() => {
|
|
||||||
return (programData?.rounds || []) as Array<{
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
competitionId: string
|
|
||||||
status: string
|
|
||||||
_count: { projects: number; assignments: number }
|
|
||||||
}>
|
|
||||||
}, [programData])
|
|
||||||
|
|
||||||
// Filter rounds by competitionId if URL param is set
|
|
||||||
const filteredRounds = useMemo(() => {
|
|
||||||
if (urlCompetitionId) {
|
|
||||||
return allRounds.filter((r) => r.competitionId === urlCompetitionId)
|
|
||||||
}
|
|
||||||
return allRounds
|
|
||||||
}, [allRounds, urlCompetitionId])
|
|
||||||
|
|
||||||
const contextRound = urlRoundId ? allRounds.find((r) => r.id === urlRoundId) : null
|
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
|
||||||
|
|
||||||
const assignMutation = trpc.projectPool.assignToRound.useMutation({
|
|
||||||
onSuccess: (result) => {
|
|
||||||
utils.project.list.invalidate()
|
|
||||||
utils.projectPool.listUnassigned.invalidate()
|
|
||||||
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
|
|
||||||
setSelectedProjects([])
|
|
||||||
setAssignDialogOpen(false)
|
|
||||||
setTargetRoundId(urlRoundId)
|
|
||||||
refetch()
|
|
||||||
},
|
|
||||||
onError: (error: unknown) => {
|
|
||||||
toast.error((error as { message?: string }).message || 'Failed to assign projects')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const assignAllMutation = trpc.projectPool.assignAllToRound.useMutation({
|
|
||||||
onSuccess: (result) => {
|
|
||||||
utils.project.list.invalidate()
|
|
||||||
utils.projectPool.listUnassigned.invalidate()
|
|
||||||
toast.success(`Assigned all ${result.assignedCount} projects to round`)
|
|
||||||
setSelectedProjects([])
|
|
||||||
setAssignAllDialogOpen(false)
|
|
||||||
setTargetRoundId(urlRoundId)
|
|
||||||
refetch()
|
|
||||||
},
|
|
||||||
onError: (error: unknown) => {
|
|
||||||
toast.error((error as { message?: string }).message || 'Failed to assign projects')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const isPending = assignMutation.isPending || assignAllMutation.isPending
|
|
||||||
|
|
||||||
const handleBulkAssign = () => {
|
|
||||||
if (selectedProjects.length === 0 || !targetRoundId) return
|
|
||||||
assignMutation.mutate({
|
|
||||||
projectIds: selectedProjects,
|
|
||||||
roundId: targetRoundId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAssignAll = () => {
|
|
||||||
if (!targetRoundId || !programId) return
|
|
||||||
assignAllMutation.mutate({
|
|
||||||
programId,
|
|
||||||
roundId: targetRoundId,
|
|
||||||
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
|
|
||||||
unassignedOnly: showUnassignedOnly,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleQuickAssign = (projectId: string, roundId: string) => {
|
|
||||||
assignMutation.mutate({
|
|
||||||
projectIds: [projectId],
|
|
||||||
roundId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleSelectAll = () => {
|
|
||||||
if (!poolData?.projects) return
|
|
||||||
if (selectedProjects.length === poolData.projects.length) {
|
|
||||||
setSelectedProjects([])
|
|
||||||
} else {
|
|
||||||
setSelectedProjects(poolData.projects.map((p) => p.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleSelectProject = (projectId: string) => {
|
|
||||||
if (selectedProjects.includes(projectId)) {
|
|
||||||
setSelectedProjects(selectedProjects.filter((id) => id !== projectId))
|
|
||||||
} else {
|
|
||||||
setSelectedProjects([...selectedProjects, projectId])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editionLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Skeleton className="h-10 w-64" />
|
|
||||||
<Skeleton className="h-20 w-full" />
|
|
||||||
<Skeleton className="h-96 w-full" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Link href={"/admin/projects" as Route} className="mt-1 shrink-0">
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h1 className="text-2xl font-semibold">Project Pool</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{currentEdition
|
|
||||||
? `${currentEdition.name} ${currentEdition.year} \u2014 ${poolData?.total ?? '...'} projects`
|
|
||||||
: 'No edition selected'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Context banner when coming from a round */}
|
|
||||||
{contextRound && (
|
|
||||||
<Card className="border-blue-200 bg-blue-50/50">
|
|
||||||
<CardContent className="py-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Info className="h-4 w-4 text-blue-600 shrink-0" />
|
|
||||||
<p className="text-sm">
|
|
||||||
Assigning to <span className="font-semibold">{contextRound.name}</span>
|
|
||||||
{' \u2014 '}
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
projects already in this round are hidden
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={`/admin/rounds/${urlRoundId}` as Route}
|
|
||||||
>
|
|
||||||
<Button variant="outline" size="sm" className="shrink-0">
|
|
||||||
<ArrowLeft className="h-3.5 w-3.5 mr-1" />
|
|
||||||
Back to Round
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-end">
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<label className="text-sm font-medium">Category</label>
|
|
||||||
<Select value={categoryFilter} onValueChange={(value: string) => {
|
|
||||||
setCategoryFilter(value as 'STARTUP' | 'BUSINESS_CONCEPT' | 'all')
|
|
||||||
setCurrentPage(1)
|
|
||||||
}}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Categories</SelectItem>
|
|
||||||
<SelectItem value="STARTUP">Startup</SelectItem>
|
|
||||||
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<label className="text-sm font-medium">Search</label>
|
|
||||||
<Input
|
|
||||||
placeholder="Project or team name..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearchQuery(e.target.value)
|
|
||||||
setCurrentPage(1)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pb-0.5">
|
|
||||||
<Switch
|
|
||||||
id="unassigned-only"
|
|
||||||
checked={showUnassignedOnly}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
setShowUnassignedOnly(checked)
|
|
||||||
setCurrentPage(1)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label htmlFor="unassigned-only" className="text-sm font-medium cursor-pointer whitespace-nowrap">
|
|
||||||
Unassigned only
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Action bar */}
|
|
||||||
{programId && poolData && poolData.total > 0 && (
|
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
<span className="font-medium text-foreground">{poolData.total}</span> project{poolData.total !== 1 ? 's' : ''}
|
|
||||||
{showUnassignedOnly && ' (unassigned only)'}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{selectedProjects.length > 0 && (
|
|
||||||
<Button onClick={() => setAssignDialogOpen(true)} size="sm">
|
|
||||||
Assign {selectedProjects.length} Selected
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setAssignAllDialogOpen(true)}
|
|
||||||
>
|
|
||||||
Assign All {poolData.total} to Round
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Projects Table */}
|
|
||||||
{programId ? (
|
|
||||||
<>
|
|
||||||
{isLoadingPool ? (
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-16 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
) : poolData && poolData.total > 0 ? (
|
|
||||||
<>
|
|
||||||
<Card>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="border-b">
|
|
||||||
<tr className="text-sm">
|
|
||||||
<th className="p-3 text-left w-[40px]">
|
|
||||||
<Checkbox
|
|
||||||
checked={poolData.projects.length > 0 && selectedProjects.length === poolData.projects.length}
|
|
||||||
onCheckedChange={toggleSelectAll}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th className="p-3 text-left font-medium">Project</th>
|
|
||||||
<th className="p-3 text-left font-medium">Category</th>
|
|
||||||
<th className="p-3 text-left font-medium">Rounds</th>
|
|
||||||
<th className="p-3 text-left font-medium">Country</th>
|
|
||||||
<th className="p-3 text-left font-medium">Submitted</th>
|
|
||||||
<th className="p-3 text-left font-medium">Quick Assign</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{poolData.projects.map((project) => (
|
|
||||||
<tr key={project.id} className="border-b hover:bg-muted/50">
|
|
||||||
<td className="p-3">
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedProjects.includes(project.id)}
|
|
||||||
onCheckedChange={() => toggleSelectProject(project.id)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
<Link
|
|
||||||
href={`/admin/projects/${project.id}` as Route}
|
|
||||||
className="hover:underline"
|
|
||||||
>
|
|
||||||
<div className="font-medium">{project.title}</div>
|
|
||||||
<div className="text-sm text-muted-foreground">{project.teamName}</div>
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
{project.competitionCategory && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
{(project as any).projectRoundStates?.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{(project as any).projectRoundStates.map((prs: any) => (
|
|
||||||
<Badge
|
|
||||||
key={prs.roundId}
|
|
||||||
variant="secondary"
|
|
||||||
className={`text-[10px] ${roundTypeColors[prs.round?.roundType] || 'bg-gray-100 text-gray-700'}`}
|
|
||||||
>
|
|
||||||
{prs.round?.name || 'Round'}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">None</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-sm text-muted-foreground">
|
|
||||||
{project.country ? (() => {
|
|
||||||
const code = normalizeCountryToCode(project.country)
|
|
||||||
const flag = code ? getCountryFlag(code) : null
|
|
||||||
const name = code ? getCountryName(code) : project.country
|
|
||||||
return <>{flag && <span>{flag} </span>}{name}</>
|
|
||||||
})() : '-'}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-sm text-muted-foreground">
|
|
||||||
{project.submittedAt
|
|
||||||
? new Date(project.submittedAt).toLocaleDateString()
|
|
||||||
: '-'}
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
{isLoadingRounds ? (
|
|
||||||
<Skeleton className="h-9 w-[200px]" />
|
|
||||||
) : (
|
|
||||||
<Select
|
|
||||||
onValueChange={(roundId) => handleQuickAssign(project.id, roundId)}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[200px]">
|
|
||||||
<SelectValue placeholder="Assign to round..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filteredRounds.map((round) => (
|
|
||||||
<SelectItem key={round.id} value={round.id}>
|
|
||||||
{round.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{poolData.totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Showing {((currentPage - 1) * perPage) + 1} to {Math.min(currentPage * perPage, poolData.total)} of {poolData.total}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage(currentPage - 1)}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage(currentPage + 1)}
|
|
||||||
disabled={currentPage === poolData.totalPages}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Card className="p-8 text-center text-muted-foreground">
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<Layers className="h-8 w-8 text-muted-foreground/50" />
|
|
||||||
<p>
|
|
||||||
{showUnassignedOnly
|
|
||||||
? 'No unassigned projects found'
|
|
||||||
: urlRoundId
|
|
||||||
? 'All projects are already assigned to this round'
|
|
||||||
: 'No projects found for this program'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Card className="p-8 text-center text-muted-foreground">
|
|
||||||
No edition selected. Please select an edition from the sidebar.
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bulk Assignment Dialog (selected projects) */}
|
|
||||||
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Assign Selected Projects</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Assign {selectedProjects.length} selected project{selectedProjects.length > 1 ? 's' : ''} to a round:
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select round..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filteredRounds.map((round) => (
|
|
||||||
<SelectItem key={round.id} value={round.id}>
|
|
||||||
{round.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setAssignDialogOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleBulkAssign}
|
|
||||||
disabled={!targetRoundId || isPending}
|
|
||||||
>
|
|
||||||
{assignMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
Assign {selectedProjects.length} Projects
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Assign ALL Dialog */}
|
|
||||||
<Dialog open={assignAllDialogOpen} onOpenChange={setAssignAllDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Assign All Projects</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
This will assign all {poolData?.total || 0}{categoryFilter !== 'all' ? ` ${categoryFilter === 'STARTUP' ? 'Startup' : 'Business Concept'}` : ''}{showUnassignedOnly ? ' unassigned' : ''} projects to a round in one operation.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select round..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filteredRounds.map((round) => (
|
|
||||||
<SelectItem key={round.id} value={round.id}>
|
|
||||||
{round.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setAssignAllDialogOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleAssignAll}
|
|
||||||
disabled={!targetRoundId || isPending}
|
|
||||||
>
|
|
||||||
{assignAllMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
Assign All {poolData?.total || 0} Projects
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -42,6 +42,9 @@ import {
|
|||||||
UserCheck,
|
UserCheck,
|
||||||
Globe,
|
Globe,
|
||||||
Layers,
|
Layers,
|
||||||
|
Trophy,
|
||||||
|
ArrowRight,
|
||||||
|
Hash,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
@@ -224,25 +227,23 @@ function ReportsOverview() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Project Reports (default: all projects, filterable by round) */}
|
{/* Project Reports Summary */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
<div>
|
||||||
<ClipboardList className="h-4 w-4 text-emerald-600" />
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
||||||
|
<ClipboardList className="h-4 w-4 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
Project Reports
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Summary dashboard — optionally filter to a specific round
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
Project Reports
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Project-wide reporting across all projects — optionally filter to a specific round
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* Scope selector */}
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
|
||||||
<span className="text-sm font-medium">Scope:</span>
|
|
||||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||||
<SelectTrigger className="w-full sm:w-[360px]">
|
<SelectTrigger className="w-[280px]">
|
||||||
<SelectValue placeholder="All projects" />
|
<SelectValue placeholder="All projects" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -259,44 +260,123 @@ function ReportsOverview() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
{projectsLoading ? (
|
{projectsLoading ? (
|
||||||
<Skeleton className="h-[400px]" />
|
<Skeleton className="h-[300px]" />
|
||||||
) : projectRankings?.length ? (
|
) : projectRankings?.length ? (
|
||||||
<div className="rounded-lg border">
|
<>
|
||||||
<Table>
|
{/* Summary stats row */}
|
||||||
<TableHeader>
|
{(() => {
|
||||||
<TableRow>
|
const evaluated = projectRankings.filter(p => p.averageScore !== null)
|
||||||
<TableHead>Project</TableHead>
|
const scores = evaluated.map(p => p.averageScore as number)
|
||||||
<TableHead className="hidden sm:table-cell">Team</TableHead>
|
const avgScore = scores.length ? scores.reduce((a, b) => a + b, 0) / scores.length : 0
|
||||||
<TableHead className="text-right">Avg</TableHead>
|
const minScore = scores.length ? Math.min(...scores) : 0
|
||||||
<TableHead className="text-right">Evals</TableHead>
|
const maxScore = scores.length ? Math.max(...scores) : 0
|
||||||
<TableHead>Status</TableHead>
|
const evalPercent = projectRankings.length ? Math.round((evaluated.length / projectRankings.length) * 100) : 0
|
||||||
</TableRow>
|
const statusCounts = projectRankings.reduce((acc, p) => {
|
||||||
</TableHeader>
|
acc[p.status] = (acc[p.status] || 0) + 1
|
||||||
<TableBody>
|
return acc
|
||||||
{projectRankings.map((p) => (
|
}, {} as Record<string, number>)
|
||||||
<TableRow key={p.id}>
|
|
||||||
<TableCell className="font-medium">
|
return (
|
||||||
<Link href={`/admin/projects/${p.id}`} className="hover:underline">
|
<>
|
||||||
{p.title}
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
</Link>
|
<div className="rounded-lg border p-3 text-center">
|
||||||
</TableCell>
|
<p className="text-xs text-muted-foreground">Total Projects</p>
|
||||||
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
<p className="text-xl font-bold tabular-nums">{projectRankings.length}</p>
|
||||||
{p.teamName || '-'}
|
</div>
|
||||||
</TableCell>
|
<div className="rounded-lg border p-3 text-center">
|
||||||
<TableCell className="text-right tabular-nums">
|
<p className="text-xs text-muted-foreground">Avg Score</p>
|
||||||
{p.averageScore === null ? '-' : p.averageScore.toFixed(2)}
|
<p className="text-xl font-bold tabular-nums">{avgScore ? avgScore.toFixed(1) : '-'}</p>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell className="text-right tabular-nums">{p.evaluationCount}</TableCell>
|
<div className="rounded-lg border p-3 text-center">
|
||||||
<TableCell>
|
<p className="text-xs text-muted-foreground">Evaluated</p>
|
||||||
<Badge variant="outline">{p.status}</Badge>
|
<p className="text-xl font-bold tabular-nums">{evalPercent}%</p>
|
||||||
</TableCell>
|
</div>
|
||||||
</TableRow>
|
<div className="rounded-lg border p-3 text-center">
|
||||||
))}
|
<p className="text-xs text-muted-foreground">Score Range</p>
|
||||||
</TableBody>
|
<p className="text-xl font-bold tabular-nums">
|
||||||
</Table>
|
{scores.length ? `${minScore.toFixed(1)}–${maxScore.toFixed(1)}` : '-'}
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status breakdown chips */}
|
||||||
|
{Object.keys(statusCounts).length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(statusCounts)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.map(([status, count]) => (
|
||||||
|
<Badge key={status} variant="outline" className="gap-1">
|
||||||
|
<Hash className="h-3 w-3" />
|
||||||
|
{formatStatusLabel(status)} <span className="font-bold tabular-nums">{count}</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top 10 ranked table */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1.5">
|
||||||
|
<Trophy className="h-3.5 w-3.5" /> Top 10 by Average Score
|
||||||
|
</p>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-10">#</TableHead>
|
||||||
|
<TableHead>Project</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell">Team</TableHead>
|
||||||
|
<TableHead className="text-right">Avg</TableHead>
|
||||||
|
<TableHead className="text-right">Evals</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{projectRankings.slice(0, 10).map((p, idx) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell className="text-muted-foreground tabular-nums">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<Link href={`/admin/projects/${p.id}`} className="hover:underline">
|
||||||
|
{p.title}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
||||||
|
{p.teamName || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{p.averageScore === null ? '-' : p.averageScore.toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{p.evaluationCount}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{formatStatusLabel(p.status)}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Link to full analytics */}
|
||||||
|
{projectRankings.length > 10 && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="ghost" size="sm" className="gap-1 text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
const tabs = document.querySelectorAll('[role="tab"]')
|
||||||
|
const analyticsTab = Array.from(tabs).find(t => t.textContent?.includes('Analytics')) as HTMLElement
|
||||||
|
analyticsTab?.click()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View full rankings in Analytics <ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||||
<ClipboardList className="h-10 w-10 text-muted-foreground/50" />
|
<ClipboardList className="h-10 w-10 text-muted-foreground/50" />
|
||||||
@@ -408,6 +488,30 @@ function parseSelection(value: string | null): { roundId?: string; programId?: s
|
|||||||
return { roundId: value }
|
return { roundId: value }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map raw DB status to display-friendly labels
|
||||||
|
function formatStatusLabel(status: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
ELIGIBLE: 'Special Award',
|
||||||
|
ASSIGNED: 'Assigned',
|
||||||
|
PENDING: 'Pending',
|
||||||
|
IN_PROGRESS: 'In Progress',
|
||||||
|
PASSED: 'Passed',
|
||||||
|
REJECTED: 'Rejected',
|
||||||
|
COMPLETED: 'Completed',
|
||||||
|
WITHDRAWN: 'Withdrawn',
|
||||||
|
}
|
||||||
|
return labels[status] ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the best default round: active > last closed > first
|
||||||
|
function findDefaultRound(rounds: Array<{ id: string; status?: string }>): string | undefined {
|
||||||
|
const active = rounds.find(r => r.status === 'ROUND_ACTIVE')
|
||||||
|
if (active) return active.id
|
||||||
|
const closed = [...rounds].reverse().find(r => r.status === 'ROUND_CLOSED')
|
||||||
|
if (closed) return closed.id
|
||||||
|
return rounds[0]?.id
|
||||||
|
}
|
||||||
|
|
||||||
function StageAnalytics() {
|
function StageAnalytics() {
|
||||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -415,13 +519,13 @@ function StageAnalytics() {
|
|||||||
|
|
||||||
// Flatten stages from all programs with program name
|
// Flatten stages from all programs with program name
|
||||||
const rounds = programs?.flatMap(p =>
|
const rounds = programs?.flatMap(p =>
|
||||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
|
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
// Set default selected stage
|
// Set default selected stage — prefer active round
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rounds.length && !selectedValue) {
|
if (rounds.length && !selectedValue) {
|
||||||
setSelectedValue(rounds[0].id)
|
setSelectedValue(findDefaultRound(rounds) ?? rounds[0].id)
|
||||||
}
|
}
|
||||||
}, [rounds.length, selectedValue])
|
}, [rounds.length, selectedValue])
|
||||||
|
|
||||||
@@ -702,12 +806,12 @@ function JurorConsistencyTab() {
|
|||||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
|
|
||||||
const stages = programs?.flatMap((p) =>
|
const stages = programs?.flatMap((p) =>
|
||||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
|
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programId: p.id, programName: `${p.year} Edition` }))
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stages.length && !selectedValue) {
|
if (stages.length && !selectedValue) {
|
||||||
setSelectedValue(stages[0].id)
|
setSelectedValue(findDefaultRound(stages) ?? stages[0].id)
|
||||||
}
|
}
|
||||||
}, [stages.length, selectedValue])
|
}, [stages.length, selectedValue])
|
||||||
|
|
||||||
@@ -776,12 +880,12 @@ function DiversityTab() {
|
|||||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
|
|
||||||
const stages = programs?.flatMap((p) =>
|
const stages = programs?.flatMap((p) =>
|
||||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
|
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programId: p.id, programName: `${p.year} Edition` }))
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stages.length && !selectedValue) {
|
if (stages.length && !selectedValue) {
|
||||||
setSelectedValue(stages[0].id)
|
setSelectedValue(findDefaultRound(stages) ?? stages[0].id)
|
||||||
}
|
}
|
||||||
}, [stages.length, selectedValue])
|
}, [stages.length, selectedValue])
|
||||||
|
|
||||||
@@ -896,6 +1000,8 @@ function RoundPipelineTab() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{rounds.map((round, idx) => {
|
{rounds.map((round, idx) => {
|
||||||
const stats = comparisonMap.get(round.id) as any
|
const stats = comparisonMap.get(round.id) as any
|
||||||
|
const isClosed = round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED'
|
||||||
|
const progressValue = isClosed ? 100 : (stats?.completionRate ?? 0)
|
||||||
return (
|
return (
|
||||||
<div key={round.id} className="flex items-center gap-4">
|
<div key={round.id} className="flex items-center gap-4">
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium">
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium">
|
||||||
@@ -910,14 +1016,12 @@ function RoundPipelineTab() {
|
|||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<span className="tabular-nums">{stats?.projectCount ?? 0} projects</span>
|
<span className="tabular-nums">{stats?.projectCount ?? 0} projects</span>
|
||||||
<span className="tabular-nums">{stats?.evaluationCount ?? 0} evals</span>
|
<span className="tabular-nums">{stats?.evaluationCount ?? 0} evals</span>
|
||||||
<Badge variant={round.status === 'ROUND_ACTIVE' ? 'default' : round.status === 'ROUND_CLOSED' ? 'secondary' : 'outline'}>
|
<Badge variant={round.status === 'ROUND_ACTIVE' ? 'default' : isClosed ? 'secondary' : 'outline'}>
|
||||||
{round.status?.replace('ROUND_', '') ?? 'DRAFT'}
|
{round.status?.replace('ROUND_', '') ?? 'DRAFT'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{stats?.completionRate != null && (
|
<Progress value={progressValue} className="mt-2 h-2" />
|
||||||
<Progress value={stats.completionRate} className="mt-2 h-2" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -934,12 +1038,12 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
const { data: pdfPrograms } = trpc.program.list.useQuery({ includeStages: true })
|
const { data: pdfPrograms } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
const pdfStages = pdfPrograms?.flatMap((p) =>
|
const pdfStages = pdfPrograms?.flatMap((p) =>
|
||||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
|
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programName: `${p.year} Edition` }))
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pdfStages.length && !pdfStageId) {
|
if (pdfStages.length && !pdfStageId) {
|
||||||
setPdfStageId(pdfStages[0].id)
|
setPdfStageId(findDefaultRound(pdfStages) ?? pdfStages[0].id)
|
||||||
}
|
}
|
||||||
}, [pdfStages.length, pdfStageId])
|
}, [pdfStages.length, pdfStageId])
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -46,43 +46,28 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useEdition } from '@/contexts/edition-context'
|
import { useEdition } from '@/contexts/edition-context'
|
||||||
|
import {
|
||||||
|
roundTypeConfig,
|
||||||
|
roundStatusConfig,
|
||||||
|
awardStatusConfig,
|
||||||
|
ROUND_TYPE_OPTIONS,
|
||||||
|
} from '@/lib/round-config'
|
||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
// ─── Constants (derived from shared config) ──────────────────────────────────
|
||||||
|
|
||||||
const ROUND_TYPES = [
|
const ROUND_TYPES = ROUND_TYPE_OPTIONS
|
||||||
{ value: 'INTAKE', label: 'Intake' },
|
|
||||||
{ value: 'FILTERING', label: 'Filtering' },
|
|
||||||
{ value: 'EVALUATION', label: 'Evaluation' },
|
|
||||||
{ value: 'SUBMISSION', label: 'Submission' },
|
|
||||||
{ value: 'MENTORING', label: 'Mentoring' },
|
|
||||||
{ value: 'LIVE_FINAL', label: 'Live Final' },
|
|
||||||
{ value: 'DELIBERATION', label: 'Deliberation' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const ROUND_TYPE_COLORS: Record<string, { dot: string; bg: string; text: string; border: string }> = {
|
const ROUND_TYPE_COLORS: Record<string, { dot: string; bg: string; text: string; border: string }> = Object.fromEntries(
|
||||||
INTAKE: { dot: '#9ca3af', bg: 'bg-gray-50', text: 'text-gray-600', border: 'border-gray-300' },
|
Object.entries(roundTypeConfig).map(([k, v]) => [k, { dot: v.dotColor, bg: v.cardBg, text: v.cardText, border: v.cardBorder }])
|
||||||
FILTERING: { dot: '#f59e0b', bg: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-300' },
|
)
|
||||||
EVALUATION: { dot: '#3b82f6', bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-300' },
|
|
||||||
SUBMISSION: { dot: '#8b5cf6', bg: 'bg-purple-50', text: 'text-purple-700', border: 'border-purple-300' },
|
|
||||||
MENTORING: { dot: '#557f8c', bg: 'bg-teal-50', text: 'text-teal-700', border: 'border-teal-300' },
|
|
||||||
LIVE_FINAL: { dot: '#de0f1e', bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-300' },
|
|
||||||
DELIBERATION: { dot: '#6366f1', bg: 'bg-indigo-50', text: 'text-indigo-700', border: 'border-indigo-300' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROUND_STATUS_STYLES: Record<string, { color: string; label: string; pulse?: boolean }> = {
|
const ROUND_STATUS_STYLES: Record<string, { color: string; label: string; pulse?: boolean }> = Object.fromEntries(
|
||||||
ROUND_DRAFT: { color: '#9ca3af', label: 'Draft' },
|
Object.entries(roundStatusConfig).map(([k, v]) => [k, { color: v.dotColor, label: v.label, pulse: v.pulse }])
|
||||||
ROUND_ACTIVE: { color: '#10b981', label: 'Active', pulse: true },
|
)
|
||||||
ROUND_CLOSED: { color: '#3b82f6', label: 'Closed' },
|
|
||||||
ROUND_ARCHIVED: { color: '#6b7280', label: 'Archived' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const AWARD_STATUS_COLORS: Record<string, string> = {
|
const AWARD_STATUS_COLORS: Record<string, string> = Object.fromEntries(
|
||||||
DRAFT: 'text-gray-500',
|
Object.entries(awardStatusConfig).map(([k, v]) => [k, v.color])
|
||||||
NOMINATIONS_OPEN: 'text-amber-600',
|
)
|
||||||
VOTING_OPEN: 'text-emerald-600',
|
|
||||||
CLOSED: 'text-blue-600',
|
|
||||||
ARCHIVED: 'text-gray-400',
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -205,14 +190,14 @@ export default function RoundsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startEditSettings = () => {
|
const startEditSettings = () => {
|
||||||
if (!comp || !compDetail) return
|
if (!comp) return
|
||||||
setEditingCompId(comp.id)
|
setEditingCompId(comp.id)
|
||||||
setCompetitionEdits({
|
setCompetitionEdits({
|
||||||
name: compDetail.name,
|
name: comp.name,
|
||||||
categoryMode: compDetail.categoryMode,
|
categoryMode: (comp as any).categoryMode,
|
||||||
startupFinalistCount: compDetail.startupFinalistCount,
|
startupFinalistCount: (comp as any).startupFinalistCount,
|
||||||
conceptFinalistCount: compDetail.conceptFinalistCount,
|
conceptFinalistCount: (comp as any).conceptFinalistCount,
|
||||||
notifyOnDeadlineApproach: compDetail.notifyOnDeadlineApproach,
|
notifyOnDeadlineApproach: (comp as any).notifyOnDeadlineApproach,
|
||||||
})
|
})
|
||||||
setSettingsOpen(true)
|
setSettingsOpen(true)
|
||||||
}
|
}
|
||||||
@@ -268,12 +253,12 @@ export default function RoundsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-[#053d57] mb-1">No Competition Configured</h3>
|
<h3 className="text-lg font-semibold text-[#053d57] mb-1">No Competition Configured</h3>
|
||||||
<p className="text-sm text-muted-foreground max-w-sm mb-5">
|
<p className="text-sm text-muted-foreground max-w-sm mb-5">
|
||||||
Create a competition to start building the evaluation pipeline.
|
Create a program edition to start building the evaluation pipeline.
|
||||||
</p>
|
</p>
|
||||||
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
|
<Link href={'/admin/programs' as Route}>
|
||||||
<Button className="bg-[#de0f1e] hover:bg-[#de0f1e]/90">
|
<Button className="bg-[#de0f1e] hover:bg-[#de0f1e]/90">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Create Competition
|
Manage Editions
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export default async function AdminLayout({
|
|||||||
|
|
||||||
// Fetch all editions (programs) for the edition selector
|
// Fetch all editions (programs) for the edition selector
|
||||||
const editions = await prisma.program.findMany({
|
const editions = await prisma.program.findMany({
|
||||||
where: { isTest: false },
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import { useSession } from 'next-auth/react'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline'
|
import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline'
|
||||||
import { ArrowLeft, FileText, Calendar } from 'lucide-react'
|
import { ArrowLeft, FileText, Calendar } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
export default function ApplicantCompetitionsPage() {
|
export default function ApplicantCompetitionPage() {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
||||||
enabled: !!session,
|
enabled: !!session,
|
||||||
@@ -26,7 +25,7 @@ export default function ApplicantCompetitionsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const competitionId = myProject?.project?.programId
|
const hasProject = !!myProject?.project
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -45,7 +44,7 @@ export default function ApplicantCompetitionsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!competitionId ? (
|
{!hasProject ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||||
@@ -59,7 +58,7 @@ export default function ApplicantCompetitionsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<ApplicantCompetitionTimeline competitionId={competitionId} />
|
<ApplicantCompetitionTimeline />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
||||||
import { FileUploadSlot } from '@/components/applicant/file-upload-slot'
|
|
||||||
import { ArrowLeft, Lock, Clock, Calendar, AlertCircle } from 'lucide-react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
export default function ApplicantSubmissionWindowPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const windowId = params.windowId as string
|
|
||||||
|
|
||||||
const [uploadedFiles, setUploadedFiles] = useState<Record<string, File>>({})
|
|
||||||
|
|
||||||
const { data: window, isLoading } = trpc.round.getById.useQuery(
|
|
||||||
{ id: windowId },
|
|
||||||
{ enabled: !!windowId }
|
|
||||||
)
|
|
||||||
|
|
||||||
const { data: deadlineStatus } = trpc.round.checkDeadline.useQuery(
|
|
||||||
{ windowId },
|
|
||||||
{
|
|
||||||
enabled: !!windowId,
|
|
||||||
refetchInterval: 60000, // Refresh every minute
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleUpload = (requirementId: string, file: File) => {
|
|
||||||
setUploadedFiles(prev => ({ ...prev, [requirementId]: file }))
|
|
||||||
toast.success(`File "${file.name}" selected for upload`)
|
|
||||||
// In a real implementation, this would trigger file upload
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Skeleton className="h-8 w-64" />
|
|
||||||
<Skeleton className="h-96" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!window) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Button variant="ghost" size="sm" asChild>
|
|
||||||
<Link href={'/applicant/competitions' as Route}>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
|
||||||
<p className="font-medium">Submission window not found</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLocked = deadlineStatus?.status === 'LOCKED' || deadlineStatus?.status === 'CLOSED'
|
|
||||||
const deadline = window.windowCloseAt
|
|
||||||
? new Date(window.windowCloseAt)
|
|
||||||
: null
|
|
||||||
const timeRemaining = deadline ? deadline.getTime() - Date.now() : null
|
|
||||||
const daysRemaining = timeRemaining ? Math.floor(timeRemaining / (1000 * 60 * 60 * 24)) : null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="sm" asChild>
|
|
||||||
<Link href={'/applicant/competitions' as Route}>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{window.name}</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Upload required documents for this submission window
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Deadline card */}
|
|
||||||
{deadline && (
|
|
||||||
<Card className={isLocked ? 'border-red-200 bg-red-50/50' : 'border-l-4 border-l-amber-500'}>
|
|
||||||
<CardContent className="flex items-start gap-3 p-4">
|
|
||||||
{isLocked ? (
|
|
||||||
<>
|
|
||||||
<Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-medium text-sm text-red-900">Submission Window Closed</p>
|
|
||||||
<p className="text-sm text-red-700 mt-1">
|
|
||||||
This submission window closed on {deadline.toLocaleDateString()}. No further
|
|
||||||
uploads are allowed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Clock className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-medium text-sm">Deadline Countdown</p>
|
|
||||||
<div className="flex items-baseline gap-2 mt-1">
|
|
||||||
<span className="text-2xl font-bold tabular-nums text-amber-600">
|
|
||||||
{daysRemaining !== null ? daysRemaining : '—'}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
day{daysRemaining !== 1 ? 's' : ''} remaining
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Due: {deadline.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* File requirements */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>File Requirements</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Upload the required files below. {isLocked && 'Viewing only - window is closed.'}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* File requirements would be fetched separately in a real implementation */}
|
|
||||||
{false ? (
|
|
||||||
[].map((req: any) => (
|
|
||||||
<FileUploadSlot
|
|
||||||
key={req.id}
|
|
||||||
requirement={{
|
|
||||||
id: req.id,
|
|
||||||
label: req.label,
|
|
||||||
description: req.description,
|
|
||||||
mimeTypes: req.mimeTypes || [],
|
|
||||||
maxSizeMb: req.maxSizeMb,
|
|
||||||
required: req.required || false,
|
|
||||||
}}
|
|
||||||
isLocked={isLocked}
|
|
||||||
onUpload={(file) => handleUpload(req.id, file)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<Calendar className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No file requirements defined for this window
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{!isLocked && (
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button variant="outline">Save Draft</Button>
|
|
||||||
<Button className="bg-brand-blue hover:bg-brand-blue-light">
|
|
||||||
Submit All Files
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
147
src/app/(applicant)/applicant/evaluations/page.tsx
Normal file
147
src/app/(applicant)/applicant/evaluations/page.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Star, MessageSquare } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function ApplicantEvaluationsPage() {
|
||||||
|
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
||||||
|
<p className="text-muted-foreground">Anonymous evaluations from jury members</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="py-6">
|
||||||
|
<Skeleton className="h-6 w-48 mb-4" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEvaluations = rounds && rounds.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Anonymous evaluations from jury members
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasEvaluations ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Star className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">No Evaluations Available</h3>
|
||||||
|
<p className="text-muted-foreground text-center max-w-md">
|
||||||
|
Evaluations will appear here once jury review is complete and results are published.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{rounds.map((round) => (
|
||||||
|
<Card key={round.roundId}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>{round.roundName}</CardTitle>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{round.evaluationCount} evaluation{round.evaluationCount !== 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{round.evaluations.map((ev, idx) => (
|
||||||
|
<div
|
||||||
|
key={ev.id}
|
||||||
|
className="rounded-lg border p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
Evaluator #{idx + 1}
|
||||||
|
</span>
|
||||||
|
{ev.submittedAt && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(ev.submittedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ev.globalScore !== null && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Star className="h-4 w-4 text-yellow-500" />
|
||||||
|
<span className="text-lg font-semibold">{ev.globalScore}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">/ 100</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ev.criterionScores && ev.criteria && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Criterion Scores</p>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{(() => {
|
||||||
|
const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
|
||||||
|
const scores = ev.criterionScores as Record<string, number>
|
||||||
|
return criteria
|
||||||
|
.filter((c) => c.id || c.label || c.name)
|
||||||
|
.map((c, ci) => {
|
||||||
|
const key = c.id || String(ci)
|
||||||
|
const score = scores[key]
|
||||||
|
return (
|
||||||
|
<div key={ci} className="flex items-center justify-between text-sm">
|
||||||
|
<span>{c.label || c.name || `Criterion ${ci + 1}`}</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{score !== undefined ? score : '—'}
|
||||||
|
{c.maxScore ? ` / ${c.maxScore}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ev.feedbackText && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
|
||||||
|
<MessageSquare className="h-3.5 w-3.5" />
|
||||||
|
Written Feedback
|
||||||
|
</div>
|
||||||
|
<blockquote className="border-l-2 border-muted pl-4 text-sm italic text-muted-foreground">
|
||||||
|
{ev.feedbackText}
|
||||||
|
</blockquote>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
Evaluator identities are kept confidential.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,25 +9,26 @@ import { Button } from '@/components/ui/button'
|
|||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
import { StatusTracker } from '@/components/shared/status-tracker'
|
import { StatusTracker } from '@/components/shared/status-tracker'
|
||||||
|
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
AlertCircle,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Users,
|
Users,
|
||||||
Crown,
|
Crown,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Upload,
|
Upload,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
Star,
|
||||||
|
AlertCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
||||||
@@ -49,6 +50,18 @@ export default function ApplicantDashboardPage() {
|
|||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: deadlines } = trpc.applicant.getUpcomingDeadlines.useQuery(undefined, {
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: docCompleteness } = trpc.applicant.getDocumentCompleteness.useQuery(undefined, {
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: evaluations } = trpc.applicant.getMyEvaluations.useQuery(undefined, {
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
})
|
||||||
|
|
||||||
if (sessionStatus === 'loading' || isLoading) {
|
if (sessionStatus === 'loading' || isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -98,10 +111,11 @@ export default function ApplicantDashboardPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { project, timeline, currentStatus, openRounds } = data
|
const { project, timeline, currentStatus, openRounds, hasPassedIntake } = data
|
||||||
const isDraft = !project.submittedAt
|
const isDraft = !project.submittedAt
|
||||||
const programYear = project.program?.year
|
const programYear = project.program?.year
|
||||||
const programName = project.program?.name
|
const programName = project.program?.name
|
||||||
|
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -213,7 +227,7 @@ export default function ApplicantDashboardPage() {
|
|||||||
|
|
||||||
{/* Quick actions */}
|
{/* Quick actions */}
|
||||||
<AnimatedCard index={1}>
|
<AnimatedCard index={1}>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Link href={"/applicant/documents" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
|
<Link href={"/applicant/documents" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
|
||||||
<div className="rounded-xl bg-blue-500/10 p-2.5 transition-colors group-hover:bg-blue-500/20">
|
<div className="rounded-xl bg-blue-500/10 p-2.5 transition-colors group-hover:bg-blue-500/20">
|
||||||
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
@@ -240,41 +254,108 @@ export default function ApplicantDashboardPage() {
|
|||||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href={"/applicant/mentor" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-green-500/30 hover:bg-green-500/5">
|
{project.mentorAssignment && (
|
||||||
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
|
<Link href={"/applicant/mentor" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-green-500/30 hover:bg-green-500/5">
|
||||||
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
|
||||||
</div>
|
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
<div className="flex-1 min-w-0">
|
</div>
|
||||||
<p className="text-sm font-medium">Mentor</p>
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-sm font-medium">Mentor</p>
|
||||||
{project.mentorAssignment?.mentor?.name || 'Not assigned'}
|
<p className="text-xs text-muted-foreground">
|
||||||
</p>
|
{project.mentorAssignment.mentor?.name || 'Assigned'}
|
||||||
</div>
|
</p>
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
</div>
|
||||||
</Link>
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
|
{/* Document Completeness */}
|
||||||
|
{docCompleteness && docCompleteness.length > 0 && (
|
||||||
|
<AnimatedCard index={2}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
Document Progress
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{docCompleteness.map((dc) => (
|
||||||
|
<div key={dc.roundId}>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1.5">
|
||||||
|
<span className="font-medium">{dc.roundName}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{dc.uploaded}/{dc.required} files
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: `${dc.required > 0 ? Math.round((dc.uploaded / dc.required) * 100) : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Status timeline */}
|
{/* Competition timeline or status tracker */}
|
||||||
<AnimatedCard index={2}>
|
<AnimatedCard index={3}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Status Timeline</CardTitle>
|
<CardTitle>
|
||||||
|
{hasPassedIntake ? 'Competition Progress' : 'Status Timeline'}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<StatusTracker
|
{hasPassedIntake ? (
|
||||||
timeline={timeline}
|
<CompetitionTimelineSidebar />
|
||||||
currentStatus={currentStatus || 'SUBMITTED'}
|
) : (
|
||||||
/>
|
<StatusTracker
|
||||||
|
timeline={timeline}
|
||||||
|
currentStatus={currentStatus || 'SUBMITTED'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
|
{/* Jury Feedback Card */}
|
||||||
|
{totalEvaluations > 0 && (
|
||||||
|
<AnimatedCard index={4}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Star className="h-5 w-5" />
|
||||||
|
Jury Feedback
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={"/applicant/evaluations" as Route}>
|
||||||
|
View All
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{totalEvaluations} evaluation{totalEvaluations !== 1 ? 's' : ''} available from{' '}
|
||||||
|
{evaluations?.length ?? 0} round{(evaluations?.length ?? 0) !== 1 ? 's' : ''}.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Team overview */}
|
{/* Team overview */}
|
||||||
<AnimatedCard index={3}>
|
<AnimatedCard index={5}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -326,8 +407,32 @@ export default function ApplicantDashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
|
{/* Upcoming Deadlines */}
|
||||||
|
{deadlines && deadlines.length > 0 && (
|
||||||
|
<AnimatedCard index={6}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
Upcoming Deadlines
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{deadlines.map((dl, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium truncate mr-2">{dl.roundName}</span>
|
||||||
|
<span className="text-muted-foreground shrink-0">
|
||||||
|
{new Date(dl.windowCloseAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Key dates */}
|
{/* Key dates */}
|
||||||
<AnimatedCard index={4}>
|
<AnimatedCard index={7}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Key Dates</CardTitle>
|
<CardTitle>Key Dates</CardTitle>
|
||||||
@@ -347,12 +452,6 @@ export default function ApplicantDashboardPage() {
|
|||||||
<span className="text-muted-foreground">Last Updated</span>
|
<span className="text-muted-foreground">Last Updated</span>
|
||||||
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
|
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
|
||||||
</div>
|
</div>
|
||||||
{openRounds.length > 0 && openRounds[0].windowCloseAt && (
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Deadline</span>
|
|
||||||
<span>{new Date(openRounds[0].windowCloseAt).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|||||||
122
src/app/(applicant)/applicant/resources/[id]/page.tsx
Normal file
122
src/app/(applicant)/applicant/resources/[id]/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const ResourceRenderer = dynamic(
|
||||||
|
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function ApplicantResourceDetailPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const resourceId = params.id as string
|
||||||
|
|
||||||
|
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||||
|
const logAccess = trpc.learningResource.logAccess.useMutation()
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
// Log access on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (resourceId) {
|
||||||
|
logAccess.mutate({ id: resourceId })
|
||||||
|
}
|
||||||
|
}, [resourceId])
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
|
||||||
|
window.open(url, '_blank')
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
<Skeleton className="h-10 w-2/3" />
|
||||||
|
<Skeleton className="h-6 w-1/3" />
|
||||||
|
<Skeleton className="h-px w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !resource) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Resource not found</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
This resource may have been removed or you don't have access.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/applicant/resources">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Resources
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button variant="ghost" asChild className="-ml-4">
|
||||||
|
<Link href="/applicant/resources">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Resources
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{resource.externalUrl && (
|
||||||
|
<a href={resource.externalUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Open Link
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{resource.objectKey && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<ResourceRenderer
|
||||||
|
title={resource.title}
|
||||||
|
description={resource.description}
|
||||||
|
contentJson={resource.contentJson}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
146
src/app/(applicant)/applicant/resources/page.tsx
Normal file
146
src/app/(applicant)/applicant/resources/page.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
BookOpen,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export default function ApplicantResourcesPage() {
|
||||||
|
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = trpc.learningResource.myResources.useQuery({})
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const handleDownload = async (resourceId: string) => {
|
||||||
|
setDownloadingId(resourceId)
|
||||||
|
try {
|
||||||
|
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
|
||||||
|
window.open(url, '_blank')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download failed:', error)
|
||||||
|
} finally {
|
||||||
|
setDownloadingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Resources</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Resources and materials for applicants
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
|
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = data?.resources || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Resources</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Resources and materials for applicants
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{resources.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<BookOpen className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">No resources available</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Check back later for learning materials
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{resources.map((resource) => {
|
||||||
|
const isDownloading = downloadingId === resource.id
|
||||||
|
const hasContent = !!resource.contentJson
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={resource.id}>
|
||||||
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-medium">{resource.title}</h3>
|
||||||
|
{resource.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||||
|
{resource.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasContent && (
|
||||||
|
<Link href={`/applicant/resources/${resource.id}` as Route}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<BookOpen className="mr-2 h-4 w-4" />
|
||||||
|
Read
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{resource.externalUrl && (
|
||||||
|
<a
|
||||||
|
href={resource.externalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{resource.objectKey && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDownload(resource.id)}
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
{isDownloading ? 'Loading...' : 'Download'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -280,6 +280,15 @@ export default function ApplicantTeamPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/50 border p-3 text-sm">
|
||||||
|
<p className="font-medium mb-1">What invited members can do:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||||
|
<li>Upload documents for submission rounds</li>
|
||||||
|
<li>View project status and competition progress</li>
|
||||||
|
<li>Receive email notifications about round updates</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-2 text-muted-foreground">Only the Team Lead can invite or remove members.</p>
|
||||||
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
const isDirtyRef = useRef(false)
|
const isDirtyRef = useRef(false)
|
||||||
const evaluationIdRef = useRef<string | null>(null)
|
const evaluationIdRef = useRef<string | null>(null)
|
||||||
const isSubmittedRef = useRef(false)
|
const isSubmittedRef = useRef(false)
|
||||||
|
const isSubmittingRef = useRef(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const startPromiseRef = useRef<Promise<{ id: string }> | null>(null)
|
||||||
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
|
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
|
||||||
|
|
||||||
@@ -173,6 +176,24 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Initialize numeric criteria with midpoint values so slider visual matches stored value.
|
||||||
|
const criteriaInitializedRef = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (criteriaInitializedRef.current || criteria.length === 0) return
|
||||||
|
if (existingEvaluation?.criterionScoresJson) return
|
||||||
|
criteriaInitializedRef.current = true
|
||||||
|
|
||||||
|
const defaults: Record<string, number | boolean | string> = {}
|
||||||
|
for (const c of criteria) {
|
||||||
|
if (c.type === 'numeric') {
|
||||||
|
defaults[c.id] = Math.ceil((c.minScore + c.maxScore) / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(defaults).length > 0) {
|
||||||
|
setCriteriaValues((prev) => ({ ...defaults, ...prev }))
|
||||||
|
}
|
||||||
|
}, [criteria, existingEvaluation?.criterionScoresJson])
|
||||||
|
|
||||||
// Build current form data for autosave
|
// Build current form data for autosave
|
||||||
const buildSavePayload = useCallback(() => {
|
const buildSavePayload = useCallback(() => {
|
||||||
return {
|
return {
|
||||||
@@ -185,17 +206,23 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
|
|
||||||
// Perform autosave
|
// Perform autosave
|
||||||
const performAutosave = useCallback(async () => {
|
const performAutosave = useCallback(async () => {
|
||||||
if (!isDirtyRef.current || isSubmittedRef.current) return
|
if (!isDirtyRef.current || isSubmittedRef.current || isSubmittingRef.current) return
|
||||||
if (existingEvaluation?.status === 'SUBMITTED') return
|
if (existingEvaluation?.status === 'SUBMITTED') return
|
||||||
|
|
||||||
let evalId = evaluationIdRef.current
|
let evalId = evaluationIdRef.current
|
||||||
if (!evalId && myAssignment) {
|
if (!evalId && myAssignment) {
|
||||||
try {
|
try {
|
||||||
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
// Reuse in-flight start promise to avoid duplicate creation
|
||||||
|
if (!startPromiseRef.current) {
|
||||||
|
startPromiseRef.current = startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
||||||
|
}
|
||||||
|
const newEval = await startPromiseRef.current
|
||||||
evalId = newEval.id
|
evalId = newEval.id
|
||||||
evaluationIdRef.current = evalId
|
evaluationIdRef.current = evalId
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
|
} finally {
|
||||||
|
startPromiseRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!evalId) return
|
if (!evalId) return
|
||||||
@@ -277,7 +304,11 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
|
|
||||||
let evaluationId = evaluationIdRef.current
|
let evaluationId = evaluationIdRef.current
|
||||||
if (!evaluationId) {
|
if (!evaluationId) {
|
||||||
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
if (!startPromiseRef.current) {
|
||||||
|
startPromiseRef.current = startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
||||||
|
}
|
||||||
|
const newEval = await startPromiseRef.current
|
||||||
|
startPromiseRef.current = null
|
||||||
evaluationId = newEval.id
|
evaluationId = newEval.id
|
||||||
evaluationIdRef.current = evaluationId
|
evaluationIdRef.current = evaluationId
|
||||||
}
|
}
|
||||||
@@ -293,8 +324,18 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
// Cancel any pending autosave to avoid race conditions
|
||||||
|
if (autosaveTimerRef.current) {
|
||||||
|
clearTimeout(autosaveTimerRef.current)
|
||||||
|
autosaveTimerRef.current = null
|
||||||
|
}
|
||||||
|
isSubmittingRef.current = true
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
if (!myAssignment) {
|
if (!myAssignment) {
|
||||||
toast.error('Assignment not found')
|
toast.error('Assignment not found')
|
||||||
|
isSubmittingRef.current = false
|
||||||
|
setIsSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,14 +348,20 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
const val = criteriaValues[c.id]
|
const val = criteriaValues[c.id]
|
||||||
if (c.type === 'numeric' && (val === undefined || val === null)) {
|
if (c.type === 'numeric' && (val === undefined || val === null)) {
|
||||||
toast.error(`Please score "${c.label}"`)
|
toast.error(`Please score "${c.label}"`)
|
||||||
|
isSubmittingRef.current = false
|
||||||
|
setIsSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (c.type === 'boolean' && val === undefined) {
|
if (c.type === 'boolean' && val === undefined) {
|
||||||
toast.error(`Please answer "${c.label}"`)
|
toast.error(`Please answer "${c.label}"`)
|
||||||
|
isSubmittingRef.current = false
|
||||||
|
setIsSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) {
|
if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) {
|
||||||
toast.error(`Please fill in "${c.label}"`)
|
toast.error(`Please fill in "${c.label}"`)
|
||||||
|
isSubmittingRef.current = false
|
||||||
|
setIsSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,6 +371,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
const score = parseInt(globalScore, 10)
|
const score = parseInt(globalScore, 10)
|
||||||
if (isNaN(score) || score < 1 || score > 10) {
|
if (isNaN(score) || score < 1 || score > 10) {
|
||||||
toast.error('Please enter a valid score between 1 and 10')
|
toast.error('Please enter a valid score between 1 and 10')
|
||||||
|
isSubmittingRef.current = false
|
||||||
|
setIsSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,6 +380,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
if (scoringMode === 'binary') {
|
if (scoringMode === 'binary') {
|
||||||
if (!binaryDecision) {
|
if (!binaryDecision) {
|
||||||
toast.error('Please select accept or reject')
|
toast.error('Please select accept or reject')
|
||||||
|
isSubmittingRef.current = false
|
||||||
|
setIsSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,13 +389,20 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
if (requireFeedback) {
|
if (requireFeedback) {
|
||||||
if (!feedbackText.trim() || feedbackText.length < feedbackMinLength) {
|
if (!feedbackText.trim() || feedbackText.length < feedbackMinLength) {
|
||||||
toast.error(`Please provide feedback (minimum ${feedbackMinLength} characters)`)
|
toast.error(`Please provide feedback (minimum ${feedbackMinLength} characters)`)
|
||||||
|
isSubmittingRef.current = false
|
||||||
|
setIsSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reuse in-flight start promise to avoid duplicate creation (e.g. if autosave is mid-flight)
|
||||||
let evaluationId = evaluationIdRef.current
|
let evaluationId = evaluationIdRef.current
|
||||||
if (!evaluationId) {
|
if (!evaluationId) {
|
||||||
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
if (!startPromiseRef.current) {
|
||||||
|
startPromiseRef.current = startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
||||||
|
}
|
||||||
|
const newEval = await startPromiseRef.current
|
||||||
|
startPromiseRef.current = null
|
||||||
evaluationId = newEval.id
|
evaluationId = newEval.id
|
||||||
evaluationIdRef.current = evaluationId
|
evaluationIdRef.current = evaluationId
|
||||||
}
|
}
|
||||||
@@ -370,13 +428,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
submitMutation.mutate({
|
try {
|
||||||
id: evaluationId,
|
await submitMutation.mutateAsync({
|
||||||
criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : {},
|
id: evaluationId,
|
||||||
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : computedGlobalScore,
|
criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : {},
|
||||||
binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true,
|
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : computedGlobalScore,
|
||||||
feedbackText: feedbackText || 'No feedback provided',
|
binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true,
|
||||||
})
|
feedbackText: feedbackText || 'No feedback provided',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Error toast already handled by onError callback
|
||||||
|
isSubmittingRef.current = false
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!round || !project) {
|
if (!round || !project) {
|
||||||
@@ -840,7 +904,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={submitMutation.isPending || autosaveMutation.isPending}
|
disabled={submitMutation.isPending || isSubmitting}
|
||||||
className="bg-brand-blue hover:bg-brand-blue-light"
|
className="bg-brand-blue hover:bg-brand-blue-light"
|
||||||
>
|
>
|
||||||
<Send className="mr-2 h-4 w-4" />
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
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>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Waves,
|
Waves,
|
||||||
|
Send,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||||
@@ -390,6 +391,11 @@ async function JuryDashboardContent() {
|
|||||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
Done
|
Done
|
||||||
</Badge>
|
</Badge>
|
||||||
|
) : isDraft && isVotingOpen ? (
|
||||||
|
<Badge className="text-xs bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-700 animate-pulse">
|
||||||
|
<Send className="mr-1 h-3 w-3" />
|
||||||
|
Ready to submit
|
||||||
|
</Badge>
|
||||||
) : isDraft ? (
|
) : isDraft ? (
|
||||||
<Badge variant="warning" className="text-xs">
|
<Badge variant="warning" className="text-xs">
|
||||||
<Clock className="mr-1 h-3 w-3" />
|
<Clock className="mr-1 h-3 w-3" />
|
||||||
@@ -404,10 +410,17 @@ async function JuryDashboardContent() {
|
|||||||
View
|
View
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
) : isVotingOpen && isDraft ? (
|
||||||
|
<Button size="sm" asChild className="h-7 px-3 bg-amber-600 hover:bg-amber-700 text-white shadow-sm">
|
||||||
|
<Link href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}/evaluate`}>
|
||||||
|
<Send className="mr-1 h-3 w-3" />
|
||||||
|
Review & Submit
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
) : isVotingOpen ? (
|
) : isVotingOpen ? (
|
||||||
<Button size="sm" asChild className="h-7 px-3 bg-brand-blue hover:bg-brand-blue-light shadow-sm">
|
<Button size="sm" asChild className="h-7 px-3 bg-brand-blue hover:bg-brand-blue-light shadow-sm">
|
||||||
<Link href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}/evaluate`}>
|
<Link href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}/evaluate`}>
|
||||||
{isDraft ? 'Continue' : 'Evaluate'}
|
Evaluate
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export default async function MentorLayout({
|
|||||||
const session = await requireRole('MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
|
const session = await requireRole('MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
|
||||||
|
|
||||||
// Check if user has completed onboarding (for mentors)
|
// Check if user has completed onboarding (for mentors)
|
||||||
if (session.user.role === 'MENTOR') {
|
const userRoles = session.user.roles?.length ? session.user.roles : [session.user.role]
|
||||||
|
if (userRoles.includes('MENTOR') && !userRoles.some(r => r === 'SUPER_ADMIN' || r === 'PROGRAM_ADMIN')) {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
select: { onboardingCompletedAt: true },
|
select: { onboardingCompletedAt: true },
|
||||||
|
|||||||
122
src/app/(mentor)/mentor/resources/[id]/page.tsx
Normal file
122
src/app/(mentor)/mentor/resources/[id]/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const ResourceRenderer = dynamic(
|
||||||
|
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function MentorResourceDetailPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const resourceId = params.id as string
|
||||||
|
|
||||||
|
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||||
|
const logAccess = trpc.learningResource.logAccess.useMutation()
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
// Log access on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (resourceId) {
|
||||||
|
logAccess.mutate({ id: resourceId })
|
||||||
|
}
|
||||||
|
}, [resourceId])
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
|
||||||
|
window.open(url, '_blank')
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
<Skeleton className="h-10 w-2/3" />
|
||||||
|
<Skeleton className="h-6 w-1/3" />
|
||||||
|
<Skeleton className="h-px w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !resource) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Resource not found</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
This resource may have been removed or you don't have access.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/mentor/resources">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Resources
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button variant="ghost" asChild className="-ml-4">
|
||||||
|
<Link href="/mentor/resources">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Resources
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{resource.externalUrl && (
|
||||||
|
<a href={resource.externalUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Open Link
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{resource.objectKey && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<ResourceRenderer
|
||||||
|
title={resource.title}
|
||||||
|
description={resource.description}
|
||||||
|
contentJson={resource.contentJson}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,38 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Video,
|
|
||||||
Link as LinkIcon,
|
|
||||||
File,
|
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const resourceTypeIcons = {
|
|
||||||
PDF: FileText,
|
|
||||||
VIDEO: Video,
|
|
||||||
DOCUMENT: File,
|
|
||||||
LINK: LinkIcon,
|
|
||||||
OTHER: File,
|
|
||||||
}
|
|
||||||
|
|
||||||
const cohortColors: Record<string, string> = {
|
|
||||||
ALL: 'bg-gray-100 text-gray-800',
|
|
||||||
SEMIFINALIST: 'bg-blue-100 text-blue-800',
|
|
||||||
FINALIST: 'bg-purple-100 text-purple-800',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MentorResourcesPage() {
|
export default function MentorResourcesPage() {
|
||||||
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -101,14 +85,14 @@ export default function MentorResourcesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{resources.map((resource) => {
|
{resources.map((resource) => {
|
||||||
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
|
|
||||||
const isDownloading = downloadingId === resource.id
|
const isDownloading = downloadingId === resource.id
|
||||||
|
const hasContent = !!resource.contentJson
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={resource.id}>
|
<Card key={resource.id}>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||||
<Icon className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-medium">{resource.title}</h3>
|
<h3 className="font-medium">{resource.title}</h3>
|
||||||
@@ -117,36 +101,39 @@ export default function MentorResourcesPage() {
|
|||||||
{resource.description}
|
{resource.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<Badge variant="outline" className={cohortColors[resource.cohortLevel] || cohortColors.ALL}>
|
|
||||||
{resource.cohortLevel}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{resource.resourceType}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
{resource.externalUrl ? (
|
{hasContent && (
|
||||||
|
<Link href={`/mentor/resources/${resource.id}` as Route}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<BookOpen className="mr-2 h-4 w-4" />
|
||||||
|
Read
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{resource.externalUrl && (
|
||||||
<a
|
<a
|
||||||
href={resource.externalUrl}
|
href={resource.externalUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<Button>
|
<Button variant="outline" size="sm">
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
Open
|
Open
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
) : resource.objectKey ? (
|
)}
|
||||||
|
{resource.objectKey && (
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={() => handleDownload(resource.id)}
|
onClick={() => handleDownload(resource.id)}
|
||||||
disabled={isDownloading}
|
disabled={isDownloading}
|
||||||
>
|
>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
{isDownloading ? 'Loading...' : 'Download'}
|
{isDownloading ? 'Loading...' : 'Download'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
// Delete projects where isDraft=true AND draftExpiresAt has passed
|
// Delete projects where isDraft=true AND draftExpiresAt has passed
|
||||||
// Exclude test projects — they are managed separately
|
|
||||||
const result = await prisma.project.deleteMany({
|
const result = await prisma.project.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
isTest: false,
|
|
||||||
isDraft: true,
|
isDraft: true,
|
||||||
draftExpiresAt: {
|
draftExpiresAt: {
|
||||||
lt: now,
|
lt: now,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { Metadata } from 'next'
|
|||||||
import './globals.css'
|
import './globals.css'
|
||||||
import { Providers } from './providers'
|
import { Providers } from './providers'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@@ -23,10 +22,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className="min-h-screen bg-background font-sans antialiased">
|
<body className="min-h-screen bg-background font-sans antialiased">
|
||||||
<Providers>
|
<Providers>{children}</Providers>
|
||||||
<ImpersonationBanner />
|
|
||||||
{children}
|
|
||||||
</Providers>
|
|
||||||
<Toaster
|
<Toaster
|
||||||
position="top-right"
|
position="top-right"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
|
|||||||
170
src/components/admin/assignment/coi-review-section.tsx
Normal file
170
src/components/admin/assignment/coi-review-section.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { ShieldAlert, Eye, CheckCircle2, UserPlus, FileText } from 'lucide-react'
|
||||||
|
|
||||||
|
export type COIReviewSectionProps = {
|
||||||
|
roundId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function COIReviewSection({ roundId }: COIReviewSectionProps) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const { data: declarations, isLoading } = trpc.evaluation.listCOIByStage.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 15_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const reviewMutation = trpc.evaluation.reviewCOI.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
utils.evaluation.listCOIByStage.invalidate({ roundId })
|
||||||
|
utils.assignment.listByStage.invalidate({ roundId })
|
||||||
|
utils.analytics.getJurorWorkload.invalidate({ roundId })
|
||||||
|
if (data.reassignment) {
|
||||||
|
toast.success(`Reassigned to ${data.reassignment.newJurorName}`)
|
||||||
|
} else {
|
||||||
|
toast.success('COI review updated')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show placeholder when no declarations
|
||||||
|
if (!isLoading && (!declarations || declarations.length === 0)) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed p-4 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">No conflict of interest declarations yet.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflictCount = declarations?.filter((d) => d.hasConflict).length ?? 0
|
||||||
|
const unreviewedCount = declarations?.filter((d) => d.hasConflict && !d.reviewedAt).length ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<ShieldAlert className="h-4 w-4" />
|
||||||
|
Conflict of Interest Declarations
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{declarations?.length ?? 0} declaration{(declarations?.length ?? 0) !== 1 ? 's' : ''}
|
||||||
|
{conflictCount > 0 && (
|
||||||
|
<> — <span className="text-amber-600 font-medium">{conflictCount} conflict{conflictCount !== 1 ? 's' : ''}</span></>
|
||||||
|
)}
|
||||||
|
{unreviewedCount > 0 && (
|
||||||
|
<> ({unreviewedCount} pending review)</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
||||||
|
<div className="grid grid-cols-[1fr_1fr_80px_100px_100px] gap-2 text-xs text-muted-foreground font-medium px-3 py-2 sticky top-0 bg-background border-b">
|
||||||
|
<span>Juror</span>
|
||||||
|
<span>Project</span>
|
||||||
|
<span>Conflict</span>
|
||||||
|
<span>Type</span>
|
||||||
|
<span>Action</span>
|
||||||
|
</div>
|
||||||
|
{declarations?.map((coi: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={coi.id}
|
||||||
|
className={cn(
|
||||||
|
'grid grid-cols-[1fr_1fr_80px_100px_100px] gap-2 items-center px-3 py-2 rounded-md text-sm transition-colors',
|
||||||
|
idx % 2 === 1 ? 'bg-muted/20' : 'hover:bg-muted/20',
|
||||||
|
coi.hasConflict && !coi.reviewedAt && 'border-l-4 border-l-amber-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{coi.user?.name || coi.user?.email || 'Unknown'}</span>
|
||||||
|
<span className="truncate text-muted-foreground">{coi.assignment?.project?.title || 'Unknown'}</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] justify-center',
|
||||||
|
coi.hasConflict
|
||||||
|
? 'bg-red-50 text-red-700 border-red-200'
|
||||||
|
: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{coi.hasConflict ? 'Yes' : 'No'}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
{coi.hasConflict ? (coi.conflictType || 'Unspecified') : '\u2014'}
|
||||||
|
</span>
|
||||||
|
{coi.hasConflict ? (
|
||||||
|
coi.reviewedAt ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] justify-center',
|
||||||
|
coi.reviewAction === 'cleared'
|
||||||
|
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||||
|
: coi.reviewAction === 'reassigned'
|
||||||
|
? 'bg-blue-50 text-blue-700 border-blue-200'
|
||||||
|
: 'bg-gray-50 text-gray-600 border-gray-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{coi.reviewAction === 'cleared' ? 'Cleared' : coi.reviewAction === 'reassigned' ? 'Reassigned' : 'Noted'}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs">
|
||||||
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
|
Review
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'cleared' })}
|
||||||
|
disabled={reviewMutation.isPending}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 mr-2 text-emerald-600" />
|
||||||
|
Clear — no real conflict
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'reassigned' })}
|
||||||
|
disabled={reviewMutation.isPending}
|
||||||
|
>
|
||||||
|
<UserPlus className="h-3.5 w-3.5 mr-2 text-blue-600" />
|
||||||
|
Reassign to another juror
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'noted' })}
|
||||||
|
disabled={reviewMutation.isPending}
|
||||||
|
>
|
||||||
|
<FileText className="h-3.5 w-3.5 mr-2 text-gray-600" />
|
||||||
|
Note — keep as is
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
854
src/components/admin/assignment/individual-assignments-table.tsx
Normal file
854
src/components/admin/assignment/individual-assignments-table.tsx
Normal file
@@ -0,0 +1,854 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/command'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
RotateCcw,
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Search,
|
||||||
|
MoreHorizontal,
|
||||||
|
UserPlus,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export type IndividualAssignmentsTableProps = {
|
||||||
|
roundId: string
|
||||||
|
projectStates: any[] | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IndividualAssignmentsTable({
|
||||||
|
roundId,
|
||||||
|
projectStates,
|
||||||
|
}: IndividualAssignmentsTableProps) {
|
||||||
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||||
|
const [confirmAction, setConfirmAction] = useState<{ type: 'reset' | 'delete'; assignment: any } | null>(null)
|
||||||
|
const [assignMode, setAssignMode] = useState<'byJuror' | 'byProject'>('byJuror')
|
||||||
|
// ── By Juror mode state ──
|
||||||
|
const [selectedJurorId, setSelectedJurorId] = useState('')
|
||||||
|
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(new Set())
|
||||||
|
const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
|
||||||
|
const [projectSearch, setProjectSearch] = useState('')
|
||||||
|
// ── By Project mode state ──
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState('')
|
||||||
|
const [selectedJurorIds, setSelectedJurorIds] = useState<Set<string>>(new Set())
|
||||||
|
const [projectPopoverOpen, setProjectPopoverOpen] = useState(false)
|
||||||
|
const [jurorSearch, setJurorSearch] = useState('')
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 15_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: juryMembers } = trpc.user.getJuryMembers.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ enabled: addDialogOpen },
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteMutation = trpc.assignment.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.assignment.listByStage.invalidate({ roundId })
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
toast.success('Assignment removed')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetEvalMutation = trpc.evaluation.resetEvaluation.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.assignment.listByStage.invalidate({ roundId })
|
||||||
|
toast.success('Evaluation reset — juror can now start over')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const reassignCOIMutation = trpc.assignment.reassignCOI.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
utils.assignment.listByStage.invalidate({ roundId })
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
utils.analytics.getJurorWorkload.invalidate({ roundId })
|
||||||
|
utils.evaluation.listCOIByStage.invalidate({ roundId })
|
||||||
|
toast.success(`Reassigned to ${data.newJurorName}`)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = trpc.assignment.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.assignment.listByStage.invalidate({ roundId })
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
utils.user.getJuryMembers.invalidate({ roundId })
|
||||||
|
toast.success('Assignment created')
|
||||||
|
resetDialog()
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const bulkCreateMutation = trpc.assignment.bulkCreate.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
utils.assignment.listByStage.invalidate({ roundId })
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
utils.user.getJuryMembers.invalidate({ roundId })
|
||||||
|
toast.success(`${result.created} assignment(s) created`)
|
||||||
|
resetDialog()
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetDialog = useCallback(() => {
|
||||||
|
setAddDialogOpen(false)
|
||||||
|
setAssignMode('byJuror')
|
||||||
|
setSelectedJurorId('')
|
||||||
|
setSelectedProjectIds(new Set())
|
||||||
|
setProjectSearch('')
|
||||||
|
setSelectedProjectId('')
|
||||||
|
setSelectedJurorIds(new Set())
|
||||||
|
setJurorSearch('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectedJuror = useMemo(
|
||||||
|
() => juryMembers?.find((j: any) => j.id === selectedJurorId),
|
||||||
|
[juryMembers, selectedJurorId],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filter projects by search term
|
||||||
|
const filteredProjects = useMemo(() => {
|
||||||
|
const items = projectStates ?? []
|
||||||
|
if (!projectSearch) return items
|
||||||
|
const q = projectSearch.toLowerCase()
|
||||||
|
return items.filter((ps: any) =>
|
||||||
|
ps.project?.title?.toLowerCase().includes(q) ||
|
||||||
|
ps.project?.teamName?.toLowerCase().includes(q) ||
|
||||||
|
ps.project?.competitionCategory?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}, [projectStates, projectSearch])
|
||||||
|
|
||||||
|
// Existing assignments for the selected juror (to grey out already-assigned projects)
|
||||||
|
const jurorExistingProjectIds = useMemo(() => {
|
||||||
|
if (!selectedJurorId || !assignments) return new Set<string>()
|
||||||
|
return new Set(
|
||||||
|
assignments
|
||||||
|
.filter((a: any) => a.userId === selectedJurorId)
|
||||||
|
.map((a: any) => a.projectId)
|
||||||
|
)
|
||||||
|
}, [selectedJurorId, assignments])
|
||||||
|
|
||||||
|
const toggleProject = useCallback((projectId: string) => {
|
||||||
|
setSelectedProjectIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(projectId)) {
|
||||||
|
next.delete(projectId)
|
||||||
|
} else {
|
||||||
|
next.add(projectId)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectAllUnassigned = useCallback(() => {
|
||||||
|
const unassigned = filteredProjects
|
||||||
|
.filter((ps: any) => !jurorExistingProjectIds.has(ps.project?.id))
|
||||||
|
.map((ps: any) => ps.project?.id)
|
||||||
|
.filter(Boolean)
|
||||||
|
setSelectedProjectIds(new Set(unassigned))
|
||||||
|
}, [filteredProjects, jurorExistingProjectIds])
|
||||||
|
|
||||||
|
const handleCreate = useCallback(() => {
|
||||||
|
if (!selectedJurorId || selectedProjectIds.size === 0) return
|
||||||
|
|
||||||
|
const projectIds = Array.from(selectedProjectIds)
|
||||||
|
if (projectIds.length === 1) {
|
||||||
|
createMutation.mutate({
|
||||||
|
userId: selectedJurorId,
|
||||||
|
projectId: projectIds[0],
|
||||||
|
roundId,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
bulkCreateMutation.mutate({
|
||||||
|
roundId,
|
||||||
|
assignments: projectIds.map(projectId => ({
|
||||||
|
userId: selectedJurorId,
|
||||||
|
projectId,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [selectedJurorId, selectedProjectIds, roundId, createMutation, bulkCreateMutation])
|
||||||
|
|
||||||
|
const isMutating = createMutation.isPending || bulkCreateMutation.isPending
|
||||||
|
|
||||||
|
// ── By Project mode helpers ──
|
||||||
|
|
||||||
|
// Existing assignments for the selected project (to grey out already-assigned jurors)
|
||||||
|
const projectExistingJurorIds = useMemo(() => {
|
||||||
|
if (!selectedProjectId || !assignments) return new Set<string>()
|
||||||
|
return new Set(
|
||||||
|
assignments
|
||||||
|
.filter((a: any) => a.projectId === selectedProjectId)
|
||||||
|
.map((a: any) => a.userId)
|
||||||
|
)
|
||||||
|
}, [selectedProjectId, assignments])
|
||||||
|
|
||||||
|
// Count assignments per juror in this round (for display)
|
||||||
|
const jurorAssignmentCounts = useMemo(() => {
|
||||||
|
if (!assignments) return new Map<string, number>()
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const a of assignments) {
|
||||||
|
counts.set(a.userId, (counts.get(a.userId) || 0) + 1)
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}, [assignments])
|
||||||
|
|
||||||
|
// Filter jurors by search term
|
||||||
|
const filteredJurors = useMemo(() => {
|
||||||
|
const items = juryMembers ?? []
|
||||||
|
if (!jurorSearch) return items
|
||||||
|
const q = jurorSearch.toLowerCase()
|
||||||
|
return items.filter((j: any) =>
|
||||||
|
j.name?.toLowerCase().includes(q) ||
|
||||||
|
j.email?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}, [juryMembers, jurorSearch])
|
||||||
|
|
||||||
|
const toggleJuror = useCallback((jurorId: string) => {
|
||||||
|
setSelectedJurorIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(jurorId)) next.delete(jurorId)
|
||||||
|
else next.add(jurorId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCreateByProject = useCallback(() => {
|
||||||
|
if (!selectedProjectId || selectedJurorIds.size === 0) return
|
||||||
|
|
||||||
|
const jurorIds = Array.from(selectedJurorIds)
|
||||||
|
if (jurorIds.length === 1) {
|
||||||
|
createMutation.mutate({
|
||||||
|
userId: jurorIds[0],
|
||||||
|
projectId: selectedProjectId,
|
||||||
|
roundId,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
bulkCreateMutation.mutate({
|
||||||
|
roundId,
|
||||||
|
assignments: jurorIds.map(userId => ({
|
||||||
|
userId,
|
||||||
|
projectId: selectedProjectId,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [selectedProjectId, selectedJurorIds, roundId, createMutation, bulkCreateMutation])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{assignments?.length ?? 0} individual assignments</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1.5" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : !assignments || assignments.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-6">
|
||||||
|
No assignments yet. Generate assignments or add one manually.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 max-h-[500px] overflow-y-auto">
|
||||||
|
<div className="grid grid-cols-[1fr_1fr_100px_70px] gap-2 text-xs text-muted-foreground font-medium px-3 py-2 sticky top-0 bg-background border-b">
|
||||||
|
<span>Juror</span>
|
||||||
|
<span>Project</span>
|
||||||
|
<span>Status</span>
|
||||||
|
<span>Actions</span>
|
||||||
|
</div>
|
||||||
|
{assignments.map((a: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
className={cn(
|
||||||
|
'grid grid-cols-[1fr_1fr_100px_70px] gap-2 items-center px-3 py-2 rounded-md text-sm transition-colors',
|
||||||
|
idx % 2 === 1 ? 'bg-muted/20' : 'hover:bg-muted/20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{a.user?.name || a.user?.email || 'Unknown'}</span>
|
||||||
|
<span className="truncate text-muted-foreground">{a.project?.title || 'Unknown'}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{a.conflictOfInterest?.hasConflict ? (
|
||||||
|
<Badge variant="outline" className="text-[10px] justify-center bg-red-50 text-red-700 border-red-200">
|
||||||
|
COI
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] justify-center',
|
||||||
|
a.evaluation?.status === 'SUBMITTED'
|
||||||
|
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||||
|
: a.evaluation?.status === 'DRAFT'
|
||||||
|
? 'bg-blue-50 text-blue-700 border-blue-200'
|
||||||
|
: 'bg-gray-50 text-gray-600 border-gray-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{a.evaluation?.status || 'PENDING'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{a.conflictOfInterest?.hasConflict && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => reassignCOIMutation.mutate({ assignmentId: a.id })}
|
||||||
|
disabled={reassignCOIMutation.isPending}
|
||||||
|
>
|
||||||
|
<UserPlus className="h-3.5 w-3.5 mr-2 text-blue-600" />
|
||||||
|
Reassign (COI)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{a.evaluation && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setConfirmAction({ type: 'reset', assignment: a })}
|
||||||
|
disabled={resetEvalMutation.isPending}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Reset Evaluation
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => setConfirmAction({ type: 'delete', assignment: a })}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Delete Assignment
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Assignment Dialog */}
|
||||||
|
<Dialog open={addDialogOpen} onOpenChange={(open) => {
|
||||||
|
if (!open) resetDialog()
|
||||||
|
else setAddDialogOpen(true)
|
||||||
|
}}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Assignment</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{assignMode === 'byJuror'
|
||||||
|
? 'Select a juror, then choose projects to assign'
|
||||||
|
: 'Select a project, then choose jurors to assign'
|
||||||
|
}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
<Tabs value={assignMode} onValueChange={(v) => {
|
||||||
|
setAssignMode(v as 'byJuror' | 'byProject')
|
||||||
|
// Reset selections when switching
|
||||||
|
setSelectedJurorId('')
|
||||||
|
setSelectedProjectIds(new Set())
|
||||||
|
setProjectSearch('')
|
||||||
|
setSelectedProjectId('')
|
||||||
|
setSelectedJurorIds(new Set())
|
||||||
|
setJurorSearch('')
|
||||||
|
}}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="byJuror">By Juror</TabsTrigger>
|
||||||
|
<TabsTrigger value="byProject">By Project</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ── By Juror Tab ── */}
|
||||||
|
<TabsContent value="byJuror" className="space-y-4 mt-4">
|
||||||
|
{/* Juror Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Juror</Label>
|
||||||
|
<Popover open={jurorPopoverOpen} onOpenChange={setJurorPopoverOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={jurorPopoverOpen}
|
||||||
|
className="w-full justify-between font-normal"
|
||||||
|
>
|
||||||
|
{selectedJuror
|
||||||
|
? (
|
||||||
|
<span className="flex items-center gap-2 truncate">
|
||||||
|
<span className="truncate">{selectedJuror.name || selectedJuror.email}</span>
|
||||||
|
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||||
|
{selectedJuror.currentAssignments}/{selectedJuror.maxAssignments ?? '\u221E'}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: <span className="text-muted-foreground">Select a jury member...</span>
|
||||||
|
}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search by name or email..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No jury members found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{juryMembers?.map((juror: any) => {
|
||||||
|
const atCapacity = juror.maxAssignments !== null && juror.availableSlots === 0
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={juror.id}
|
||||||
|
value={`${juror.name ?? ''} ${juror.email}`}
|
||||||
|
disabled={atCapacity}
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedJurorId(juror.id === selectedJurorId ? '' : juror.id)
|
||||||
|
setSelectedProjectIds(new Set())
|
||||||
|
setJurorPopoverOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'mr-2 h-4 w-4',
|
||||||
|
selectedJurorId === juror.id ? 'opacity-100' : 'opacity-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-1 items-center justify-between min-w-0">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">
|
||||||
|
{juror.name || 'Unnamed'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{juror.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={atCapacity ? 'destructive' : 'secondary'}
|
||||||
|
className="text-[10px] ml-2 shrink-0"
|
||||||
|
>
|
||||||
|
{juror.currentAssignments}/{juror.maxAssignments ?? '\u221E'}
|
||||||
|
{atCapacity ? ' full' : ''}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Multi-Select */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
Projects
|
||||||
|
{selectedProjectIds.size > 0 && (
|
||||||
|
<span className="ml-1.5 text-muted-foreground font-normal">
|
||||||
|
({selectedProjectIds.size} selected)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
{selectedJurorId && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={selectAllUnassigned}
|
||||||
|
>
|
||||||
|
Select all
|
||||||
|
</Button>
|
||||||
|
{selectedProjectIds.size > 0 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => setSelectedProjectIds(new Set())}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Filter projects..."
|
||||||
|
value={projectSearch}
|
||||||
|
onChange={(e) => setProjectSearch(e.target.value)}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project checklist */}
|
||||||
|
<ScrollArea className="h-[320px] rounded-md border">
|
||||||
|
<div className="p-2 space-y-0.5">
|
||||||
|
{!selectedJurorId ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
Select a juror first
|
||||||
|
</p>
|
||||||
|
) : filteredProjects.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
No projects found
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filteredProjects.map((ps: any) => {
|
||||||
|
const project = ps.project
|
||||||
|
if (!project) return null
|
||||||
|
const alreadyAssigned = jurorExistingProjectIds.has(project.id)
|
||||||
|
const isSelected = selectedProjectIds.has(project.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={project.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors',
|
||||||
|
alreadyAssigned
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: isSelected
|
||||||
|
? 'bg-accent'
|
||||||
|
: 'hover:bg-muted/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
disabled={alreadyAssigned}
|
||||||
|
onCheckedChange={() => toggleProject(project.id)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-1 items-center justify-between min-w-0">
|
||||||
|
<span className="truncate">{project.title}</span>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||||||
|
{project.competitionCategory && (
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{project.competitionCategory === 'STARTUP'
|
||||||
|
? 'Startup'
|
||||||
|
: project.competitionCategory === 'BUSINESS_CONCEPT'
|
||||||
|
? 'Concept'
|
||||||
|
: project.competitionCategory}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{alreadyAssigned && (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
Assigned
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={resetDialog}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!selectedJurorId || selectedProjectIds.size === 0 || isMutating}
|
||||||
|
>
|
||||||
|
{isMutating && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
|
{selectedProjectIds.size <= 1
|
||||||
|
? 'Create Assignment'
|
||||||
|
: `Create ${selectedProjectIds.size} Assignments`
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── By Project Tab ── */}
|
||||||
|
<TabsContent value="byProject" className="space-y-4 mt-4">
|
||||||
|
{/* Project Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Project</Label>
|
||||||
|
<Popover open={projectPopoverOpen} onOpenChange={setProjectPopoverOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={projectPopoverOpen}
|
||||||
|
className="w-full justify-between font-normal"
|
||||||
|
>
|
||||||
|
{selectedProjectId
|
||||||
|
? (
|
||||||
|
<span className="truncate">
|
||||||
|
{(projectStates ?? []).find((ps: any) => ps.project?.id === selectedProjectId)?.project?.title || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: <span className="text-muted-foreground">Select a project...</span>
|
||||||
|
}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search by project title..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No projects found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{(projectStates ?? []).map((ps: any) => {
|
||||||
|
const project = ps.project
|
||||||
|
if (!project) return null
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={project.id}
|
||||||
|
value={`${project.title ?? ''} ${project.teamName ?? ''}`}
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedProjectId(project.id === selectedProjectId ? '' : project.id)
|
||||||
|
setSelectedJurorIds(new Set())
|
||||||
|
setProjectPopoverOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'mr-2 h-4 w-4',
|
||||||
|
selectedProjectId === project.id ? 'opacity-100' : 'opacity-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-1 items-center justify-between min-w-0">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{project.teamName}</p>
|
||||||
|
</div>
|
||||||
|
{project.competitionCategory && (
|
||||||
|
<Badge variant="outline" className="text-[10px] ml-2 shrink-0">
|
||||||
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Concept'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Juror Multi-Select */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
Jurors
|
||||||
|
{selectedJurorIds.size > 0 && (
|
||||||
|
<span className="ml-1.5 text-muted-foreground font-normal">
|
||||||
|
({selectedJurorIds.size} selected)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
{selectedProjectId && selectedJurorIds.size > 0 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => setSelectedJurorIds(new Set())}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Filter jurors..."
|
||||||
|
value={jurorSearch}
|
||||||
|
onChange={(e) => setJurorSearch(e.target.value)}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Juror checklist */}
|
||||||
|
<ScrollArea className="h-[320px] rounded-md border">
|
||||||
|
<div className="p-2 space-y-0.5">
|
||||||
|
{!selectedProjectId ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
Select a project first
|
||||||
|
</p>
|
||||||
|
) : filteredJurors.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
No jurors found
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filteredJurors.map((juror: any) => {
|
||||||
|
const alreadyAssigned = projectExistingJurorIds.has(juror.id)
|
||||||
|
const isSelected = selectedJurorIds.has(juror.id)
|
||||||
|
const assignCount = jurorAssignmentCounts.get(juror.id) ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={juror.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors',
|
||||||
|
alreadyAssigned
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: isSelected
|
||||||
|
? 'bg-accent'
|
||||||
|
: 'hover:bg-muted/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
disabled={alreadyAssigned}
|
||||||
|
onCheckedChange={() => toggleJuror(juror.id)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-1 items-center justify-between min-w-0">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="font-medium truncate block">{juror.name || 'Unnamed'}</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate block">{juror.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{assignCount} assigned
|
||||||
|
</Badge>
|
||||||
|
{alreadyAssigned && (
|
||||||
|
<Badge variant="outline" className="text-[10px] bg-amber-50 text-amber-700 border-amber-200">
|
||||||
|
Already on project
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={resetDialog}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateByProject}
|
||||||
|
disabled={!selectedProjectId || selectedJurorIds.size === 0 || isMutating}
|
||||||
|
>
|
||||||
|
{isMutating && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
|
{selectedJurorIds.size <= 1
|
||||||
|
? 'Create Assignment'
|
||||||
|
: `Create ${selectedJurorIds.size} Assignments`
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Confirmation AlertDialog for reset/delete */}
|
||||||
|
<AlertDialog open={!!confirmAction} onOpenChange={(open) => { if (!open) setConfirmAction(null) }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{confirmAction?.type === 'reset' ? 'Reset evaluation?' : 'Delete assignment?'}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{confirmAction?.type === 'reset'
|
||||||
|
? `Reset evaluation by ${confirmAction.assignment?.user?.name || confirmAction.assignment?.user?.email} for "${confirmAction.assignment?.project?.title}"? This will erase all scores and feedback so they can start over.`
|
||||||
|
: `Remove assignment for ${confirmAction?.assignment?.user?.name || confirmAction?.assignment?.user?.email} on "${confirmAction?.assignment?.project?.title}"?`
|
||||||
|
}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className={confirmAction?.type === 'delete' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirmAction?.type === 'reset') {
|
||||||
|
resetEvalMutation.mutate({ assignmentId: confirmAction.assignment.id })
|
||||||
|
} else if (confirmAction?.type === 'delete') {
|
||||||
|
deleteMutation.mutate({ id: confirmAction.assignment.id })
|
||||||
|
}
|
||||||
|
setConfirmAction(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmAction?.type === 'reset' ? 'Reset' : 'Delete'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
381
src/components/admin/assignment/jury-progress-table.tsx
Normal file
381
src/components/admin/assignment/jury-progress-table.tsx
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Loader2, Mail, ArrowRightLeft, UserPlus, Trash2 } from 'lucide-react'
|
||||||
|
import { TransferAssignmentsDialog } from './transfer-assignments-dialog'
|
||||||
|
import { InlineMemberCap } from '@/components/admin/jury/inline-member-cap'
|
||||||
|
|
||||||
|
export type JuryProgressTableMember = {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
maxAssignmentsOverride: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JuryProgressTableProps = {
|
||||||
|
roundId: string
|
||||||
|
members?: JuryProgressTableMember[]
|
||||||
|
onSaveCap?: (memberId: string, val: number | null) => void
|
||||||
|
onRemoveMember?: (memberId: string, memberName: string) => void
|
||||||
|
onAddMember?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JuryProgressTable({
|
||||||
|
roundId,
|
||||||
|
members,
|
||||||
|
onSaveCap,
|
||||||
|
onRemoveMember,
|
||||||
|
onAddMember,
|
||||||
|
}: JuryProgressTableProps) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const [transferJuror, setTransferJuror] = useState<{ id: string; name: string } | null>(null)
|
||||||
|
|
||||||
|
const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 15_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const notifyMutation = trpc.assignment.notifySingleJurorOfAssignments.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success(`Notified juror of ${data.projectCount} assignment(s)`)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const reshuffleMutation = trpc.assignment.reassignDroppedJuror.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
utils.assignment.listByStage.invalidate({ roundId })
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
utils.analytics.getJurorWorkload.invalidate({ roundId })
|
||||||
|
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
|
||||||
|
|
||||||
|
if (data.failedCount > 0) {
|
||||||
|
toast.warning(`Dropped juror and reassigned ${data.movedCount} project(s). ${data.failedCount} could not be reassigned (all remaining jurors at cap/blocked).`)
|
||||||
|
} else {
|
||||||
|
toast.success(`Dropped juror and reassigned ${data.movedCount} project(s) evenly across available jurors.`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasMembersData = members !== undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{hasMembersData ? 'Jury Members & Progress' : 'Jury Progress'}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{hasMembersData
|
||||||
|
? 'Manage jury members, caps, and evaluation progress per juror.'
|
||||||
|
: 'Evaluation completion per juror. Click the mail icon to notify an individual juror.'}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{onAddMember && (
|
||||||
|
<Button size="sm" onClick={onAddMember}>
|
||||||
|
<UserPlus className="h-4 w-4 mr-1.5" />
|
||||||
|
Add Member
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : !workload || workload.length === 0 ? (
|
||||||
|
hasMembersData && members && members.length > 0 ? (
|
||||||
|
// Show members-only view when there are members but no assignments yet
|
||||||
|
<div className="space-y-1">
|
||||||
|
{members.map((member, idx) => (
|
||||||
|
<MemberOnlyRow
|
||||||
|
key={member.id}
|
||||||
|
member={member}
|
||||||
|
idx={idx}
|
||||||
|
roundId={roundId}
|
||||||
|
onSaveCap={onSaveCap}
|
||||||
|
onRemoveMember={onRemoveMember}
|
||||||
|
notifyMutation={notifyMutation}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-6">
|
||||||
|
{hasMembersData ? 'No members yet. Add jury members to get started.' : 'No assignments yet'}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-[500px] overflow-y-auto overflow-x-hidden">
|
||||||
|
{workload.map((juror) => {
|
||||||
|
const pct = juror.completionRate
|
||||||
|
const barGradient = pct === 100
|
||||||
|
? 'bg-gradient-to-r from-emerald-400 to-emerald-600'
|
||||||
|
: pct >= 50
|
||||||
|
? 'bg-gradient-to-r from-blue-400 to-blue-600'
|
||||||
|
: pct > 0
|
||||||
|
? 'bg-gradient-to-r from-amber-400 to-amber-600'
|
||||||
|
: 'bg-gray-300'
|
||||||
|
|
||||||
|
// Find the corresponding member entry for cap editing
|
||||||
|
const member = members?.find((m) => m.userId === juror.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={juror.id} className="space-y-1 hover:bg-muted/20 rounded px-1 py-0.5 -mx-1 transition-colors group">
|
||||||
|
<div className="flex justify-between items-center text-xs">
|
||||||
|
<span className="font-medium truncate max-w-[140px]">{juror.name}</span>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{member && onSaveCap && (
|
||||||
|
<InlineMemberCap
|
||||||
|
memberId={member.id}
|
||||||
|
currentValue={member.maxAssignmentsOverride}
|
||||||
|
roundId={roundId}
|
||||||
|
jurorUserId={member.userId}
|
||||||
|
onSave={(val) => onSaveCap(member.id, val)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-muted-foreground tabular-nums">
|
||||||
|
{juror.completed}/{juror.assigned} ({pct}%)
|
||||||
|
</span>
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||||
|
disabled={notifyMutation.isPending}
|
||||||
|
onClick={() => notifyMutation.mutate({ roundId, userId: juror.id })}
|
||||||
|
>
|
||||||
|
{notifyMutation.isPending && notifyMutation.variables?.userId === juror.id ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Mail className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left"><p>Notify this juror of their assignments</p></TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setTransferJuror({ id: juror.id, name: juror.name })}
|
||||||
|
>
|
||||||
|
<ArrowRightLeft className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left"><p>Transfer assignments to other jurors</p></TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||||
|
disabled={reshuffleMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Remove ${juror.name} from this jury pool and reassign all their unsubmitted projects to other jurors within their caps? Submitted evaluations will be preserved. This cannot be undone.`
|
||||||
|
)
|
||||||
|
if (!ok) return
|
||||||
|
reshuffleMutation.mutate({ roundId, jurorId: juror.id })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reshuffleMutation.isPending && reshuffleMutation.variables?.jurorId === juror.id ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<UserPlus className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left"><p>Drop juror + reshuffle pending projects</p></TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
{member && onRemoveMember && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 text-destructive hover:text-destructive shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Remove member?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Remove {member.name} from this jury group?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => onRemoveMember(member.id, member.name)}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn('h-full rounded-full transition-all duration-500', barGradient)}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{transferJuror && (
|
||||||
|
<TransferAssignmentsDialog
|
||||||
|
roundId={roundId}
|
||||||
|
sourceJuror={transferJuror}
|
||||||
|
open={!!transferJuror}
|
||||||
|
onClose={() => setTransferJuror(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-component for member-only rows (no workload data yet)
|
||||||
|
function MemberOnlyRow({
|
||||||
|
member,
|
||||||
|
idx,
|
||||||
|
roundId,
|
||||||
|
onSaveCap,
|
||||||
|
onRemoveMember,
|
||||||
|
notifyMutation,
|
||||||
|
}: {
|
||||||
|
member: JuryProgressTableMember
|
||||||
|
idx: number
|
||||||
|
roundId: string
|
||||||
|
onSaveCap?: (memberId: string, val: number | null) => void
|
||||||
|
onRemoveMember?: (memberId: string, memberName: string) => void
|
||||||
|
notifyMutation: ReturnType<typeof trpc.assignment.notifySingleJurorOfAssignments.useMutation>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between py-2 px-2 rounded-md transition-colors text-xs',
|
||||||
|
idx % 2 === 1 && 'bg-muted/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium truncate">{member.name}</p>
|
||||||
|
<p className="text-muted-foreground truncate">{member.email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{onSaveCap && (
|
||||||
|
<InlineMemberCap
|
||||||
|
memberId={member.id}
|
||||||
|
currentValue={member.maxAssignmentsOverride}
|
||||||
|
roundId={roundId}
|
||||||
|
jurorUserId={member.userId}
|
||||||
|
onSave={(val) => onSaveCap(member.id, val)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||||
|
disabled={notifyMutation.isPending}
|
||||||
|
onClick={() => notifyMutation.mutate({ roundId, userId: member.userId })}
|
||||||
|
>
|
||||||
|
{notifyMutation.isPending && notifyMutation.variables?.userId === member.userId ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Mail className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left"><p>Notify juror of assignments</p></TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
{onRemoveMember && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-destructive hover:text-destructive shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Remove member?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Remove {member.name} from this jury group?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => onRemoveMember(member.id, member.name)}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
src/components/admin/assignment/notify-jurors-button.tsx
Normal file
61
src/components/admin/assignment/notify-jurors-button.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Mail, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
export type NotifyJurorsButtonProps = {
|
||||||
|
roundId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotifyJurorsButton({ roundId }: NotifyJurorsButtonProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const mutation = trpc.assignment.notifyJurorsOfAssignments.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success(`Notified ${data.jurorCount} juror(s) of their assignments`)
|
||||||
|
setOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||||
|
<Mail className="h-4 w-4 mr-1.5" />
|
||||||
|
Notify Jurors
|
||||||
|
</Button>
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Notify jurors of their assignments?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will send an email to every juror assigned to this round, reminding them of how many projects they need to evaluate.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => mutation.mutate({ roundId })}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
|
Notify Jurors
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
src/components/admin/assignment/reassignment-history.tsx
Normal file
101
src/components/admin/assignment/reassignment-history.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { History } from 'lucide-react'
|
||||||
|
|
||||||
|
export type ReassignmentHistoryProps = {
|
||||||
|
roundId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) {
|
||||||
|
const { data: events, isLoading } = trpc.assignment.getReassignmentHistory.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<History className="h-4 w-4" />
|
||||||
|
Reassignment History
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Juror dropout, COI, transfer, and cap redistribution audit trail</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : !events || events.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-6">
|
||||||
|
No reassignment events for this round
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 max-h-[500px] overflow-y-auto">
|
||||||
|
{events.map((event) => (
|
||||||
|
<div key={event.id} className="border rounded-lg p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={event.type === 'DROPOUT' ? 'destructive' : 'secondary'}>
|
||||||
|
{event.type === 'DROPOUT' ? 'Juror Dropout' : event.type === 'COI' ? 'COI Reassignment' : event.type === 'TRANSFER' ? 'Assignment Transfer' : 'Cap Redistribution'}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{event.droppedJuror.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(event.timestamp).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
By {event.performedBy.name || event.performedBy.email} — {event.movedCount} project(s) reassigned
|
||||||
|
{event.failedCount > 0 && `, ${event.failedCount} failed`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{event.moves.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-muted-foreground border-b">
|
||||||
|
<th className="text-left py-1 font-medium">Project</th>
|
||||||
|
<th className="text-left py-1 font-medium">Reassigned To</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{event.moves.map((move, i) => (
|
||||||
|
<tr key={i} className="border-b last:border-0">
|
||||||
|
<td className="py-1.5 pr-2 max-w-[250px] truncate">
|
||||||
|
{move.projectTitle}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 font-medium">
|
||||||
|
{move.newJurorName}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{event.failedProjects.length > 0 && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<p className="text-xs font-medium text-destructive">Could not reassign:</p>
|
||||||
|
<ul className="text-xs text-muted-foreground list-disc list-inside">
|
||||||
|
{event.failedProjects.map((p, i) => (
|
||||||
|
<li key={i}>{p}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
src/components/admin/assignment/round-unassigned-queue.tsx
Normal file
66
src/components/admin/assignment/round-unassigned-queue.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
|
export type RoundUnassignedQueueProps = {
|
||||||
|
roundId: string
|
||||||
|
requiredReviews?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: RoundUnassignedQueueProps) {
|
||||||
|
const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
|
||||||
|
{ roundId, requiredReviews },
|
||||||
|
{ refetchInterval: 15_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Unassigned Projects</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Projects with fewer than {requiredReviews} jury assignments</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : unassigned && unassigned.length > 0 ? (
|
||||||
|
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||||
|
{unassigned.map((project: any) => (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
className={cn(
|
||||||
|
'flex justify-between items-center p-3 border rounded-md hover:bg-muted/30 transition-colors',
|
||||||
|
(project.assignmentCount || 0) === 0 && 'border-l-4 border-l-red-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{project.competitionCategory || 'No category'}
|
||||||
|
{project.teamName && ` \u00b7 ${project.teamName}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className={cn(
|
||||||
|
'text-xs shrink-0 ml-3',
|
||||||
|
(project.assignmentCount || 0) === 0
|
||||||
|
? 'bg-red-50 text-red-700 border-red-200'
|
||||||
|
: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||||
|
)}>
|
||||||
|
{project.assignmentCount || 0} / {requiredReviews}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-6">
|
||||||
|
All projects have sufficient assignments
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
src/components/admin/assignment/send-reminders-button.tsx
Normal file
61
src/components/admin/assignment/send-reminders-button.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Send, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
export type SendRemindersButtonProps = {
|
||||||
|
roundId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SendRemindersButton({ roundId }: SendRemindersButtonProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const mutation = trpc.evaluation.triggerReminders.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success(`Sent ${data.sent} reminder(s)`)
|
||||||
|
setOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||||
|
<Send className="h-4 w-4 mr-1.5" />
|
||||||
|
Send Reminders
|
||||||
|
</Button>
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Send evaluation reminders?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will send reminder emails to all jurors who have incomplete evaluations for this round.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => mutation.mutate({ roundId })}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
|
Send Reminders
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
337
src/components/admin/assignment/transfer-assignments-dialog.tsx
Normal file
337
src/components/admin/assignment/transfer-assignments-dialog.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Loader2, Sparkles } from 'lucide-react'
|
||||||
|
|
||||||
|
export type TransferAssignmentsDialogProps = {
|
||||||
|
roundId: string
|
||||||
|
sourceJuror: { id: string; name: string }
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransferAssignmentsDialog({
|
||||||
|
roundId,
|
||||||
|
sourceJuror,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: TransferAssignmentsDialogProps) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const [step, setStep] = useState<1 | 2>(1)
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Fetch source juror's assignments
|
||||||
|
const { data: sourceAssignments, isLoading: loadingAssignments } = trpc.assignment.listByStage.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ enabled: open },
|
||||||
|
)
|
||||||
|
|
||||||
|
const jurorAssignments = useMemo(() =>
|
||||||
|
(sourceAssignments ?? []).filter((a: any) => a.userId === sourceJuror.id),
|
||||||
|
[sourceAssignments, sourceJuror.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fetch transfer candidates when in step 2
|
||||||
|
const { data: candidateData, isLoading: loadingCandidates } = trpc.assignment.getTransferCandidates.useQuery(
|
||||||
|
{ roundId, sourceJurorId: sourceJuror.id, assignmentIds: [...selectedIds] },
|
||||||
|
{ enabled: step === 2 && selectedIds.size > 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Per-assignment destination overrides
|
||||||
|
const [destOverrides, setDestOverrides] = useState<Record<string, string>>({})
|
||||||
|
const [forceOverCap, setForceOverCap] = useState(false)
|
||||||
|
|
||||||
|
// Auto-assign: distribute assignments across eligible candidates balanced by load
|
||||||
|
const handleAutoAssign = () => {
|
||||||
|
if (!candidateData) return
|
||||||
|
const movable = candidateData.assignments.filter((a) => a.movable)
|
||||||
|
if (movable.length === 0) return
|
||||||
|
|
||||||
|
// Simulate load starting from each candidate's current load
|
||||||
|
const simLoad = new Map<string, number>()
|
||||||
|
for (const c of candidateData.candidates) {
|
||||||
|
simLoad.set(c.userId, c.currentLoad)
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrides: Record<string, string> = {}
|
||||||
|
|
||||||
|
for (const assignment of movable) {
|
||||||
|
const eligible = candidateData.candidates
|
||||||
|
.filter((c) => c.eligibleProjectIds.includes(assignment.projectId))
|
||||||
|
|
||||||
|
if (eligible.length === 0) continue
|
||||||
|
|
||||||
|
// Sort: prefer not-all-completed, then under cap, then lowest simulated load
|
||||||
|
const sorted = [...eligible].sort((a, b) => {
|
||||||
|
// Prefer jurors who haven't completed all evaluations
|
||||||
|
if (a.allCompleted !== b.allCompleted) return a.allCompleted ? 1 : -1
|
||||||
|
const loadA = simLoad.get(a.userId) ?? 0
|
||||||
|
const loadB = simLoad.get(b.userId) ?? 0
|
||||||
|
// Prefer jurors under their cap
|
||||||
|
const overCapA = loadA >= a.cap ? 1 : 0
|
||||||
|
const overCapB = loadB >= b.cap ? 1 : 0
|
||||||
|
if (overCapA !== overCapB) return overCapA - overCapB
|
||||||
|
// Then pick the least loaded
|
||||||
|
return loadA - loadB
|
||||||
|
})
|
||||||
|
|
||||||
|
const best = sorted[0]
|
||||||
|
overrides[assignment.id] = best.userId
|
||||||
|
simLoad.set(best.userId, (simLoad.get(best.userId) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDestOverrides(overrides)
|
||||||
|
}
|
||||||
|
|
||||||
|
const transferMutation = trpc.assignment.transferAssignments.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
utils.assignment.listByStage.invalidate({ roundId })
|
||||||
|
utils.analytics.getJurorWorkload.invalidate({ roundId })
|
||||||
|
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
|
||||||
|
utils.assignment.getReassignmentHistory.invalidate({ roundId })
|
||||||
|
|
||||||
|
const successCount = data.succeeded.length
|
||||||
|
const failCount = data.failed.length
|
||||||
|
if (failCount > 0) {
|
||||||
|
toast.warning(`Transferred ${successCount} project(s). ${failCount} failed.`)
|
||||||
|
} else {
|
||||||
|
toast.success(`Transferred ${successCount} project(s) successfully.`)
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build the transfer plan: for each selected assignment, determine destination
|
||||||
|
const transferPlan = useMemo(() => {
|
||||||
|
if (!candidateData) return []
|
||||||
|
const movable = candidateData.assignments.filter((a) => a.movable)
|
||||||
|
return movable.map((assignment) => {
|
||||||
|
const override = destOverrides[assignment.id]
|
||||||
|
// Default: first eligible candidate
|
||||||
|
const defaultDest = candidateData.candidates.find((c) =>
|
||||||
|
c.eligibleProjectIds.includes(assignment.projectId)
|
||||||
|
)
|
||||||
|
const destId = override || defaultDest?.userId || ''
|
||||||
|
const destName = candidateData.candidates.find((c) => c.userId === destId)?.name || ''
|
||||||
|
return { assignmentId: assignment.id, projectTitle: assignment.projectTitle, destinationJurorId: destId, destName }
|
||||||
|
}).filter((t) => t.destinationJurorId)
|
||||||
|
}, [candidateData, destOverrides])
|
||||||
|
|
||||||
|
// Check if any destination is at or over cap
|
||||||
|
const anyOverCap = useMemo(() => {
|
||||||
|
if (!candidateData) return false
|
||||||
|
const destCounts = new Map<string, number>()
|
||||||
|
for (const t of transferPlan) {
|
||||||
|
destCounts.set(t.destinationJurorId, (destCounts.get(t.destinationJurorId) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
return candidateData.candidates.some((c) => {
|
||||||
|
const extraLoad = destCounts.get(c.userId) ?? 0
|
||||||
|
return c.currentLoad + extraLoad > c.cap
|
||||||
|
})
|
||||||
|
}, [candidateData, transferPlan])
|
||||||
|
|
||||||
|
const handleTransfer = () => {
|
||||||
|
transferMutation.mutate({
|
||||||
|
roundId,
|
||||||
|
sourceJurorId: sourceJuror.id,
|
||||||
|
transfers: transferPlan.map((t) => ({ assignmentId: t.assignmentId, destinationJurorId: t.destinationJurorId })),
|
||||||
|
forceOverCap,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMovable = (a: any) => {
|
||||||
|
const status = a.evaluation?.status
|
||||||
|
return !status || status === 'NOT_STARTED' || status === 'DRAFT'
|
||||||
|
}
|
||||||
|
|
||||||
|
const movableAssignments = jurorAssignments.filter(isMovable)
|
||||||
|
const allMovableSelected = movableAssignments.length > 0 && movableAssignments.every((a: any) => selectedIds.has(a.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Transfer Assignments from {sourceJuror.name}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{step === 1 ? 'Select projects to transfer to other jurors.' : 'Choose destination jurors for each project.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{loadingAssignments ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : jurorAssignments.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-6">No assignments found.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 pb-2 border-b">
|
||||||
|
<Checkbox
|
||||||
|
checked={allMovableSelected}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedIds(new Set(movableAssignments.map((a: any) => a.id)))
|
||||||
|
} else {
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">Select all movable ({movableAssignments.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
||||||
|
{jurorAssignments.map((a: any) => {
|
||||||
|
const movable = isMovable(a)
|
||||||
|
const status = a.evaluation?.status || 'No evaluation'
|
||||||
|
return (
|
||||||
|
<div key={a.id} className={cn('flex items-center gap-3 py-2 px-2 rounded-md', !movable && 'opacity-50')}>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(a.id)}
|
||||||
|
disabled={!movable}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const next = new Set(selectedIds)
|
||||||
|
if (checked) next.add(a.id)
|
||||||
|
else next.delete(a.id)
|
||||||
|
setSelectedIds(next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm truncate">{a.project?.title || 'Unknown'}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-xs shrink-0">
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
onClick={() => { setStep(2); setDestOverrides({}) }}
|
||||||
|
>
|
||||||
|
Next ({selectedIds.size} selected)
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{loadingCandidates ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : !candidateData || candidateData.candidates.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-6">No eligible candidates found.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAutoAssign}>
|
||||||
|
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Auto-assign
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-[350px] overflow-y-auto">
|
||||||
|
{candidateData.assignments.filter((a) => a.movable).map((assignment) => {
|
||||||
|
const currentDest = destOverrides[assignment.id] ||
|
||||||
|
candidateData.candidates.find((c) => c.eligibleProjectIds.includes(assignment.projectId))?.userId || ''
|
||||||
|
return (
|
||||||
|
<div key={assignment.id} className="flex items-center gap-3 py-2 px-2 border rounded-md">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm truncate">{assignment.projectTitle}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{assignment.evalStatus || 'No evaluation'}</p>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={currentDest}
|
||||||
|
onValueChange={(v) => setDestOverrides((prev) => ({ ...prev, [assignment.id]: v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[220px] h-8 text-xs">
|
||||||
|
<SelectValue placeholder="Select juror" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{candidateData.candidates.map((c) => {
|
||||||
|
const isEligible = c.eligibleProjectIds.includes(assignment.projectId)
|
||||||
|
const alreadyHas = c.alreadyAssignedProjectIds?.includes(assignment.projectId)
|
||||||
|
return (
|
||||||
|
<SelectItem
|
||||||
|
key={c.userId}
|
||||||
|
value={c.userId}
|
||||||
|
disabled={!isEligible}
|
||||||
|
className={cn(!isEligible && 'opacity-50')}
|
||||||
|
>
|
||||||
|
<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>}
|
||||||
|
{alreadyHas && <span className="text-amber-600 ml-1">Already assigned</span>}
|
||||||
|
{!isEligible && !alreadyHas && c.currentLoad >= c.cap && <span className="text-red-500 ml-1">At cap</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,24 +6,18 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { CheckCircle2, Circle, Clock } from 'lucide-react'
|
import {
|
||||||
|
roundTypeConfig as sharedRoundTypeConfig,
|
||||||
|
roundStatusConfig as sharedRoundStatusConfig,
|
||||||
|
} from '@/lib/round-config'
|
||||||
|
|
||||||
const roundTypeColors: Record<string, string> = {
|
const roundTypeColors: Record<string, string> = Object.fromEntries(
|
||||||
INTAKE: 'bg-gray-100 text-gray-700 border-gray-300',
|
Object.entries(sharedRoundTypeConfig).map(([k, v]) => [k, `${v.badgeClass} ${v.cardBorder}`])
|
||||||
FILTERING: 'bg-amber-100 text-amber-700 border-amber-300',
|
)
|
||||||
EVALUATION: 'bg-blue-100 text-blue-700 border-blue-300',
|
|
||||||
SUBMISSION: 'bg-purple-100 text-purple-700 border-purple-300',
|
|
||||||
MENTORING: 'bg-teal-100 text-teal-700 border-teal-300',
|
|
||||||
LIVE_FINAL: 'bg-red-100 text-red-700 border-red-300',
|
|
||||||
DELIBERATION: 'bg-indigo-100 text-indigo-700 border-indigo-300',
|
|
||||||
}
|
|
||||||
|
|
||||||
const roundStatusConfig: Record<string, { icon: typeof Circle; color: string }> = {
|
const roundStatusConfig: Record<string, { icon: React.ElementType; color: string }> = Object.fromEntries(
|
||||||
ROUND_DRAFT: { icon: Circle, color: 'text-gray-400' },
|
Object.entries(sharedRoundStatusConfig).map(([k, v]) => [k, { icon: v.timelineIcon, color: v.timelineIconColor }])
|
||||||
ROUND_ACTIVE: { icon: Clock, color: 'text-emerald-500' },
|
)
|
||||||
ROUND_CLOSED: { icon: CheckCircle2, color: 'text-blue-500' },
|
|
||||||
ROUND_ARCHIVED: { icon: CheckCircle2, color: 'text-gray-400' },
|
|
||||||
}
|
|
||||||
|
|
||||||
type RoundSummary = {
|
type RoundSummary = {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { AlertCircle, CheckCircle2 } from 'lucide-react'
|
import { AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||||
|
import { roundTypeConfig } from '@/lib/round-config'
|
||||||
|
|
||||||
type WizardRound = {
|
type WizardRound = {
|
||||||
tempId: string
|
tempId: string
|
||||||
@@ -40,15 +41,9 @@ type ReviewSectionProps = {
|
|||||||
state: WizardState
|
state: WizardState
|
||||||
}
|
}
|
||||||
|
|
||||||
const roundTypeColors: Record<string, string> = {
|
const roundTypeColors: Record<string, string> = Object.fromEntries(
|
||||||
INTAKE: 'bg-gray-100 text-gray-700',
|
Object.entries(roundTypeConfig).map(([k, v]) => [k, v.badgeClass])
|
||||||
FILTERING: 'bg-amber-100 text-amber-700',
|
)
|
||||||
EVALUATION: 'bg-blue-100 text-blue-700',
|
|
||||||
SUBMISSION: 'bg-purple-100 text-purple-700',
|
|
||||||
MENTORING: 'bg-teal-100 text-teal-700',
|
|
||||||
LIVE_FINAL: 'bg-red-100 text-red-700',
|
|
||||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReviewSection({ state }: ReviewSectionProps) {
|
export function ReviewSection({ state }: ReviewSectionProps) {
|
||||||
const warnings: string[] = []
|
const warnings: string[] = []
|
||||||
|
|||||||
164
src/components/admin/jury/inline-member-cap.tsx
Normal file
164
src/components/admin/jury/inline-member-cap.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Loader2, Pencil } from 'lucide-react'
|
||||||
|
|
||||||
|
export type InlineMemberCapProps = {
|
||||||
|
memberId: string
|
||||||
|
currentValue: number | null
|
||||||
|
onSave: (val: number | null) => void
|
||||||
|
roundId?: string
|
||||||
|
jurorUserId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineMemberCap({
|
||||||
|
memberId,
|
||||||
|
currentValue,
|
||||||
|
onSave,
|
||||||
|
roundId,
|
||||||
|
jurorUserId,
|
||||||
|
}: InlineMemberCapProps) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [value, setValue] = useState(currentValue?.toString() ?? '')
|
||||||
|
const [overCapInfo, setOverCapInfo] = useState<{ total: number; overCapCount: number; movableOverCap: number; immovableOverCap: number } | null>(null)
|
||||||
|
const [showBanner, setShowBanner] = useState(false)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const redistributeMutation = trpc.assignment.redistributeOverCap.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
utils.assignment.listByStage.invalidate()
|
||||||
|
utils.analytics.getJurorWorkload.invalidate()
|
||||||
|
utils.roundAssignment.unassignedQueue.invalidate()
|
||||||
|
setShowBanner(false)
|
||||||
|
setOverCapInfo(null)
|
||||||
|
if (data.failed > 0) {
|
||||||
|
toast.warning(`Redistributed ${data.redistributed} project(s). ${data.failed} could not be reassigned.`)
|
||||||
|
} else {
|
||||||
|
toast.success(`Redistributed ${data.redistributed} project(s) to other jurors.`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) inputRef.current?.focus()
|
||||||
|
}, [editing])
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
const newVal = trimmed === '' ? null : parseInt(trimmed, 10)
|
||||||
|
if (newVal !== null && (isNaN(newVal) || newVal < 1)) {
|
||||||
|
toast.error('Enter a positive number or leave empty for no cap')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newVal === currentValue) {
|
||||||
|
setEditing(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check over-cap impact before saving
|
||||||
|
if (newVal !== null && roundId && jurorUserId) {
|
||||||
|
try {
|
||||||
|
const preview = await utils.client.assignment.getOverCapPreview.query({
|
||||||
|
roundId,
|
||||||
|
jurorId: jurorUserId,
|
||||||
|
newCap: newVal,
|
||||||
|
})
|
||||||
|
if (preview.overCapCount > 0) {
|
||||||
|
setOverCapInfo(preview)
|
||||||
|
setShowBanner(true)
|
||||||
|
setEditing(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If preview fails, just save the cap normally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave(newVal)
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRedistribute = () => {
|
||||||
|
const newVal = parseInt(value.trim(), 10)
|
||||||
|
onSave(newVal)
|
||||||
|
if (roundId && jurorUserId) {
|
||||||
|
redistributeMutation.mutate({ roundId, jurorId: jurorUserId, newCap: newVal })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleJustSave = () => {
|
||||||
|
const newVal = value.trim() === '' ? null : parseInt(value.trim(), 10)
|
||||||
|
onSave(newVal)
|
||||||
|
setShowBanner(false)
|
||||||
|
setOverCapInfo(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBanner && overCapInfo) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800">
|
||||||
|
<p>New cap of <strong>{value}</strong> is below current load (<strong>{overCapInfo.total}</strong> assignments). <strong>{overCapInfo.movableOverCap}</strong> can be redistributed.</p>
|
||||||
|
{overCapInfo.immovableOverCap > 0 && (
|
||||||
|
<p className="text-amber-600 mt-0.5">{overCapInfo.immovableOverCap} submitted evaluation(s) cannot be moved.</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1.5 mt-1.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
className="h-6 text-xs px-2"
|
||||||
|
disabled={redistributeMutation.isPending || overCapInfo.movableOverCap === 0}
|
||||||
|
onClick={handleRedistribute}
|
||||||
|
>
|
||||||
|
{redistributeMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
||||||
|
Redistribute
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" className="h-6 text-xs px-2" onClick={handleJustSave}>
|
||||||
|
Just save cap
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-6 text-xs px-2" onClick={() => { setShowBanner(false); setOverCapInfo(null) }}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="h-6 w-16 text-xs"
|
||||||
|
value={value}
|
||||||
|
placeholder="\u221E"
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onBlur={save}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') save()
|
||||||
|
if (e.key === 'Escape') { setValue(currentValue?.toString() ?? ''); setEditing(false) }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs hover:bg-muted transition-colors group"
|
||||||
|
title="Click to set max assignment cap"
|
||||||
|
onClick={() => { setValue(currentValue?.toString() ?? ''); setEditing(true) }}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">max:</span>
|
||||||
|
<span className="font-medium">{currentValue ?? '\u221E'}</span>
|
||||||
|
<Pencil className="h-2.5 w-2.5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -364,9 +364,16 @@ export function MembersContent() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={roleColors[user.role] || 'secondary'}>
|
<div className="flex flex-wrap gap-1">
|
||||||
{user.role.replace(/_/g, ' ')}
|
{((user as unknown as { roles?: string[] }).roles?.length
|
||||||
</Badge>
|
? (user as unknown as { roles: string[] }).roles
|
||||||
|
: [user.role]
|
||||||
|
).map((r) => (
|
||||||
|
<Badge key={r} variant={roleColors[r] || 'secondary'}>
|
||||||
|
{r.replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{user.expertiseTags && user.expertiseTags.length > 0 ? (
|
{user.expertiseTags && user.expertiseTags.length > 0 ? (
|
||||||
@@ -469,9 +476,16 @@ export function MembersContent() {
|
|||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Role</span>
|
<span className="text-muted-foreground">Role</span>
|
||||||
<Badge variant={roleColors[user.role] || 'secondary'}>
|
<div className="flex flex-wrap gap-1 justify-end">
|
||||||
{user.role.replace(/_/g, ' ')}
|
{((user as unknown as { roles?: string[] }).roles?.length
|
||||||
</Badge>
|
? (user as unknown as { roles: string[] }).roles
|
||||||
|
: [user.role]
|
||||||
|
).map((r) => (
|
||||||
|
<Badge key={r} variant={roleColors[r] || 'secondary'}>
|
||||||
|
{r.replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Assignments</span>
|
<span className="text-muted-foreground">Assignments</span>
|
||||||
|
|||||||
165
src/components/admin/program/competition-settings.tsx
Normal file
165
src/components/admin/program/competition-settings.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Loader2, Save, Settings } from 'lucide-react'
|
||||||
|
|
||||||
|
type CompetitionSettingsProps = {
|
||||||
|
competitionId: string
|
||||||
|
initialSettings: {
|
||||||
|
categoryMode: string
|
||||||
|
startupFinalistCount: number
|
||||||
|
conceptFinalistCount: number
|
||||||
|
notifyOnRoundAdvance: boolean
|
||||||
|
notifyOnDeadlineApproach: boolean
|
||||||
|
deadlineReminderDays: number[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompetitionSettings({ competitionId, initialSettings }: CompetitionSettingsProps) {
|
||||||
|
const [settings, setSettings] = useState(initialSettings)
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
|
||||||
|
const updateMutation = trpc.competition.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Competition settings saved')
|
||||||
|
setDirty(false)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
function update<K extends keyof typeof settings>(key: K, value: (typeof settings)[K]) {
|
||||||
|
setSettings((prev) => ({ ...prev, [key]: value }))
|
||||||
|
setDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
updateMutation.mutate({ id: competitionId, ...settings })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Competition Settings
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Category mode, finalist targets, and notification preferences
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{dirty && (
|
||||||
|
<Button onClick={handleSave} disabled={updateMutation.isPending} size="sm">
|
||||||
|
{updateMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Category Mode</Label>
|
||||||
|
<Select value={settings.categoryMode} onValueChange={(v) => update('categoryMode', v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="SHARED">Shared Pool</SelectItem>
|
||||||
|
<SelectItem value="SEPARATE">Separate Tracks</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Startup Finalist Count</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={settings.startupFinalistCount}
|
||||||
|
onChange={(e) => update('startupFinalistCount', parseInt(e.target.value) || 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Concept Finalist Count</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={settings.conceptFinalistCount}
|
||||||
|
onChange={(e) => update('conceptFinalistCount', parseInt(e.target.value) || 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium">Notifications</h4>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Notify on Round Advance</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">Email applicants when their project advances</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifyOnRoundAdvance}
|
||||||
|
onCheckedChange={(v) => update('notifyOnRoundAdvance', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Notify on Deadline Approach</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">Send reminders before deadlines</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifyOnDeadlineApproach}
|
||||||
|
onCheckedChange={(v) => update('notifyOnDeadlineApproach', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Reminder Days Before Deadline</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{settings.deadlineReminderDays.map((day, idx) => (
|
||||||
|
<Badge key={idx} variant="secondary" className="gap-1">
|
||||||
|
{day}d
|
||||||
|
<button
|
||||||
|
className="ml-1 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
const next = settings.deadlineReminderDays.filter((_, i) => i !== idx)
|
||||||
|
update('deadlineReminderDays', next)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="Add..."
|
||||||
|
className="w-20 h-7 text-xs"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const val = parseInt((e.target as HTMLInputElement).value)
|
||||||
|
if (val > 0 && !settings.deadlineReminderDays.includes(val)) {
|
||||||
|
update('deadlineReminderDays', [...settings.deadlineReminderDays, val].sort((a, b) => b - a))
|
||||||
|
;(e.target as HTMLInputElement).value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
289
src/components/admin/round/advance-projects-dialog.tsx
Normal file
289
src/components/admin/round/advance-projects-dialog.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
export type AdvanceProjectsDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
roundId: string
|
||||||
|
roundType?: string
|
||||||
|
projectStates: any[] | undefined
|
||||||
|
config: Record<string, unknown>
|
||||||
|
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string; autoPassPending?: boolean }) => void; isPending: boolean }
|
||||||
|
competitionRounds?: Array<{ id: string; name: string; sortOrder: number; roundType: string }>
|
||||||
|
currentSortOrder?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdvanceProjectsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
roundId,
|
||||||
|
roundType,
|
||||||
|
projectStates,
|
||||||
|
config,
|
||||||
|
advanceMutation,
|
||||||
|
competitionRounds,
|
||||||
|
currentSortOrder,
|
||||||
|
}: AdvanceProjectsDialogProps) {
|
||||||
|
// For non-jury rounds (INTAKE, SUBMISSION, MENTORING), offer a simpler "advance all" flow
|
||||||
|
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(roundType ?? '')
|
||||||
|
// Target round selector
|
||||||
|
const availableTargets = useMemo(() =>
|
||||||
|
(competitionRounds ?? [])
|
||||||
|
.filter((r) => r.sortOrder > (currentSortOrder ?? -1) && r.id !== roundId)
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder),
|
||||||
|
[competitionRounds, currentSortOrder, roundId])
|
||||||
|
|
||||||
|
const [targetRoundId, setTargetRoundId] = useState<string>('')
|
||||||
|
|
||||||
|
// Default to first available target when dialog opens
|
||||||
|
if (open && !targetRoundId && availableTargets.length > 0) {
|
||||||
|
setTargetRoundId(availableTargets[0].id)
|
||||||
|
}
|
||||||
|
const allProjects = projectStates ?? []
|
||||||
|
const pendingCount = allProjects.filter((ps: any) => ps.state === 'PENDING').length
|
||||||
|
const passedProjects = useMemo(() =>
|
||||||
|
allProjects.filter((ps: any) => ps.state === 'PASSED'),
|
||||||
|
[allProjects])
|
||||||
|
|
||||||
|
const startups = useMemo(() =>
|
||||||
|
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'),
|
||||||
|
[passedProjects])
|
||||||
|
|
||||||
|
const concepts = useMemo(() =>
|
||||||
|
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'BUSINESS_CONCEPT'),
|
||||||
|
[passedProjects])
|
||||||
|
|
||||||
|
const other = useMemo(() =>
|
||||||
|
passedProjects.filter((ps: any) =>
|
||||||
|
ps.project?.competitionCategory !== 'STARTUP' && ps.project?.competitionCategory !== 'BUSINESS_CONCEPT',
|
||||||
|
),
|
||||||
|
[passedProjects])
|
||||||
|
|
||||||
|
const startupCap = (config.startupAdvanceCount as number) || 0
|
||||||
|
const conceptCap = (config.conceptAdvanceCount as number) || 0
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Reset selection when dialog opens
|
||||||
|
if (open && selected.size === 0 && passedProjects.length > 0) {
|
||||||
|
const initial = new Set<string>()
|
||||||
|
// Auto-select all (or up to cap if configured)
|
||||||
|
const startupSlice = startupCap > 0 ? startups.slice(0, startupCap) : startups
|
||||||
|
const conceptSlice = conceptCap > 0 ? concepts.slice(0, conceptCap) : concepts
|
||||||
|
for (const ps of startupSlice) initial.add(ps.project?.id)
|
||||||
|
for (const ps of conceptSlice) initial.add(ps.project?.id)
|
||||||
|
for (const ps of other) initial.add(ps.project?.id)
|
||||||
|
setSelected(initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleProject = (projectId: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(projectId)) next.delete(projectId)
|
||||||
|
else next.add(projectId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAll = (projects: any[], on: boolean) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
for (const ps of projects) {
|
||||||
|
if (on) next.add(ps.project?.id)
|
||||||
|
else next.delete(ps.project?.id)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdvance = (autoPass?: boolean) => {
|
||||||
|
if (autoPass) {
|
||||||
|
// Auto-pass all pending then advance all
|
||||||
|
advanceMutation.mutate({
|
||||||
|
roundId,
|
||||||
|
autoPassPending: true,
|
||||||
|
...(targetRoundId ? { targetRoundId } : {}),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const ids = Array.from(selected)
|
||||||
|
if (ids.length === 0) return
|
||||||
|
advanceMutation.mutate({
|
||||||
|
roundId,
|
||||||
|
projectIds: ids,
|
||||||
|
...(targetRoundId ? { targetRoundId } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onOpenChange(false)
|
||||||
|
setSelected(new Set())
|
||||||
|
setTargetRoundId('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onOpenChange(false)
|
||||||
|
setSelected(new Set())
|
||||||
|
setTargetRoundId('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCategorySection = (
|
||||||
|
label: string,
|
||||||
|
projects: any[],
|
||||||
|
cap: number,
|
||||||
|
badgeColor: string,
|
||||||
|
) => {
|
||||||
|
const selectedInCategory = projects.filter((ps: any) => selected.has(ps.project?.id)).length
|
||||||
|
const overCap = cap > 0 && selectedInCategory > cap
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={projects.length > 0 && projects.every((ps: any) => selected.has(ps.project?.id))}
|
||||||
|
onCheckedChange={(checked) => toggleAll(projects, !!checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
<Badge variant="secondary" className={cn('text-[10px]', badgeColor)}>
|
||||||
|
{selectedInCategory}/{projects.length}
|
||||||
|
</Badge>
|
||||||
|
{cap > 0 && (
|
||||||
|
<span className={cn('text-[10px]', overCap ? 'text-red-500 font-medium' : 'text-muted-foreground')}>
|
||||||
|
(target: {cap})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground pl-7">No passed projects in this category</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 pl-7">
|
||||||
|
{projects.map((ps: any) => (
|
||||||
|
<label
|
||||||
|
key={ps.project?.id}
|
||||||
|
className="flex items-center gap-2 p-2 rounded hover:bg-muted/30 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.has(ps.project?.id)}
|
||||||
|
onCheckedChange={() => toggleProject(ps.project?.id)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm truncate flex-1">{ps.project?.title || 'Untitled'}</span>
|
||||||
|
{ps.project?.teamName && (
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">{ps.project.teamName}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalProjectCount = allProjects.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Advance Projects</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isSimpleAdvance
|
||||||
|
? `Move all ${totalProjectCount} projects to the next round.`
|
||||||
|
: `Select which passed projects to advance. ${selected.size} of ${passedProjects.length} selected.`
|
||||||
|
}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Target round selector */}
|
||||||
|
{availableTargets.length > 0 && (
|
||||||
|
<div className="space-y-2 pb-2 border-b">
|
||||||
|
<Label className="text-sm">Advance to</Label>
|
||||||
|
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select target round" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableTargets.map((r) => (
|
||||||
|
<SelectItem key={r.id} value={r.id}>
|
||||||
|
{r.name} ({r.roundType.replace('_', ' ').toLowerCase()})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{availableTargets.length === 0 && (
|
||||||
|
<div className="text-sm text-amber-600 bg-amber-50 rounded-md p-3">
|
||||||
|
No subsequent rounds found. Projects will advance to the next round by sort order.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSimpleAdvance ? (
|
||||||
|
/* Simple mode for INTAKE/SUBMISSION/MENTORING — no per-project selection needed */
|
||||||
|
<div className="py-4 space-y-3">
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-4 text-center space-y-1">
|
||||||
|
<p className="text-3xl font-bold">{totalProjectCount}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">projects will be advanced</p>
|
||||||
|
</div>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2">
|
||||||
|
<p className="text-xs text-blue-700">
|
||||||
|
{pendingCount} pending project{pendingCount !== 1 ? 's' : ''} will be automatically marked as passed and advanced.
|
||||||
|
{passedProjects.length > 0 && ` ${passedProjects.length} already passed.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Detailed mode for jury/evaluation rounds — per-project selection */
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4 py-2">
|
||||||
|
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
|
||||||
|
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')}
|
||||||
|
{other.length > 0 && renderCategorySection('Other / Uncategorized', other, 0, 'bg-gray-100 text-gray-700')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleClose}>Cancel</Button>
|
||||||
|
{isSimpleAdvance ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleAdvance(true)}
|
||||||
|
disabled={totalProjectCount === 0 || advanceMutation.isPending || availableTargets.length === 0}
|
||||||
|
>
|
||||||
|
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
|
Advance All {totalProjectCount} Project{totalProjectCount !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleAdvance()}
|
||||||
|
disabled={selected.size === 0 || advanceMutation.isPending || availableTargets.length === 0}
|
||||||
|
>
|
||||||
|
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
|
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
231
src/components/admin/round/ai-recommendations-display.tsx
Normal file
231
src/components/admin/round/ai-recommendations-display.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Loader2, ChevronDown, CheckCircle2, X } from 'lucide-react'
|
||||||
|
|
||||||
|
export type RecommendationItem = {
|
||||||
|
projectId: string
|
||||||
|
rank: number
|
||||||
|
score: number
|
||||||
|
category: string
|
||||||
|
strengths: string[]
|
||||||
|
concerns: string[]
|
||||||
|
recommendation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AIRecommendationsDisplayProps = {
|
||||||
|
recommendations: { STARTUP: RecommendationItem[]; BUSINESS_CONCEPT: RecommendationItem[] }
|
||||||
|
projectStates: any[] | undefined
|
||||||
|
roundId: string
|
||||||
|
onClear: () => void
|
||||||
|
onApplied: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AIRecommendationsDisplay({
|
||||||
|
recommendations,
|
||||||
|
projectStates,
|
||||||
|
roundId,
|
||||||
|
onClear,
|
||||||
|
onApplied,
|
||||||
|
}: AIRecommendationsDisplayProps) {
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const [applying, setApplying] = useState(false)
|
||||||
|
|
||||||
|
// Initialize selected with all recommended project IDs
|
||||||
|
const allRecommendedIds = useMemo(() => {
|
||||||
|
const ids = new Set<string>()
|
||||||
|
for (const item of recommendations.STARTUP) ids.add(item.projectId)
|
||||||
|
for (const item of recommendations.BUSINESS_CONCEPT) ids.add(item.projectId)
|
||||||
|
return ids
|
||||||
|
}, [recommendations])
|
||||||
|
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set(allRecommendedIds))
|
||||||
|
|
||||||
|
// Build projectId → title map from projectStates
|
||||||
|
const projectTitleMap = useMemo(() => {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
if (projectStates) {
|
||||||
|
for (const ps of projectStates) {
|
||||||
|
if (ps.project?.id && ps.project?.title) {
|
||||||
|
map.set(ps.project.id, ps.project.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [projectStates])
|
||||||
|
|
||||||
|
const transitionMutation = trpc.roundEngine.transitionProject.useMutation()
|
||||||
|
|
||||||
|
const toggleProject = (projectId: string) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(projectId)) next.delete(projectId)
|
||||||
|
else next.add(projectId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedStartups = recommendations.STARTUP.filter((item) => selectedIds.has(item.projectId)).length
|
||||||
|
const selectedConcepts = recommendations.BUSINESS_CONCEPT.filter((item) => selectedIds.has(item.projectId)).length
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
setApplying(true)
|
||||||
|
try {
|
||||||
|
// Transition all selected projects to PASSED
|
||||||
|
const promises = Array.from(selectedIds).map((projectId) =>
|
||||||
|
transitionMutation.mutateAsync({ projectId, roundId, newState: 'PASSED' }).catch(() => {
|
||||||
|
// Project might already be PASSED — that's OK
|
||||||
|
})
|
||||||
|
)
|
||||||
|
await Promise.all(promises)
|
||||||
|
toast.success(`Marked ${selectedIds.size} project(s) as passed`)
|
||||||
|
onApplied()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to apply recommendations')
|
||||||
|
} finally {
|
||||||
|
setApplying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCategory = (label: string, items: RecommendationItem[], colorClass: string) => {
|
||||||
|
if (items.length === 0) return (
|
||||||
|
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||||
|
No {label.toLowerCase()} projects evaluated
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item) => {
|
||||||
|
const isExpanded = expandedId === `${item.category}-${item.projectId}`
|
||||||
|
const isSelected = selectedIds.has(item.projectId)
|
||||||
|
const projectTitle = projectTitleMap.get(item.projectId) || item.projectId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.projectId}
|
||||||
|
className={cn(
|
||||||
|
'border rounded-lg overflow-hidden transition-colors',
|
||||||
|
!isSelected && 'opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 p-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => toggleProject(item.projectId)}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedId(isExpanded ? null : `${item.category}-${item.projectId}`)}
|
||||||
|
className="flex-1 flex items-center gap-3 text-left hover:bg-muted/30 rounded transition-colors min-w-0"
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0 shadow-sm',
|
||||||
|
colorClass === 'bg-blue-500' ? 'bg-gradient-to-br from-blue-400 to-blue-600' : 'bg-gradient-to-br from-purple-400 to-purple-600',
|
||||||
|
)}>
|
||||||
|
{item.rank}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{projectTitle}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{item.recommendation}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="shrink-0 text-xs font-mono">
|
||||||
|
{item.score}/100
|
||||||
|
</Badge>
|
||||||
|
<ChevronDown className={cn(
|
||||||
|
'h-4 w-4 text-muted-foreground transition-transform shrink-0',
|
||||||
|
isExpanded && 'rotate-180',
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-3 pb-3 pt-0 space-y-2 border-t bg-muted/10">
|
||||||
|
<div className="pt-2">
|
||||||
|
<p className="text-xs font-medium text-emerald-700 mb-1">Strengths</p>
|
||||||
|
<ul className="text-xs text-muted-foreground space-y-0.5 pl-4 list-disc">
|
||||||
|
{item.strengths.map((s, i) => <li key={i}>{s}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{item.concerns.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-amber-700 mb-1">Concerns</p>
|
||||||
|
<ul className="text-xs text-muted-foreground space-y-0.5 pl-4 list-disc">
|
||||||
|
{item.concerns.map((c, i) => <li key={i}>{c}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-blue-700 mb-1">Recommendation</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{item.recommendation}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">AI Shortlist Recommendations</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Ranked independently per category — {selectedStartups} of {recommendations.STARTUP.length} startups, {selectedConcepts} of {recommendations.BUSINESS_CONCEPT.length} concepts selected
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClear}>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||||
|
Startup ({recommendations.STARTUP.length})
|
||||||
|
</h4>
|
||||||
|
{renderCategory('Startup', recommendations.STARTUP, 'bg-blue-500')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-purple-500" />
|
||||||
|
Business Concept ({recommendations.BUSINESS_CONCEPT.length})
|
||||||
|
</h4>
|
||||||
|
{renderCategory('Business Concept', recommendations.BUSINESS_CONCEPT, 'bg-purple-500')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Apply button */}
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''} will be marked as <strong>Passed</strong>
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={selectedIds.size === 0 || applying}
|
||||||
|
className="bg-[#053d57] hover:bg-[#053d57]/90 text-white"
|
||||||
|
>
|
||||||
|
{applying ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-1.5 animate-spin" />Applying...</>
|
||||||
|
) : (
|
||||||
|
<><CheckCircle2 className="h-4 w-4 mr-1.5" />Apply & Mark as Passed</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
src/components/admin/round/evaluation-criteria-editor.tsx
Normal file
124
src/components/admin/round/evaluation-criteria-editor.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { EvaluationFormBuilder } from '@/components/forms/evaluation-form-builder'
|
||||||
|
import type { Criterion } from '@/components/forms/evaluation-form-builder'
|
||||||
|
|
||||||
|
export type EvaluationCriteriaEditorProps = {
|
||||||
|
roundId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorProps) {
|
||||||
|
const [pendingCriteria, setPendingCriteria] = useState<Criterion[] | null>(null)
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const { data: form, isLoading } = trpc.evaluation.getForm.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const upsertMutation = trpc.evaluation.upsertForm.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.evaluation.getForm.invalidate({ roundId })
|
||||||
|
toast.success('Evaluation criteria saved')
|
||||||
|
setPendingCriteria(null)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert server criteriaJson to Criterion[] format
|
||||||
|
const serverCriteria: Criterion[] = useMemo(() => {
|
||||||
|
if (!form?.criteriaJson) return []
|
||||||
|
return (form.criteriaJson as Criterion[]).map((c) => {
|
||||||
|
// Handle legacy numeric-only format: convert "scale" string like "1-10" back to minScore/maxScore
|
||||||
|
const type = c.type || 'numeric'
|
||||||
|
if (type === 'numeric' && typeof c.scale === 'string') {
|
||||||
|
const parts = (c.scale as string).split('-').map(Number)
|
||||||
|
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
||||||
|
return { ...c, type: 'numeric' as const, scale: parts[1], minScore: parts[0], maxScore: parts[1] } as unknown as Criterion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...c, type } as Criterion
|
||||||
|
})
|
||||||
|
}, [form?.criteriaJson])
|
||||||
|
|
||||||
|
const handleChange = useCallback((criteria: Criterion[]) => {
|
||||||
|
setPendingCriteria(criteria)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const criteria = pendingCriteria ?? serverCriteria
|
||||||
|
const validCriteria = criteria.filter((c) => c.label.trim())
|
||||||
|
if (validCriteria.length === 0) {
|
||||||
|
toast.error('Add at least one criterion')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Map to upsertForm format
|
||||||
|
upsertMutation.mutate({
|
||||||
|
roundId,
|
||||||
|
criteria: validCriteria.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.label,
|
||||||
|
description: c.description,
|
||||||
|
type: c.type || 'numeric',
|
||||||
|
weight: c.weight,
|
||||||
|
scale: typeof c.scale === 'number' ? c.scale : undefined,
|
||||||
|
minScore: (c as any).minScore,
|
||||||
|
maxScore: (c as any).maxScore,
|
||||||
|
required: c.required,
|
||||||
|
maxLength: c.maxLength,
|
||||||
|
placeholder: c.placeholder,
|
||||||
|
trueLabel: c.trueLabel,
|
||||||
|
falseLabel: c.falseLabel,
|
||||||
|
condition: c.condition,
|
||||||
|
sectionId: c.sectionId,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">Evaluation Criteria</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{form
|
||||||
|
? `Version ${form.version} \u2014 ${(form.criteriaJson as Criterion[]).filter((c) => (c.type || 'numeric') !== 'section_header').length} criteria`
|
||||||
|
: 'No criteria defined yet. Add numeric scores, yes/no questions, and text fields.'}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{pendingCriteria && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setPendingCriteria(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={upsertMutation.isPending}>
|
||||||
|
{upsertMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
|
Save Criteria
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EvaluationFormBuilder
|
||||||
|
initialCriteria={serverCriteria}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
src/components/admin/round/export-evaluations-dialog.tsx
Normal file
43
src/components/admin/round/export-evaluations-dialog.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||||
|
|
||||||
|
export type ExportEvaluationsDialogProps = {
|
||||||
|
roundId: string
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportEvaluationsDialog({
|
||||||
|
roundId,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: ExportEvaluationsDialogProps) {
|
||||||
|
const [exportData, setExportData] = useState<any>(undefined)
|
||||||
|
const [isLoadingExport, setIsLoadingExport] = useState(false)
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const handleRequestData = async () => {
|
||||||
|
setIsLoadingExport(true)
|
||||||
|
try {
|
||||||
|
const data = await utils.export.evaluations.fetch({ roundId, includeDetails: true })
|
||||||
|
setExportData(data)
|
||||||
|
return data
|
||||||
|
} finally {
|
||||||
|
setIsLoadingExport(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CsvExportDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
exportData={exportData}
|
||||||
|
isLoading={isLoadingExport}
|
||||||
|
filename={`evaluations-${roundId}`}
|
||||||
|
onRequestData={handleRequestData}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -92,7 +92,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
|
const poolLink = `/admin/projects?hasAssign=false&round=${roundId}` as Route
|
||||||
|
|
||||||
const { data: projectStates, isLoading } = trpc.roundEngine.getProjectStates.useQuery(
|
const { data: projectStates, isLoading } = trpc.roundEngine.getProjectStates.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
@@ -328,7 +328,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="grid grid-cols-[40px_1fr_140px_160px_120px_100px_48px] gap-2 px-4 py-2.5 bg-muted/40 text-xs font-medium text-muted-foreground border-b">
|
<div className="grid grid-cols-[40px_1fr_140px_160px_120px_80px_100px_48px] gap-2 px-4 py-2.5 bg-muted/40 text-xs font-medium text-muted-foreground border-b">
|
||||||
<div>
|
<div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={filtered.length > 0 && filtered.every((ps: any) => selectedIds.has(ps.projectId))}
|
checked={filtered.length > 0 && filtered.every((ps: any) => selectedIds.has(ps.projectId))}
|
||||||
@@ -339,6 +339,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
<div>Category</div>
|
<div>Category</div>
|
||||||
<div>Country</div>
|
<div>Country</div>
|
||||||
<div>State</div>
|
<div>State</div>
|
||||||
|
<div>Reviews</div>
|
||||||
<div>Entered</div>
|
<div>Entered</div>
|
||||||
<div />
|
<div />
|
||||||
</div>
|
</div>
|
||||||
@@ -347,10 +348,13 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
{filtered.map((ps: any) => {
|
{filtered.map((ps: any) => {
|
||||||
const cfg = stateConfig[ps.state as ProjectState] || stateConfig.PENDING
|
const cfg = stateConfig[ps.state as ProjectState] || stateConfig.PENDING
|
||||||
const StateIcon = cfg.icon
|
const StateIcon = cfg.icon
|
||||||
|
const total = ps.totalAssignments ?? 0
|
||||||
|
const submitted = ps.submittedCount ?? 0
|
||||||
|
const allDone = total > 0 && submitted === total
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ps.id}
|
key={ps.id}
|
||||||
className="grid grid-cols-[40px_1fr_140px_160px_120px_100px_48px] gap-2 px-4 py-3 items-center border-b last:border-b-0 hover:bg-muted/30 text-sm"
|
className="grid grid-cols-[40px_1fr_140px_160px_120px_80px_100px_48px] gap-2 px-4 py-3 items-center border-b last:border-b-0 hover:bg-muted/30 text-sm"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -381,6 +385,15 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
{cfg.label}
|
{cfg.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs tabular-nums">
|
||||||
|
{total > 0 ? (
|
||||||
|
<span className={allDone ? 'text-green-600 font-medium' : 'text-muted-foreground'}>
|
||||||
|
{submitted}/{total}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{ps.enteredAt ? new Date(ps.enteredAt).toLocaleDateString() : '—'}
|
{ps.enteredAt ? new Date(ps.enteredAt).toLocaleDateString() : '—'}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
64
src/components/admin/round/score-distribution.tsx
Normal file
64
src/components/admin/round/score-distribution.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
|
||||||
|
export type ScoreDistributionProps = {
|
||||||
|
roundId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScoreDistribution({ roundId }: ScoreDistributionProps) {
|
||||||
|
const { data: dist, isLoading } = trpc.analytics.getRoundScoreDistribution.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 15_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxCount = useMemo(() =>
|
||||||
|
dist ? Math.max(...dist.globalDistribution.map((b) => b.count), 1) : 1,
|
||||||
|
[dist])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Score Distribution</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{dist ? `${dist.totalEvaluations} evaluations \u2014 avg ${dist.averageGlobalScore.toFixed(1)}` : 'Loading...'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col flex-1 pb-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-end gap-1 flex-1 min-h-[120px]">
|
||||||
|
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="flex-1 h-full" />)}
|
||||||
|
</div>
|
||||||
|
) : !dist || dist.totalEvaluations === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-6">
|
||||||
|
No evaluations submitted yet
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-1 flex-1 min-h-[120px]">
|
||||||
|
{dist.globalDistribution.map((bucket) => {
|
||||||
|
const heightPct = (bucket.count / maxCount) * 100
|
||||||
|
return (
|
||||||
|
<div key={bucket.score} className="flex-1 flex flex-col items-center gap-1 h-full">
|
||||||
|
<span className="text-[9px] text-muted-foreground">{bucket.count || ''}</span>
|
||||||
|
<div className="w-full flex-1 relative">
|
||||||
|
<div className={cn(
|
||||||
|
'absolute inset-x-0 bottom-0 rounded-t transition-all',
|
||||||
|
bucket.score <= 3 ? 'bg-red-400' :
|
||||||
|
bucket.score <= 6 ? 'bg-amber-400' :
|
||||||
|
'bg-emerald-400',
|
||||||
|
)} style={{ height: `${Math.max(heightPct, 4)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{bucket.score}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
654
src/components/admin/round/submission-window-manager.tsx
Normal file
654
src/components/admin/round/submission-window-manager.tsx
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Plus, Lock, Unlock, LockKeyhole, Loader2, Pencil, Trash2 } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
|
type SubmissionWindowManagerProps = {
|
||||||
|
competitionId: string
|
||||||
|
roundId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWindowManagerProps) {
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||||
|
const [editingWindow, setEditingWindow] = useState<string | null>(null)
|
||||||
|
const [deletingWindow, setDeletingWindow] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Create form state
|
||||||
|
const [createForm, setCreateForm] = useState({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
roundNumber: 1,
|
||||||
|
windowOpenAt: '',
|
||||||
|
windowCloseAt: '',
|
||||||
|
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
|
||||||
|
graceHours: 0,
|
||||||
|
lockOnClose: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Edit form state
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
roundNumber: 1,
|
||||||
|
windowOpenAt: '',
|
||||||
|
windowCloseAt: '',
|
||||||
|
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
|
||||||
|
graceHours: 0,
|
||||||
|
lockOnClose: true,
|
||||||
|
sortOrder: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
|
||||||
|
id: competitionId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createWindowMutation = trpc.round.createSubmissionWindow.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.competition.getById.invalidate({ id: competitionId })
|
||||||
|
toast.success('Submission window created')
|
||||||
|
setIsCreateOpen(false)
|
||||||
|
// Reset form
|
||||||
|
setCreateForm({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
roundNumber: 1,
|
||||||
|
windowOpenAt: '',
|
||||||
|
windowCloseAt: '',
|
||||||
|
deadlinePolicy: 'HARD_DEADLINE',
|
||||||
|
graceHours: 0,
|
||||||
|
lockOnClose: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateWindowMutation = trpc.round.updateSubmissionWindow.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.competition.getById.invalidate({ id: competitionId })
|
||||||
|
toast.success('Submission window updated')
|
||||||
|
setEditingWindow(null)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteWindowMutation = trpc.round.deleteSubmissionWindow.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.competition.getById.invalidate({ id: competitionId })
|
||||||
|
toast.success('Submission window deleted')
|
||||||
|
setDeletingWindow(null)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const openWindowMutation = trpc.round.openSubmissionWindow.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.competition.getById.invalidate({ id: competitionId })
|
||||||
|
toast.success('Window opened')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const closeWindowMutation = trpc.round.closeSubmissionWindow.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.competition.getById.invalidate({ id: competitionId })
|
||||||
|
toast.success('Window closed')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const lockWindowMutation = trpc.round.lockSubmissionWindow.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.competition.getById.invalidate({ id: competitionId })
|
||||||
|
toast.success('Window locked')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreateNameChange = (value: string) => {
|
||||||
|
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||||||
|
setCreateForm({ ...createForm, name: value, slug: autoSlug })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditNameChange = (value: string) => {
|
||||||
|
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||||||
|
setEditForm({ ...editForm, name: value, slug: autoSlug })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (!createForm.name || !createForm.slug) {
|
||||||
|
toast.error('Name and slug are required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createWindowMutation.mutate({
|
||||||
|
competitionId,
|
||||||
|
name: createForm.name,
|
||||||
|
slug: createForm.slug,
|
||||||
|
roundNumber: createForm.roundNumber,
|
||||||
|
windowOpenAt: createForm.windowOpenAt ? new Date(createForm.windowOpenAt) : undefined,
|
||||||
|
windowCloseAt: createForm.windowCloseAt ? new Date(createForm.windowCloseAt) : undefined,
|
||||||
|
deadlinePolicy: createForm.deadlinePolicy,
|
||||||
|
graceHours: createForm.deadlinePolicy === 'GRACE' ? createForm.graceHours : undefined,
|
||||||
|
lockOnClose: createForm.lockOnClose,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (!editingWindow) return
|
||||||
|
if (!editForm.name || !editForm.slug) {
|
||||||
|
toast.error('Name and slug are required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWindowMutation.mutate({
|
||||||
|
id: editingWindow,
|
||||||
|
name: editForm.name,
|
||||||
|
slug: editForm.slug,
|
||||||
|
roundNumber: editForm.roundNumber,
|
||||||
|
windowOpenAt: editForm.windowOpenAt ? new Date(editForm.windowOpenAt) : null,
|
||||||
|
windowCloseAt: editForm.windowCloseAt ? new Date(editForm.windowCloseAt) : null,
|
||||||
|
deadlinePolicy: editForm.deadlinePolicy,
|
||||||
|
graceHours: editForm.deadlinePolicy === 'GRACE' ? editForm.graceHours : null,
|
||||||
|
lockOnClose: editForm.lockOnClose,
|
||||||
|
sortOrder: editForm.sortOrder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!deletingWindow) return
|
||||||
|
deleteWindowMutation.mutate({ id: deletingWindow })
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditDialog = (window: any) => {
|
||||||
|
setEditForm({
|
||||||
|
name: window.name,
|
||||||
|
slug: window.slug,
|
||||||
|
roundNumber: window.roundNumber,
|
||||||
|
windowOpenAt: window.windowOpenAt ? new Date(window.windowOpenAt).toISOString().slice(0, 16) : '',
|
||||||
|
windowCloseAt: window.windowCloseAt ? new Date(window.windowCloseAt).toISOString().slice(0, 16) : '',
|
||||||
|
deadlinePolicy: window.deadlinePolicy ?? 'HARD_DEADLINE',
|
||||||
|
graceHours: window.graceHours ?? 0,
|
||||||
|
lockOnClose: window.lockOnClose ?? true,
|
||||||
|
sortOrder: window.sortOrder ?? 1,
|
||||||
|
})
|
||||||
|
setEditingWindow(window.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date | null | undefined) => {
|
||||||
|
if (!date) return 'Not set'
|
||||||
|
return format(new Date(date), 'MMM d, yyyy h:mm a')
|
||||||
|
}
|
||||||
|
|
||||||
|
const windows = competition?.submissionWindows ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">Submission Windows</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
File upload windows for this round
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline" className="w-full sm:w-auto">
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Create Window
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Submission Window</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-name">Window Name</Label>
|
||||||
|
<Input
|
||||||
|
id="create-name"
|
||||||
|
placeholder="e.g., Round 1 Submissions"
|
||||||
|
value={createForm.name}
|
||||||
|
onChange={(e) => handleCreateNameChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-slug">Slug</Label>
|
||||||
|
<Input
|
||||||
|
id="create-slug"
|
||||||
|
placeholder="e.g., round-1-submissions"
|
||||||
|
value={createForm.slug}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, slug: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-roundNumber">Round Number</Label>
|
||||||
|
<Input
|
||||||
|
id="create-roundNumber"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={createForm.roundNumber}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-windowOpenAt">Window Open At</Label>
|
||||||
|
<Input
|
||||||
|
id="create-windowOpenAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={createForm.windowOpenAt}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, windowOpenAt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-windowCloseAt">Window Close At</Label>
|
||||||
|
<Input
|
||||||
|
id="create-windowCloseAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={createForm.windowCloseAt}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, windowCloseAt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-deadlinePolicy">Deadline Policy</Label>
|
||||||
|
<Select
|
||||||
|
value={createForm.deadlinePolicy}
|
||||||
|
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
|
||||||
|
setCreateForm({ ...createForm, deadlinePolicy: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="create-deadlinePolicy">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
|
||||||
|
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
|
||||||
|
<SelectItem value="GRACE">Grace Period</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createForm.deadlinePolicy === 'GRACE' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-graceHours">Grace Hours</Label>
|
||||||
|
<Input
|
||||||
|
id="create-graceHours"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={createForm.graceHours}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, graceHours: parseInt(e.target.value, 10) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="create-lockOnClose"
|
||||||
|
checked={createForm.lockOnClose}
|
||||||
|
onCheckedChange={(checked) => setCreateForm({ ...createForm, lockOnClose: checked })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="create-lockOnClose" className="cursor-pointer">
|
||||||
|
Lock window on close
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setIsCreateOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={createWindowMutation.isPending}
|
||||||
|
>
|
||||||
|
{createWindowMutation.isPending && (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
)}
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Loading windows...
|
||||||
|
</div>
|
||||||
|
) : windows.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No submission windows yet. Create one to enable file uploads.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{windows.map((window) => {
|
||||||
|
const isPending = !window.windowOpenAt
|
||||||
|
const isOpen = window.windowOpenAt && !window.windowCloseAt
|
||||||
|
const isClosed = window.windowCloseAt && !window.isLocked
|
||||||
|
const isLocked = window.isLocked
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={window.id}
|
||||||
|
className="flex flex-col gap-3 border rounded-lg p-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="text-sm font-medium truncate">{window.name}</p>
|
||||||
|
{isPending && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] bg-gray-100 text-gray-700">
|
||||||
|
Pending
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isOpen && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] bg-emerald-100 text-emerald-700">
|
||||||
|
Open
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isClosed && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] bg-blue-100 text-blue-700">
|
||||||
|
Closed
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isLocked && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] bg-red-100 text-red-700">
|
||||||
|
<LockKeyhole className="h-2.5 w-2.5 mr-1" />
|
||||||
|
Locked
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground font-mono mt-0.5">{window.slug}</p>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
|
||||||
|
<span>Round {window.roundNumber}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{window._count.fileRequirements} requirements</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{window._count.projectFiles} files</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
|
||||||
|
<span>Open: {formatDate(window.windowOpenAt)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Close: {formatDate(window.windowCloseAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0 flex-wrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => openEditDialog(window)}
|
||||||
|
className="h-8 px-2"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setDeletingWindow(window.id)}
|
||||||
|
className="h-8 px-2 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
{isPending && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => openWindowMutation.mutate({ windowId: window.id })}
|
||||||
|
disabled={openWindowMutation.isPending}
|
||||||
|
>
|
||||||
|
{openWindowMutation.isPending ? (
|
||||||
|
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Unlock className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isOpen && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => closeWindowMutation.mutate({ windowId: window.id })}
|
||||||
|
disabled={closeWindowMutation.isPending}
|
||||||
|
>
|
||||||
|
{closeWindowMutation.isPending ? (
|
||||||
|
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Lock className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isClosed && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => lockWindowMutation.mutate({ windowId: window.id })}
|
||||||
|
disabled={lockWindowMutation.isPending}
|
||||||
|
>
|
||||||
|
{lockWindowMutation.isPending ? (
|
||||||
|
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LockKeyhole className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
Lock
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
|
<Dialog open={!!editingWindow} onOpenChange={(open) => !open && setEditingWindow(null)}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Submission Window</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-name">Window Name</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-name"
|
||||||
|
placeholder="e.g., Round 1 Submissions"
|
||||||
|
value={editForm.name}
|
||||||
|
onChange={(e) => handleEditNameChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-slug">Slug</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-slug"
|
||||||
|
placeholder="e.g., round-1-submissions"
|
||||||
|
value={editForm.slug}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, slug: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-roundNumber">Round Number</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-roundNumber"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={editForm.roundNumber}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-windowOpenAt">Window Open At</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-windowOpenAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={editForm.windowOpenAt}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, windowOpenAt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-windowCloseAt">Window Close At</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-windowCloseAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={editForm.windowCloseAt}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, windowCloseAt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-deadlinePolicy">Deadline Policy</Label>
|
||||||
|
<Select
|
||||||
|
value={editForm.deadlinePolicy}
|
||||||
|
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
|
||||||
|
setEditForm({ ...editForm, deadlinePolicy: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="edit-deadlinePolicy">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
|
||||||
|
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
|
||||||
|
<SelectItem value="GRACE">Grace Period</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editForm.deadlinePolicy === 'GRACE' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-graceHours">Grace Hours</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-graceHours"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={editForm.graceHours}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, graceHours: parseInt(e.target.value, 10) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="edit-lockOnClose"
|
||||||
|
checked={editForm.lockOnClose}
|
||||||
|
onCheckedChange={(checked) => setEditForm({ ...editForm, lockOnClose: checked })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="edit-lockOnClose" className="cursor-pointer">
|
||||||
|
Lock window on close
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-sortOrder">Sort Order</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-sortOrder"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={editForm.sortOrder}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, sortOrder: parseInt(e.target.value, 10) || 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setEditingWindow(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
onClick={handleEdit}
|
||||||
|
disabled={updateWindowMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateWindowMutation.isPending && (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
)}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={!!deletingWindow} onOpenChange={(open) => !open && setDeletingWindow(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Submission Window</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this submission window? This action cannot be undone.
|
||||||
|
{(windows.find(w => w.id === deletingWindow)?._count?.projectFiles ?? 0) > 0 && (
|
||||||
|
<span className="block mt-2 text-destructive font-medium">
|
||||||
|
Warning: This window has uploaded files and cannot be deleted until they are removed.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeletingWindow(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteWindowMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteWindowMutation.isPending && (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
)}
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
src/components/admin/rounds/config/config-section-header.tsx
Normal file
51
src/components/admin/rounds/config/config-section-header.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type CompletionStatus = 'complete' | 'warning' | 'error'
|
||||||
|
|
||||||
|
type ConfigSectionHeaderProps = {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
status: CompletionStatus
|
||||||
|
summary?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusDot: Record<CompletionStatus, string> = {
|
||||||
|
complete: 'bg-emerald-500',
|
||||||
|
warning: 'bg-amber-500',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigSectionHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
summary,
|
||||||
|
}: ConfigSectionHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'mt-1 h-2.5 w-2.5 rounded-full shrink-0',
|
||||||
|
statusDot[status],
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-base font-semibold">{title}</h3>
|
||||||
|
{summary && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
— {summary}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,6 +25,14 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
|
|||||||
update('advancementConfig', { ...advancementConfig, [key]: value })
|
update('advancementConfig', { ...advancementConfig, [key]: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visConfig = (config.applicantVisibility as {
|
||||||
|
enabled?: boolean; showGlobalScore?: boolean; showCriterionScores?: boolean; showFeedbackText?: boolean
|
||||||
|
}) ?? {}
|
||||||
|
|
||||||
|
const updateVisibility = (key: string, value: unknown) => {
|
||||||
|
update('applicantVisibility', { ...visConfig, [key]: value })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Scoring */}
|
{/* Scoring */}
|
||||||
@@ -202,6 +210,71 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Applicant Feedback Visibility */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Applicant Feedback Visibility</CardTitle>
|
||||||
|
<CardDescription>Control what evaluation data applicants can see after this round closes</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="applicantVisEnabled">Show Evaluations to Applicants</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">Master switch — when off, nothing is visible to applicants</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="applicantVisEnabled"
|
||||||
|
checked={visConfig.enabled ?? false}
|
||||||
|
onCheckedChange={(v) => updateVisibility('enabled', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visConfig.enabled && (
|
||||||
|
<div className="pl-6 border-l-2 border-muted space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="showGlobalScore">Show Global Score</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">Display the overall score for each evaluation</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="showGlobalScore"
|
||||||
|
checked={visConfig.showGlobalScore ?? false}
|
||||||
|
onCheckedChange={(v) => updateVisibility('showGlobalScore', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="showCriterionScores">Show Per-Criterion Scores</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">Display individual criterion scores and names</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="showCriterionScores"
|
||||||
|
checked={visConfig.showCriterionScores ?? false}
|
||||||
|
onCheckedChange={(v) => updateVisibility('showCriterionScores', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="showFeedbackText">Show Written Feedback</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">Display jury members' written comments</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="showFeedbackText"
|
||||||
|
checked={visConfig.showFeedbackText ?? false}
|
||||||
|
onCheckedChange={(v) => updateVisibility('showFeedbackText', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
|
||||||
|
Evaluations are only visible to applicants after this round closes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Advancement */}
|
{/* Advancement */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -4,36 +4,17 @@ import { trpc } from '@/lib/trpc/client'
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { CheckCircle2, Circle, Clock } from 'lucide-react'
|
import { CheckCircle2, Circle, Clock, XCircle, Trophy } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
interface ApplicantCompetitionTimelineProps {
|
const roundStatusDisplay: Record<string, { label: string; variant: 'default' | 'secondary' }> = {
|
||||||
competitionId: string
|
ROUND_DRAFT: { label: 'Upcoming', variant: 'secondary' },
|
||||||
|
ROUND_ACTIVE: { label: 'In Progress', variant: 'default' },
|
||||||
|
ROUND_CLOSED: { label: 'Completed', variant: 'default' },
|
||||||
|
ROUND_ARCHIVED: { label: 'Completed', variant: 'default' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusIcons: Record<string, React.ElementType> = {
|
export function ApplicantCompetitionTimeline() {
|
||||||
completed: CheckCircle2,
|
const { data, isLoading } = trpc.applicant.getMyCompetitionTimeline.useQuery()
|
||||||
current: Clock,
|
|
||||||
upcoming: Circle,
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
|
||||||
completed: 'text-emerald-600',
|
|
||||||
current: 'text-brand-blue',
|
|
||||||
upcoming: 'text-muted-foreground',
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusBgColors: Record<string, string> = {
|
|
||||||
completed: 'bg-emerald-50',
|
|
||||||
current: 'bg-brand-blue/10',
|
|
||||||
upcoming: 'bg-muted',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ApplicantCompetitionTimeline({ competitionId }: ApplicantCompetitionTimelineProps) {
|
|
||||||
const { data: competition, isLoading } = trpc.competition.getById.useQuery(
|
|
||||||
{ id: competitionId },
|
|
||||||
{ enabled: !!competitionId }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -52,7 +33,7 @@ export function ApplicantCompetitionTimeline({ competitionId }: ApplicantCompeti
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!competition || !competition.rounds || competition.rounds.length === 0) {
|
if (!data || data.entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -60,77 +41,117 @@ export function ApplicantCompetitionTimeline({ competitionId }: ApplicantCompeti
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-center py-8">
|
<CardContent className="text-center py-8">
|
||||||
<Circle className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
<Circle className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||||
<p className="text-sm text-muted-foreground">No rounds available</p>
|
<p className="text-sm text-muted-foreground">No rounds available yet</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rounds = competition.rounds || []
|
|
||||||
const currentRoundIndex = rounds.findIndex(r => r.status === 'ROUND_ACTIVE')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Competition Timeline</CardTitle>
|
<CardTitle>Competition Timeline</CardTitle>
|
||||||
|
{data.competitionName && (
|
||||||
|
<p className="text-sm text-muted-foreground">{data.competitionName}</p>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="relative space-y-6">
|
<div className="relative space-y-6">
|
||||||
{/* Vertical connecting line */}
|
{/* Vertical connecting line */}
|
||||||
<div className="absolute left-5 top-5 bottom-5 w-0.5 bg-border" />
|
<div className="absolute left-5 top-5 bottom-5 w-0.5 bg-border" />
|
||||||
|
|
||||||
{rounds.map((round, index) => {
|
{data.entries.map((entry) => {
|
||||||
const isActive = round.status === 'ROUND_ACTIVE'
|
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
|
||||||
const isCompleted = index < currentRoundIndex || round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED'
|
const isActive = entry.status === 'ROUND_ACTIVE'
|
||||||
const isCurrent = index === currentRoundIndex || isActive
|
const isRejected = entry.projectState === 'REJECTED'
|
||||||
const status = isCompleted ? 'completed' : isCurrent ? 'current' : 'upcoming'
|
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
|
||||||
const Icon = statusIcons[status]
|
|
||||||
|
// Determine icon
|
||||||
|
let Icon = Circle
|
||||||
|
let iconBg = 'bg-muted'
|
||||||
|
let iconColor = 'text-muted-foreground'
|
||||||
|
|
||||||
|
if (isRejected) {
|
||||||
|
Icon = XCircle
|
||||||
|
iconBg = 'bg-red-50'
|
||||||
|
iconColor = 'text-red-600'
|
||||||
|
} else if (isGrandFinale && isCompleted) {
|
||||||
|
Icon = Trophy
|
||||||
|
iconBg = 'bg-yellow-50'
|
||||||
|
iconColor = 'text-yellow-600'
|
||||||
|
} else if (isCompleted) {
|
||||||
|
Icon = CheckCircle2
|
||||||
|
iconBg = 'bg-emerald-50'
|
||||||
|
iconColor = 'text-emerald-600'
|
||||||
|
} else if (isActive) {
|
||||||
|
Icon = Clock
|
||||||
|
iconBg = 'bg-brand-blue/10'
|
||||||
|
iconColor = 'text-brand-blue'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project state display
|
||||||
|
let stateLabel: string | null = null
|
||||||
|
if (entry.projectState === 'REJECTED') {
|
||||||
|
stateLabel = 'Not Selected'
|
||||||
|
} else if (entry.projectState === 'PASSED' || entry.projectState === 'COMPLETED') {
|
||||||
|
stateLabel = 'Advanced'
|
||||||
|
} else if (entry.projectState === 'IN_PROGRESS') {
|
||||||
|
stateLabel = 'Under Review'
|
||||||
|
} else if (entry.projectState === 'PENDING') {
|
||||||
|
stateLabel = 'Pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusInfo = roundStatusDisplay[entry.status] ?? { label: 'Upcoming', variant: 'secondary' as const }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={round.id} className="relative flex items-start gap-4">
|
<div key={entry.id} className="relative flex items-start gap-4">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div
|
<div
|
||||||
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full ${statusBgColors[status]} shrink-0`}
|
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full ${iconBg} shrink-0`}
|
||||||
>
|
>
|
||||||
<Icon className={`h-5 w-5 ${statusColors[status]}`} />
|
<Icon className={`h-5 w-5 ${iconColor}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0 pb-6">
|
<div className="flex-1 min-w-0 pb-6">
|
||||||
<div className="flex items-start justify-between flex-wrap gap-2 mb-2">
|
<div className="flex items-start justify-between flex-wrap gap-2 mb-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">{round.name}</h3>
|
<h3 className="font-semibold">{entry.label}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{stateLabel && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
isRejected
|
||||||
|
? 'border-red-200 text-red-700 bg-red-50'
|
||||||
|
: entry.projectState === 'PASSED' || entry.projectState === 'COMPLETED'
|
||||||
|
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{stateLabel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
variant={statusInfo.variant}
|
||||||
|
className={
|
||||||
|
isCompleted
|
||||||
|
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||||
|
: isActive
|
||||||
|
? 'bg-brand-blue text-white'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{statusInfo.label}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
status === 'completed'
|
|
||||||
? 'default'
|
|
||||||
: status === 'current'
|
|
||||||
? 'default'
|
|
||||||
: 'secondary'
|
|
||||||
}
|
|
||||||
className={
|
|
||||||
status === 'completed'
|
|
||||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
|
||||||
: status === 'current'
|
|
||||||
? 'bg-brand-blue text-white'
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{status === 'completed' && 'Completed'}
|
|
||||||
{status === 'current' && 'In Progress'}
|
|
||||||
{status === 'upcoming' && 'Upcoming'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{round.windowOpenAt && round.windowCloseAt && (
|
{entry.windowOpenAt && entry.windowCloseAt && (
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
<p>
|
<p>Opens: {new Date(entry.windowOpenAt).toLocaleDateString()}</p>
|
||||||
Opens: {new Date(round.windowOpenAt).toLocaleDateString()}
|
<p>Closes: {new Date(entry.windowCloseAt).toLocaleDateString()}</p>
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Closes: {new Date(round.windowCloseAt).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -142,3 +163,76 @@ export function ApplicantCompetitionTimeline({ competitionId }: ApplicantCompeti
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact sidebar variant for the dashboard.
|
||||||
|
* Shows dots + labels, no date details.
|
||||||
|
*/
|
||||||
|
export function CompetitionTimelineSidebar() {
|
||||||
|
const { data, isLoading } = trpc.applicant.getMyCompetitionTimeline.useQuery()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-6" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.entries.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">No rounds available</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0">
|
||||||
|
{data.entries.map((entry, index) => {
|
||||||
|
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
|
||||||
|
const isActive = entry.status === 'ROUND_ACTIVE'
|
||||||
|
const isRejected = entry.projectState === 'REJECTED'
|
||||||
|
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
|
||||||
|
const isLast = index === data.entries.length - 1
|
||||||
|
|
||||||
|
let dotColor = 'border-2 border-muted bg-background'
|
||||||
|
if (isRejected) dotColor = 'bg-destructive'
|
||||||
|
else if (isGrandFinale && isCompleted) dotColor = 'bg-yellow-500'
|
||||||
|
else if (isCompleted) dotColor = 'bg-primary'
|
||||||
|
else if (isActive) dotColor = 'bg-primary ring-2 ring-primary/30'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={entry.id} className="relative flex gap-3">
|
||||||
|
{/* Connecting line */}
|
||||||
|
{!isLast && (
|
||||||
|
<div className="absolute left-[7px] top-[20px] h-full w-0.5 bg-muted" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dot */}
|
||||||
|
<div className={`relative z-10 mt-1.5 h-4 w-4 rounded-full shrink-0 ${dotColor}`} />
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<div className="flex-1 pb-4">
|
||||||
|
<p
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
isRejected
|
||||||
|
? 'text-destructive'
|
||||||
|
: isCompleted || isActive
|
||||||
|
? 'text-foreground'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</p>
|
||||||
|
{isRejected && (
|
||||||
|
<p className="text-xs text-destructive">Not Selected</p>
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<p className="text-xs text-primary">In Progress</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
import {
|
import {
|
||||||
Inbox,
|
|
||||||
Filter,
|
|
||||||
ClipboardCheck,
|
|
||||||
Upload,
|
|
||||||
Users,
|
|
||||||
Radio,
|
|
||||||
Scale,
|
|
||||||
Clock,
|
Clock,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -17,6 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -25,6 +20,8 @@ import {
|
|||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { StatusBadge } from '@/components/shared/status-badge'
|
import { StatusBadge } from '@/components/shared/status-badge'
|
||||||
import { cn, formatEnumLabel, daysUntil } from '@/lib/utils'
|
import { cn, formatEnumLabel, daysUntil } from '@/lib/utils'
|
||||||
|
import { roundTypeConfig, projectStateConfig } from '@/lib/round-config'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
|
||||||
export type PipelineRound = {
|
export type PipelineRound = {
|
||||||
id: string
|
id: string
|
||||||
@@ -60,24 +57,13 @@ type ActiveRoundPanelProps = {
|
|||||||
round: PipelineRound
|
round: PipelineRound
|
||||||
}
|
}
|
||||||
|
|
||||||
const roundTypeIcons: Record<string, React.ElementType> = {
|
const roundTypeIcons: Record<string, React.ElementType> = Object.fromEntries(
|
||||||
INTAKE: Inbox,
|
Object.entries(roundTypeConfig).map(([k, v]) => [k, v.icon])
|
||||||
FILTERING: Filter,
|
)
|
||||||
EVALUATION: ClipboardCheck,
|
|
||||||
SUBMISSION: Upload,
|
|
||||||
MENTORING: Users,
|
|
||||||
LIVE_FINAL: Radio,
|
|
||||||
DELIBERATION: Scale,
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateColors: Record<string, { bg: string; label: string }> = {
|
const stateColors: Record<string, { bg: string; label: string }> = Object.fromEntries(
|
||||||
PENDING: { bg: 'bg-slate-300', label: 'Pending' },
|
Object.entries(projectStateConfig).map(([k, v]) => [k, { bg: v.bg, label: v.label }])
|
||||||
IN_PROGRESS: { bg: 'bg-blue-400', label: 'In Progress' },
|
)
|
||||||
PASSED: { bg: 'bg-emerald-500', label: 'Passed' },
|
|
||||||
REJECTED: { bg: 'bg-red-400', label: 'Rejected' },
|
|
||||||
COMPLETED: { bg: 'bg-[#557f8c]', label: 'Completed' },
|
|
||||||
WITHDRAWN: { bg: 'bg-slate-400', label: 'Withdrawn' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeadlineCountdown({ date }: { date: Date }) {
|
function DeadlineCountdown({ date }: { date: Date }) {
|
||||||
const days = daysUntil(date)
|
const days = daysUntil(date)
|
||||||
@@ -155,6 +141,80 @@ function ProjectStateBar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EvaluationRoundContent({ round }: { round: PipelineRound }) {
|
||||||
|
const [showAll, setShowAll] = useState(false)
|
||||||
|
|
||||||
|
const { data: workload, isLoading: isLoadingWorkload } = trpc.analytics.getJurorWorkload.useQuery(
|
||||||
|
{ roundId: round.id },
|
||||||
|
{ enabled: round.roundType === 'EVALUATION' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const pct =
|
||||||
|
round.evalTotal > 0
|
||||||
|
? Math.round((round.evalSubmitted / round.evalTotal) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Evaluation progress</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{round.evalSubmitted} / {round.evalTotal} ({pct}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={pct} gradient />
|
||||||
|
{round.evalDraft > 0 && (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
{round.evalDraft} draft{round.evalDraft !== 1 ? 's' : ''} in progress
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{/* Per-juror progress */}
|
||||||
|
<div className="mt-3 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Jury Progress</span>
|
||||||
|
{workload && workload.length > 8 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAll(!showAll)}
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{showAll ? 'Show less' : `Show all (${workload.length})`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isLoadingWorkload ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-4 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : workload && workload.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(showAll ? workload : workload.slice(0, 8)).map((juror) => {
|
||||||
|
const pct = juror.assigned > 0 ? (juror.completed / juror.assigned) * 100 : 0
|
||||||
|
return (
|
||||||
|
<div key={juror.id} className="flex items-center gap-2">
|
||||||
|
<span className="max-w-[140px] truncate text-xs">{juror.name}</span>
|
||||||
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{juror.completed}/{juror.assigned}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">No jurors assigned yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function RoundTypeContent({ round }: { round: PipelineRound }) {
|
function RoundTypeContent({ round }: { round: PipelineRound }) {
|
||||||
const { projectStates } = round
|
const { projectStates } = round
|
||||||
|
|
||||||
@@ -188,29 +248,8 @@ function RoundTypeContent({ round }: { round: PipelineRound }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'EVALUATION': {
|
case 'EVALUATION':
|
||||||
const pct =
|
return <EvaluationRoundContent round={round} />
|
||||||
round.evalTotal > 0
|
|
||||||
? Math.round((round.evalSubmitted / round.evalTotal) * 100)
|
|
||||||
: 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Evaluation progress</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{round.evalSubmitted} / {round.evalTotal} ({pct}%)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={pct} gradient />
|
|
||||||
{round.evalDraft > 0 && (
|
|
||||||
<p className="text-xs text-amber-600">
|
|
||||||
{round.evalDraft} draft{round.evalDraft !== 1 ? 's' : ''} in progress
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'SUBMISSION':
|
case 'SUBMISSION':
|
||||||
return (
|
return (
|
||||||
@@ -264,7 +303,7 @@ function RoundTypeContent({ round }: { round: PipelineRound }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ActiveRoundPanel({ round }: ActiveRoundPanelProps) {
|
export function ActiveRoundPanel({ round }: ActiveRoundPanelProps) {
|
||||||
const Icon = roundTypeIcons[round.roundType] || ClipboardCheck
|
const Icon = roundTypeIcons[round.roundType] || roundTypeConfig.INTAKE.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Link from 'next/link'
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
import { Workflow, ArrowRight } from 'lucide-react'
|
import { Workflow, ArrowRight, ChevronRight } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
PipelineRoundNode,
|
PipelineRoundNode,
|
||||||
type PipelineRound,
|
type PipelineRound,
|
||||||
@@ -12,27 +12,46 @@ import {
|
|||||||
|
|
||||||
function Connector({
|
function Connector({
|
||||||
prevStatus,
|
prevStatus,
|
||||||
|
nextStatus,
|
||||||
index,
|
index,
|
||||||
}: {
|
}: {
|
||||||
prevStatus: string
|
prevStatus: string
|
||||||
|
nextStatus: string
|
||||||
index: number
|
index: number
|
||||||
}) {
|
}) {
|
||||||
const isCompleted =
|
const isCompleted =
|
||||||
prevStatus === 'ROUND_CLOSED' || prevStatus === 'ROUND_ARCHIVED'
|
prevStatus === 'ROUND_CLOSED' || prevStatus === 'ROUND_ARCHIVED'
|
||||||
|
const isNextActive = nextStatus === 'ROUND_ACTIVE'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scaleX: 0 }}
|
initial={{ scaleX: 0, opacity: 0 }}
|
||||||
animate={{ scaleX: 1 }}
|
animate={{ scaleX: 1, opacity: 1 }}
|
||||||
transition={{ duration: 0.25, delay: 0.15 + index * 0.06 }}
|
transition={{ duration: 0.3, delay: 0.15 + index * 0.06 }}
|
||||||
className="flex items-center self-center origin-left"
|
className="flex items-center self-center origin-left px-0.5"
|
||||||
>
|
>
|
||||||
<div
|
<div className="flex items-center gap-0">
|
||||||
className={cn(
|
<div
|
||||||
'h-0.5 w-6',
|
className={cn(
|
||||||
isCompleted ? 'bg-emerald-300' : 'bg-slate-200'
|
'h-0.5 w-5 transition-colors',
|
||||||
)}
|
isCompleted
|
||||||
/>
|
? 'bg-emerald-400'
|
||||||
|
: isNextActive
|
||||||
|
? 'bg-blue-300'
|
||||||
|
: 'bg-slate-200'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
'h-3.5 w-3.5 -ml-1',
|
||||||
|
isCompleted
|
||||||
|
? 'text-emerald-400'
|
||||||
|
: isNextActive
|
||||||
|
? 'text-blue-300'
|
||||||
|
: 'text-slate-200'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -88,15 +107,17 @@ export function CompetitionPipeline({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="px-4 pb-4">
|
||||||
<div className="overflow-x-auto pb-2">
|
{/* Scrollable container with padding to prevent cutoff */}
|
||||||
<div className="flex items-start gap-0 min-w-max">
|
<div className="overflow-x-auto -mx-1 px-1 pt-2 pb-3">
|
||||||
|
<div className="flex items-center gap-0 min-w-max">
|
||||||
{rounds.map((round, index) => (
|
{rounds.map((round, index) => (
|
||||||
<div key={round.id} className="flex items-start">
|
<div key={round.id} className="flex items-center">
|
||||||
<PipelineRoundNode round={round} index={index} />
|
<PipelineRoundNode round={round} index={index} />
|
||||||
{index < rounds.length - 1 && (
|
{index < rounds.length - 1 && (
|
||||||
<Connector
|
<Connector
|
||||||
prevStatus={round.status}
|
prevStatus={round.status}
|
||||||
|
nextStatus={rounds[index + 1].status}
|
||||||
index={index}
|
index={index}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,14 +5,9 @@ import type { Route } from 'next'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
import {
|
import {
|
||||||
Upload,
|
roundTypeConfig as sharedRoundTypeConfig,
|
||||||
Filter,
|
roundStatusConfig as sharedRoundStatusConfig,
|
||||||
ClipboardCheck,
|
} from '@/lib/round-config'
|
||||||
FileUp,
|
|
||||||
GraduationCap,
|
|
||||||
Radio,
|
|
||||||
Scale,
|
|
||||||
} from 'lucide-react'
|
|
||||||
|
|
||||||
type PipelineRound = {
|
type PipelineRound = {
|
||||||
id: string
|
id: string
|
||||||
@@ -55,66 +50,11 @@ type PipelineRound = {
|
|||||||
deliberationCount: number
|
deliberationCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const roundTypeConfig: Record<
|
const roundTypeConfig = sharedRoundTypeConfig
|
||||||
string,
|
|
||||||
{ icon: typeof Upload; iconColor: string; iconBg: string }
|
|
||||||
> = {
|
|
||||||
INTAKE: { icon: Upload, iconColor: 'text-sky-600', iconBg: 'bg-sky-100' },
|
|
||||||
FILTERING: {
|
|
||||||
icon: Filter,
|
|
||||||
iconColor: 'text-amber-600',
|
|
||||||
iconBg: 'bg-amber-100',
|
|
||||||
},
|
|
||||||
EVALUATION: {
|
|
||||||
icon: ClipboardCheck,
|
|
||||||
iconColor: 'text-violet-600',
|
|
||||||
iconBg: 'bg-violet-100',
|
|
||||||
},
|
|
||||||
SUBMISSION: {
|
|
||||||
icon: FileUp,
|
|
||||||
iconColor: 'text-blue-600',
|
|
||||||
iconBg: 'bg-blue-100',
|
|
||||||
},
|
|
||||||
MENTORING: {
|
|
||||||
icon: GraduationCap,
|
|
||||||
iconColor: 'text-teal-600',
|
|
||||||
iconBg: 'bg-teal-100',
|
|
||||||
},
|
|
||||||
LIVE_FINAL: {
|
|
||||||
icon: Radio,
|
|
||||||
iconColor: 'text-red-600',
|
|
||||||
iconBg: 'bg-red-100',
|
|
||||||
},
|
|
||||||
DELIBERATION: {
|
|
||||||
icon: Scale,
|
|
||||||
iconColor: 'text-indigo-600',
|
|
||||||
iconBg: 'bg-indigo-100',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusStyles: Record<
|
const statusStyles: Record<string, { container: string; label: string }> = Object.fromEntries(
|
||||||
string,
|
Object.entries(sharedRoundStatusConfig).map(([k, v]) => [k, { container: v.pipelineContainer, label: v.label }])
|
||||||
{ container: string; label: string }
|
)
|
||||||
> = {
|
|
||||||
ROUND_DRAFT: {
|
|
||||||
container:
|
|
||||||
'bg-slate-50 border-slate-200 text-slate-400 border-dashed',
|
|
||||||
label: 'Draft',
|
|
||||||
},
|
|
||||||
ROUND_ACTIVE: {
|
|
||||||
container:
|
|
||||||
'bg-blue-50 border-blue-300 text-blue-700 ring-2 ring-blue-400/30 shadow-lg shadow-blue-500/10',
|
|
||||||
label: 'Active',
|
|
||||||
},
|
|
||||||
ROUND_CLOSED: {
|
|
||||||
container: 'bg-emerald-50 border-emerald-200 text-emerald-600',
|
|
||||||
label: 'Closed',
|
|
||||||
},
|
|
||||||
ROUND_ARCHIVED: {
|
|
||||||
container: 'bg-slate-50/50 border-slate-100 text-slate-300',
|
|
||||||
label: 'Archived',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMetric(round: PipelineRound): string {
|
function getMetric(round: PipelineRound): string {
|
||||||
const { roundType, projectStates, filteringTotal, filteringPassed, evalTotal, evalSubmitted, assignmentCount, liveSessionStatus, deliberationCount } = round
|
const { roundType, projectStates, filteringTotal, filteringPassed, evalTotal, evalSubmitted, assignmentCount, liveSessionStatus, deliberationCount } = round
|
||||||
@@ -147,6 +87,30 @@ function getMetric(round: PipelineRound): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProgressPct(round: PipelineRound): number | null {
|
||||||
|
if (round.status !== 'ROUND_ACTIVE') return null
|
||||||
|
|
||||||
|
switch (round.roundType) {
|
||||||
|
case 'FILTERING': {
|
||||||
|
const processed = round.filteringPassed + round.filteringRejected + round.filteringFlagged
|
||||||
|
const total = round.projectStates.total || round.filteringTotal
|
||||||
|
return total > 0 ? Math.round((processed / total) * 100) : 0
|
||||||
|
}
|
||||||
|
case 'EVALUATION':
|
||||||
|
return round.evalTotal > 0 ? Math.round((round.evalSubmitted / round.evalTotal) * 100) : 0
|
||||||
|
case 'SUBMISSION': {
|
||||||
|
const total = round.projectStates.total
|
||||||
|
return total > 0 ? Math.round((round.projectStates.COMPLETED / total) * 100) : 0
|
||||||
|
}
|
||||||
|
case 'MENTORING': {
|
||||||
|
const total = round.projectStates.total
|
||||||
|
return total > 0 ? Math.round((round.projectStates.COMPLETED / total) * 100) : 0
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function PipelineRoundNode({
|
export function PipelineRoundNode({
|
||||||
round,
|
round,
|
||||||
index,
|
index,
|
||||||
@@ -158,7 +122,9 @@ export function PipelineRoundNode({
|
|||||||
const Icon = typeConfig.icon
|
const Icon = typeConfig.icon
|
||||||
const status = statusStyles[round.status] ?? statusStyles.ROUND_DRAFT
|
const status = statusStyles[round.status] ?? statusStyles.ROUND_DRAFT
|
||||||
const isActive = round.status === 'ROUND_ACTIVE'
|
const isActive = round.status === 'ROUND_ACTIVE'
|
||||||
|
const isCompleted = round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED'
|
||||||
const metric = getMetric(round)
|
const metric = getMetric(round)
|
||||||
|
const progressPct = getProgressPct(round)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -172,8 +138,8 @@ export function PipelineRoundNode({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex flex-col items-center rounded-xl border-2 p-3 transition-all hover:-translate-y-0.5 hover:shadow-md',
|
'relative flex flex-col items-center rounded-xl border-2 transition-all hover:-translate-y-0.5 hover:shadow-md',
|
||||||
isActive ? 'w-44' : 'w-36',
|
isActive ? 'w-48 px-4 py-4' : 'w-40 px-3 py-3.5',
|
||||||
status.container
|
status.container
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -185,30 +151,64 @@ export function PipelineRoundNode({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Completed check */}
|
||||||
|
{isCompleted && (
|
||||||
|
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-emerald-500 text-white">
|
||||||
|
<svg className="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-8 w-8 items-center justify-center rounded-lg',
|
'flex items-center justify-center rounded-lg',
|
||||||
|
isActive ? 'h-10 w-10' : 'h-9 w-9',
|
||||||
typeConfig.iconBg
|
typeConfig.iconBg
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className={cn('h-4 w-4', typeConfig.iconColor)} />
|
<Icon className={cn(isActive ? 'h-5 w-5' : 'h-4 w-4', typeConfig.iconColor)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<p className="mt-2 text-center text-xs font-semibold leading-tight line-clamp-2 group-hover:text-foreground transition-colors">
|
<p className="mt-2.5 text-center text-xs font-semibold leading-tight line-clamp-2 group-hover:text-foreground transition-colors">
|
||||||
{round.name}
|
{round.name}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Type label */}
|
||||||
|
<span className="mt-1 text-[10px] font-medium text-muted-foreground/70">
|
||||||
|
{typeConfig.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* Status label */}
|
{/* Status label */}
|
||||||
<span className="mt-1.5 text-[10px] font-medium uppercase tracking-wider opacity-70">
|
<span className="mt-1 text-[10px] font-semibold uppercase tracking-wider opacity-70">
|
||||||
{status.label}
|
{status.label}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* Progress bar for active rounds */}
|
||||||
|
{progressPct !== null && (
|
||||||
|
<div className="mt-2 w-full">
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-black/5">
|
||||||
|
<motion.div
|
||||||
|
className="h-full rounded-full bg-blue-500"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${progressPct}%` }}
|
||||||
|
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-center text-[10px] font-medium tabular-nums text-blue-600">
|
||||||
|
{progressPct}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Metric */}
|
{/* Metric */}
|
||||||
<p className="mt-1 text-[11px] font-medium tabular-nums opacity-80">
|
{progressPct === null && (
|
||||||
{metric}
|
<p className="mt-1.5 text-[11px] font-medium tabular-nums opacity-80">
|
||||||
</p>
|
{metric}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -13,24 +13,41 @@ import {
|
|||||||
import { StatusBadge } from '@/components/shared/status-badge'
|
import { StatusBadge } from '@/components/shared/status-badge'
|
||||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||||
import { getCountryName } from '@/lib/countries'
|
import { getCountryName } from '@/lib/countries'
|
||||||
import { formatDateOnly, truncate } from '@/lib/utils'
|
import { formatDateOnly, truncate, formatRelativeTime } from '@/lib/utils'
|
||||||
|
|
||||||
type ProjectListCompactProps = {
|
type BaseProject = {
|
||||||
projects: Array<{
|
id: string
|
||||||
id: string
|
title: string
|
||||||
title: string
|
teamName: string | null
|
||||||
teamName: string | null
|
country: string | null
|
||||||
country: string | null
|
competitionCategory: string | null
|
||||||
competitionCategory: string | null
|
oceanIssue: string | null
|
||||||
oceanIssue: string | null
|
logoKey: string | null
|
||||||
logoKey: string | null
|
createdAt: Date
|
||||||
createdAt: Date
|
submittedAt: Date | null
|
||||||
submittedAt: Date | null
|
status: string
|
||||||
status: string
|
|
||||||
}>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectListCompact({ projects }: ProjectListCompactProps) {
|
type ActiveProject = BaseProject & {
|
||||||
|
latestEvaluator: string | null
|
||||||
|
latestScore: number | null
|
||||||
|
evaluatedAt: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectListCompactProps = {
|
||||||
|
projects: BaseProject[]
|
||||||
|
activeProjects?: ActiveProject[]
|
||||||
|
mode?: 'recent' | 'active'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectListCompact({
|
||||||
|
projects,
|
||||||
|
activeProjects,
|
||||||
|
mode = 'recent',
|
||||||
|
}: ProjectListCompactProps) {
|
||||||
|
const isActiveMode = mode === 'active' && activeProjects && activeProjects.length > 0
|
||||||
|
const displayProjects = isActiveMode ? activeProjects : projects
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -40,8 +57,12 @@ export function ProjectListCompact({ projects }: ProjectListCompactProps) {
|
|||||||
<ClipboardList className="h-4 w-4 text-brand-blue" />
|
<ClipboardList className="h-4 w-4 text-brand-blue" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">Recent Projects</CardTitle>
|
<CardTitle className="text-base">
|
||||||
<CardDescription className="text-xs">Latest submissions</CardDescription>
|
{isActiveMode ? 'Recently Active' : 'Recent Projects'}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
{isActiveMode ? 'Latest evaluation activity' : 'Latest submissions'}
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
@@ -53,7 +74,7 @@ export function ProjectListCompact({ projects }: ProjectListCompactProps) {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{projects.length === 0 ? (
|
{displayProjects.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted">
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted">
|
||||||
<ClipboardList className="h-7 w-7 text-muted-foreground/40" />
|
<ClipboardList className="h-7 w-7 text-muted-foreground/40" />
|
||||||
@@ -64,48 +85,69 @@ export function ProjectListCompact({ projects }: ProjectListCompactProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{projects.map((project, idx) => (
|
{displayProjects.map((project, idx) => {
|
||||||
<motion.div
|
const activeProject = isActiveMode ? (project as ActiveProject) : null
|
||||||
key={project.id}
|
|
||||||
initial={{ opacity: 0 }}
|
return (
|
||||||
animate={{ opacity: 1 }}
|
<motion.div
|
||||||
transition={{ duration: 0.25, delay: 0.15 + idx * 0.04 }}
|
key={project.id}
|
||||||
>
|
initial={{ opacity: 0 }}
|
||||||
<Link
|
animate={{ opacity: 1 }}
|
||||||
href={`/admin/projects/${project.id}`}
|
transition={{ duration: 0.25, delay: 0.15 + idx * 0.04 }}
|
||||||
className="block"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 py-3 px-1 transition-colors hover:bg-muted/40 rounded-lg group">
|
<Link
|
||||||
<ProjectLogo
|
href={`/admin/projects/${project.id}`}
|
||||||
project={project}
|
className="block"
|
||||||
size="sm"
|
>
|
||||||
fallback="initials"
|
<div className="flex items-center gap-3 py-3 px-1 transition-colors hover:bg-muted/40 rounded-lg group">
|
||||||
/>
|
<ProjectLogo
|
||||||
<div className="flex-1 min-w-0">
|
project={project}
|
||||||
<div className="flex items-center justify-between gap-2">
|
size="sm"
|
||||||
<p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
|
fallback="initials"
|
||||||
{truncate(project.title, 50)}
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
|
||||||
|
{truncate(project.title, 50)}
|
||||||
|
</p>
|
||||||
|
{activeProject?.latestScore != null ? (
|
||||||
|
<span className="shrink-0 text-xs font-semibold tabular-nums text-brand-blue">
|
||||||
|
{activeProject.latestScore.toFixed(1)}/10
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<StatusBadge
|
||||||
|
status={project.status ?? 'SUBMITTED'}
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{isActiveMode && activeProject ? (
|
||||||
|
<>
|
||||||
|
{activeProject.latestEvaluator && (
|
||||||
|
<span>{activeProject.latestEvaluator}</span>
|
||||||
|
)}
|
||||||
|
{activeProject.evaluatedAt && (
|
||||||
|
<span> · {formatRelativeTime(activeProject.evaluatedAt)}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
[
|
||||||
|
project.teamName,
|
||||||
|
project.country ? getCountryName(project.country) : null,
|
||||||
|
formatDateOnly(project.submittedAt || project.createdAt),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' \u00b7 ')
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<StatusBadge
|
|
||||||
status={project.status ?? 'SUBMITTED'}
|
|
||||||
size="sm"
|
|
||||||
className="shrink-0"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{[
|
|
||||||
project.teamName,
|
|
||||||
project.country ? getCountryName(project.country) : null,
|
|
||||||
formatDateOnly(project.submittedAt || project.createdAt),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' \u00b7 ')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
</Link>
|
</motion.div>
|
||||||
</motion.div>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
99
src/components/dashboard/round-stats-deliberation.tsx
Normal file
99
src/components/dashboard/round-stats-deliberation.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
|
||||||
|
type PipelineRound = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
roundType: string
|
||||||
|
status: string
|
||||||
|
projectStates: {
|
||||||
|
PENDING: number
|
||||||
|
IN_PROGRESS: number
|
||||||
|
PASSED: number
|
||||||
|
REJECTED: number
|
||||||
|
COMPLETED: number
|
||||||
|
WITHDRAWN: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
deliberationCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoundStatsDeliberationProps = {
|
||||||
|
round: PipelineRound
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundStatsDeliberation({ round }: RoundStatsDeliberationProps) {
|
||||||
|
const { projectStates, deliberationCount } = round
|
||||||
|
const decided = projectStates.PASSED + projectStates.REJECTED
|
||||||
|
const decidedPct = projectStates.total > 0
|
||||||
|
? ((decided / projectStates.total) * 100).toFixed(0)
|
||||||
|
: '0'
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
value: deliberationCount,
|
||||||
|
label: 'Sessions',
|
||||||
|
detail: deliberationCount > 0 ? 'Deliberation sessions' : 'No sessions yet',
|
||||||
|
accent: deliberationCount > 0 ? 'text-brand-blue' : 'text-amber-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: projectStates.total,
|
||||||
|
label: 'Under review',
|
||||||
|
detail: 'Projects in deliberation',
|
||||||
|
accent: 'text-brand-blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: decided,
|
||||||
|
label: 'Decided',
|
||||||
|
detail: `${decidedPct}% resolved`,
|
||||||
|
accent: 'text-emerald-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: projectStates.PENDING,
|
||||||
|
label: 'Pending',
|
||||||
|
detail: projectStates.PENDING > 0 ? 'Awaiting vote' : 'All voted',
|
||||||
|
accent: projectStates.PENDING > 0 ? 'text-amber-600' : 'text-emerald-600',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||||
|
{round.name} — Deliberation
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
|
||||||
|
>
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||||
|
<span className="text-xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||||
|
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
||||||
|
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
src/components/dashboard/round-stats-live-final.tsx
Normal file
105
src/components/dashboard/round-stats-live-final.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { formatEnumLabel } from '@/lib/utils'
|
||||||
|
|
||||||
|
type PipelineRound = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
roundType: string
|
||||||
|
status: string
|
||||||
|
projectStates: {
|
||||||
|
PENDING: number
|
||||||
|
IN_PROGRESS: number
|
||||||
|
PASSED: number
|
||||||
|
REJECTED: number
|
||||||
|
COMPLETED: number
|
||||||
|
WITHDRAWN: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
liveSessionStatus: string | null
|
||||||
|
assignmentCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoundStatsLiveFinalProps = {
|
||||||
|
round: PipelineRound
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundStatsLiveFinal({ round }: RoundStatsLiveFinalProps) {
|
||||||
|
const { projectStates, liveSessionStatus, assignmentCount } = round
|
||||||
|
const sessionLabel = liveSessionStatus
|
||||||
|
? formatEnumLabel(liveSessionStatus)
|
||||||
|
: 'Not started'
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
value: projectStates.total,
|
||||||
|
label: 'Presenting',
|
||||||
|
detail: 'Projects in finals',
|
||||||
|
accent: 'text-brand-blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: sessionLabel,
|
||||||
|
label: 'Session',
|
||||||
|
detail: liveSessionStatus ? 'Live session active' : 'No session yet',
|
||||||
|
accent: liveSessionStatus ? 'text-emerald-600' : 'text-amber-600',
|
||||||
|
isText: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: projectStates.COMPLETED,
|
||||||
|
label: 'Scored',
|
||||||
|
detail: `${projectStates.total - projectStates.COMPLETED} remaining`,
|
||||||
|
accent: 'text-emerald-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: assignmentCount,
|
||||||
|
label: 'Jury votes',
|
||||||
|
detail: 'Jury assignments',
|
||||||
|
accent: 'text-brand-teal',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||||
|
{round.name} — Live Finals
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
|
||||||
|
>
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||||
|
<span className={`font-bold tabular-nums tracking-tight ${(s as any).isText ? 'text-sm' : 'text-xl'}`}>
|
||||||
|
{s.value}
|
||||||
|
</span>
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||||
|
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`font-bold tabular-nums tracking-tight ${(s as any).isText ? 'text-lg' : 'text-3xl'}`}>
|
||||||
|
{s.value}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
||||||
|
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
99
src/components/dashboard/round-stats-mentoring.tsx
Normal file
99
src/components/dashboard/round-stats-mentoring.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
|
||||||
|
type PipelineRound = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
roundType: string
|
||||||
|
status: string
|
||||||
|
projectStates: {
|
||||||
|
PENDING: number
|
||||||
|
IN_PROGRESS: number
|
||||||
|
PASSED: number
|
||||||
|
REJECTED: number
|
||||||
|
COMPLETED: number
|
||||||
|
WITHDRAWN: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
assignmentCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoundStatsMentoringProps = {
|
||||||
|
round: PipelineRound
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundStatsMentoring({ round }: RoundStatsMentoringProps) {
|
||||||
|
const { projectStates, assignmentCount } = round
|
||||||
|
const withMentor = projectStates.IN_PROGRESS + projectStates.COMPLETED + projectStates.PASSED
|
||||||
|
const completedPct = projectStates.total > 0
|
||||||
|
? ((projectStates.COMPLETED / projectStates.total) * 100).toFixed(0)
|
||||||
|
: '0'
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
value: assignmentCount,
|
||||||
|
label: 'Assignments',
|
||||||
|
detail: 'Mentor-project pairs',
|
||||||
|
accent: 'text-brand-blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: withMentor,
|
||||||
|
label: 'With mentor',
|
||||||
|
detail: withMentor > 0 ? 'Actively mentored' : 'None assigned',
|
||||||
|
accent: withMentor > 0 ? 'text-emerald-600' : 'text-amber-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: projectStates.COMPLETED,
|
||||||
|
label: 'Completed',
|
||||||
|
detail: `${completedPct}% done`,
|
||||||
|
accent: 'text-emerald-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: projectStates.total,
|
||||||
|
label: 'Total',
|
||||||
|
detail: 'Projects in round',
|
||||||
|
accent: 'text-brand-teal',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||||
|
{round.name} — Mentoring
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
|
||||||
|
>
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||||
|
<span className="text-xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||||
|
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
||||||
|
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
109
src/components/dashboard/round-stats-submission.tsx
Normal file
109
src/components/dashboard/round-stats-submission.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { daysUntil } from '@/lib/utils'
|
||||||
|
|
||||||
|
type PipelineRound = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
roundType: string
|
||||||
|
status: string
|
||||||
|
projectStates: {
|
||||||
|
PENDING: number
|
||||||
|
IN_PROGRESS: number
|
||||||
|
PASSED: number
|
||||||
|
REJECTED: number
|
||||||
|
COMPLETED: number
|
||||||
|
WITHDRAWN: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
windowCloseAt: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoundStatsSubmissionProps = {
|
||||||
|
round: PipelineRound
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundStatsSubmission({ round }: RoundStatsSubmissionProps) {
|
||||||
|
const { projectStates } = round
|
||||||
|
const completedPct = projectStates.total > 0
|
||||||
|
? ((projectStates.COMPLETED / projectStates.total) * 100).toFixed(0)
|
||||||
|
: '0'
|
||||||
|
|
||||||
|
const deadlineDays = round.windowCloseAt ? daysUntil(new Date(round.windowCloseAt)) : null
|
||||||
|
const deadlineLabel =
|
||||||
|
deadlineDays === null
|
||||||
|
? 'No deadline'
|
||||||
|
: deadlineDays <= 0
|
||||||
|
? 'Closed'
|
||||||
|
: deadlineDays === 1
|
||||||
|
? '1 day left'
|
||||||
|
: `${deadlineDays} days left`
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
value: projectStates.total,
|
||||||
|
label: 'In round',
|
||||||
|
detail: 'Total projects',
|
||||||
|
accent: 'text-brand-blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: projectStates.COMPLETED,
|
||||||
|
label: 'Completed',
|
||||||
|
detail: `${completedPct}% done`,
|
||||||
|
accent: 'text-emerald-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: projectStates.IN_PROGRESS,
|
||||||
|
label: 'In progress',
|
||||||
|
detail: projectStates.IN_PROGRESS > 0 ? 'Working on submissions' : 'None in progress',
|
||||||
|
accent: projectStates.IN_PROGRESS > 0 ? 'text-amber-600' : 'text-emerald-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: deadlineDays ?? '—',
|
||||||
|
label: 'Deadline',
|
||||||
|
detail: deadlineLabel,
|
||||||
|
accent: deadlineDays !== null && deadlineDays <= 3 ? 'text-red-600' : 'text-brand-teal',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||||
|
{round.name} — Submission
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
|
||||||
|
>
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||||
|
<span className="text-xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||||
|
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
||||||
|
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
|
|
||||||
type RoundStatsGenericProps = {
|
type RoundStatsSummaryProps = {
|
||||||
projectCount: number
|
projectCount: number
|
||||||
newProjectsThisWeek: number
|
newProjectsThisWeek: number
|
||||||
totalJurors: number
|
totalJurors: number
|
||||||
@@ -10,52 +10,58 @@ type RoundStatsGenericProps = {
|
|||||||
totalAssignments: number
|
totalAssignments: number
|
||||||
evaluationStats: Array<{ status: string; _count: number }>
|
evaluationStats: Array<{ status: string; _count: number }>
|
||||||
actionsCount: number
|
actionsCount: number
|
||||||
|
nextDraftRound?: { name: string; roundType: string } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoundStatsGeneric({
|
export function RoundStatsSummary({
|
||||||
projectCount,
|
projectCount,
|
||||||
newProjectsThisWeek,
|
|
||||||
totalJurors,
|
totalJurors,
|
||||||
activeJurors,
|
activeJurors,
|
||||||
totalAssignments,
|
totalAssignments,
|
||||||
evaluationStats,
|
evaluationStats,
|
||||||
actionsCount,
|
actionsCount,
|
||||||
}: RoundStatsGenericProps) {
|
nextDraftRound,
|
||||||
|
}: RoundStatsSummaryProps) {
|
||||||
const submittedCount =
|
const submittedCount =
|
||||||
evaluationStats.find((e) => e.status === 'SUBMITTED')?._count ?? 0
|
evaluationStats.find((e) => e.status === 'SUBMITTED')?._count ?? 0
|
||||||
const completionPct =
|
const completionPct =
|
||||||
totalAssignments > 0 ? ((submittedCount / totalAssignments) * 100).toFixed(0) : '0'
|
totalAssignments > 0 ? ((submittedCount / totalAssignments) * 100).toFixed(0) : '—'
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{
|
{
|
||||||
value: projectCount,
|
value: projectCount,
|
||||||
label: 'Projects',
|
label: 'Total projects',
|
||||||
detail: newProjectsThisWeek > 0 ? `+${newProjectsThisWeek} this week` : null,
|
detail: 'In this edition',
|
||||||
accent: 'text-brand-blue',
|
accent: 'text-brand-blue',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: totalJurors,
|
value: `${activeJurors}/${totalJurors}`,
|
||||||
label: 'Jurors',
|
label: 'Jury coverage',
|
||||||
detail: `${activeJurors} active`,
|
detail: totalJurors > 0 ? `${activeJurors} active jurors` : 'No jurors assigned',
|
||||||
accent: 'text-brand-teal',
|
accent: 'text-brand-teal',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: `${submittedCount}/${totalAssignments}`,
|
value: totalAssignments > 0 ? `${completionPct}%` : '—',
|
||||||
label: 'Evaluations',
|
label: 'Completion',
|
||||||
detail: `${completionPct}% complete`,
|
detail: totalAssignments > 0 ? `${submittedCount}/${totalAssignments} evaluations` : 'No evaluations yet',
|
||||||
accent: 'text-emerald-600',
|
accent: 'text-emerald-600',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: actionsCount,
|
value: actionsCount,
|
||||||
label: actionsCount === 1 ? 'Action' : 'Actions',
|
label: actionsCount === 1 ? 'Action' : 'Actions',
|
||||||
detail: actionsCount > 0 ? 'Pending' : 'All clear',
|
detail: nextDraftRound
|
||||||
|
? `Next: ${nextDraftRound.name}`
|
||||||
|
: actionsCount > 0 ? 'Pending' : 'All clear',
|
||||||
accent: actionsCount > 0 ? 'text-amber-600' : 'text-emerald-600',
|
accent: actionsCount > 0 ? 'text-amber-600' : 'text-emerald-600',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile: horizontal data strip */}
|
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||||
|
No active round — Competition Summary
|
||||||
|
</p>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
@@ -70,7 +76,6 @@ export function RoundStatsGeneric({
|
|||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Desktop: editorial stat row */}
|
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||||
{stats.map((s, i) => (
|
{stats.map((s, i) => (
|
||||||
@@ -81,13 +86,9 @@ export function RoundStatsGeneric({
|
|||||||
transition={{ duration: 0.3, delay: i * 0.06 }}
|
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||||
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-baseline gap-2">
|
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||||
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
||||||
{s.detail && (
|
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
||||||
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -3,7 +3,11 @@
|
|||||||
import { RoundStatsIntake } from '@/components/dashboard/round-stats-intake'
|
import { RoundStatsIntake } from '@/components/dashboard/round-stats-intake'
|
||||||
import { RoundStatsFiltering } from '@/components/dashboard/round-stats-filtering'
|
import { RoundStatsFiltering } from '@/components/dashboard/round-stats-filtering'
|
||||||
import { RoundStatsEvaluation } from '@/components/dashboard/round-stats-evaluation'
|
import { RoundStatsEvaluation } from '@/components/dashboard/round-stats-evaluation'
|
||||||
import { RoundStatsGeneric } from '@/components/dashboard/round-stats-generic'
|
import { RoundStatsSubmission } from '@/components/dashboard/round-stats-submission'
|
||||||
|
import { RoundStatsMentoring } from '@/components/dashboard/round-stats-mentoring'
|
||||||
|
import { RoundStatsLiveFinal } from '@/components/dashboard/round-stats-live-final'
|
||||||
|
import { RoundStatsDeliberation } from '@/components/dashboard/round-stats-deliberation'
|
||||||
|
import { RoundStatsSummary } from '@/components/dashboard/round-stats-summary'
|
||||||
|
|
||||||
type PipelineRound = {
|
type PipelineRound = {
|
||||||
id: string
|
id: string
|
||||||
@@ -37,6 +41,7 @@ type PipelineRound = {
|
|||||||
|
|
||||||
type RoundStatsProps = {
|
type RoundStatsProps = {
|
||||||
activeRound: PipelineRound | null
|
activeRound: PipelineRound | null
|
||||||
|
allActiveRounds?: PipelineRound[]
|
||||||
projectCount: number
|
projectCount: number
|
||||||
newProjectsThisWeek: number
|
newProjectsThisWeek: number
|
||||||
totalJurors: number
|
totalJurors: number
|
||||||
@@ -44,6 +49,7 @@ type RoundStatsProps = {
|
|||||||
totalAssignments: number
|
totalAssignments: number
|
||||||
evaluationStats: Array<{ status: string; _count: number }>
|
evaluationStats: Array<{ status: string; _count: number }>
|
||||||
actionsCount: number
|
actionsCount: number
|
||||||
|
nextDraftRound?: { name: string; roundType: string } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoundStats({
|
export function RoundStats({
|
||||||
@@ -55,10 +61,11 @@ export function RoundStats({
|
|||||||
totalAssignments,
|
totalAssignments,
|
||||||
evaluationStats,
|
evaluationStats,
|
||||||
actionsCount,
|
actionsCount,
|
||||||
|
nextDraftRound,
|
||||||
}: RoundStatsProps) {
|
}: RoundStatsProps) {
|
||||||
if (!activeRound) {
|
if (!activeRound) {
|
||||||
return (
|
return (
|
||||||
<RoundStatsGeneric
|
<RoundStatsSummary
|
||||||
projectCount={projectCount}
|
projectCount={projectCount}
|
||||||
newProjectsThisWeek={newProjectsThisWeek}
|
newProjectsThisWeek={newProjectsThisWeek}
|
||||||
totalJurors={totalJurors}
|
totalJurors={totalJurors}
|
||||||
@@ -66,6 +73,7 @@ export function RoundStats({
|
|||||||
totalAssignments={totalAssignments}
|
totalAssignments={totalAssignments}
|
||||||
evaluationStats={evaluationStats}
|
evaluationStats={evaluationStats}
|
||||||
actionsCount={actionsCount}
|
actionsCount={actionsCount}
|
||||||
|
nextDraftRound={nextDraftRound}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -89,9 +97,25 @@ export function RoundStats({
|
|||||||
activeJurors={activeJurors}
|
activeJurors={activeJurors}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
case 'SUBMISSION':
|
||||||
|
return (
|
||||||
|
<RoundStatsSubmission round={activeRound} />
|
||||||
|
)
|
||||||
|
case 'MENTORING':
|
||||||
|
return (
|
||||||
|
<RoundStatsMentoring round={activeRound} />
|
||||||
|
)
|
||||||
|
case 'LIVE_FINAL':
|
||||||
|
return (
|
||||||
|
<RoundStatsLiveFinal round={activeRound} />
|
||||||
|
)
|
||||||
|
case 'DELIBERATION':
|
||||||
|
return (
|
||||||
|
<RoundStatsDeliberation round={activeRound} />
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<RoundStatsGeneric
|
<RoundStatsSummary
|
||||||
projectCount={projectCount}
|
projectCount={projectCount}
|
||||||
newProjectsThisWeek={newProjectsThisWeek}
|
newProjectsThisWeek={newProjectsThisWeek}
|
||||||
totalJurors={totalJurors}
|
totalJurors={totalJurors}
|
||||||
@@ -99,6 +123,7 @@ export function RoundStats({
|
|||||||
totalAssignments={totalAssignments}
|
totalAssignments={totalAssignments}
|
||||||
evaluationStats={evaluationStats}
|
evaluationStats={evaluationStats}
|
||||||
actionsCount={actionsCount}
|
actionsCount={actionsCount}
|
||||||
|
nextDraftRound={nextDraftRound}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Loader2, ShieldAlert } from 'lucide-react'
|
import { AlertTriangle, Loader2, ShieldAlert } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
interface COIDeclarationDialogProps {
|
interface COIDeclarationDialogProps {
|
||||||
@@ -39,22 +39,31 @@ export function COIDeclarationDialog({
|
|||||||
const [hasConflict, setHasConflict] = useState<boolean | null>(null)
|
const [hasConflict, setHasConflict] = useState<boolean | null>(null)
|
||||||
const [conflictType, setConflictType] = useState<string>('')
|
const [conflictType, setConflictType] = useState<string>('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
|
const [showConfirmation, setShowConfirmation] = useState(false)
|
||||||
|
|
||||||
const declareCOI = trpc.evaluation.declareCOI.useMutation({
|
const declareCOI = trpc.evaluation.declareCOI.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.hasConflict) {
|
if (data.hasConflict) {
|
||||||
toast.info('Conflict of interest recorded. An admin will review your declaration.')
|
toast.info('Conflict of interest recorded. This project will be reassigned to another juror.')
|
||||||
}
|
}
|
||||||
|
setShowConfirmation(false)
|
||||||
onComplete(data.hasConflict)
|
onComplete(data.hasConflict)
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || 'Failed to submit COI declaration')
|
toast.error(error.message || 'Failed to submit COI declaration')
|
||||||
|
setShowConfirmation(false)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (hasConflict === null) return
|
if (hasConflict === null) return
|
||||||
|
|
||||||
|
// If declaring a conflict, show confirmation first
|
||||||
|
if (hasConflict && !showConfirmation) {
|
||||||
|
setShowConfirmation(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
declareCOI.mutate({
|
declareCOI.mutate({
|
||||||
assignmentId,
|
assignmentId,
|
||||||
hasConflict,
|
hasConflict,
|
||||||
@@ -71,91 +80,132 @@ export function COIDeclarationDialog({
|
|||||||
return (
|
return (
|
||||||
<AlertDialog open={open}>
|
<AlertDialog open={open}>
|
||||||
<AlertDialogContent className="max-w-md">
|
<AlertDialogContent className="max-w-md">
|
||||||
<AlertDialogHeader>
|
{showConfirmation ? (
|
||||||
<AlertDialogTitle className="flex items-center gap-2">
|
<>
|
||||||
<ShieldAlert className="h-5 w-5 text-amber-500" />
|
<AlertDialogHeader>
|
||||||
Conflict of Interest Declaration
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
</AlertDialogTitle>
|
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||||
<AlertDialogDescription>
|
Confirm Conflict of Interest
|
||||||
Before evaluating “{projectTitle}”, please declare whether
|
</AlertDialogTitle>
|
||||||
you have any conflict of interest with this project.
|
<AlertDialogDescription asChild>
|
||||||
</AlertDialogDescription>
|
<div className="space-y-3">
|
||||||
</AlertDialogHeader>
|
<p>
|
||||||
|
Are you sure you want to declare a conflict of interest with
|
||||||
<div className="space-y-4 py-2">
|
“{projectTitle}”?
|
||||||
<div className="space-y-3">
|
</p>
|
||||||
<Label className="text-sm font-medium">
|
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||||
Do you have a conflict of interest with this project?
|
<strong>This action cannot be undone.</strong> The project will be
|
||||||
</Label>
|
removed from your assignments and reassigned to another juror.
|
||||||
<div className="flex gap-3">
|
</div>
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
variant="outline"
|
||||||
variant={hasConflict === false ? 'default' : 'outline'}
|
onClick={() => setShowConfirmation(false)}
|
||||||
className="flex-1"
|
disabled={declareCOI.isPending}
|
||||||
onClick={() => setHasConflict(false)}
|
|
||||||
>
|
>
|
||||||
No Conflict
|
Go Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
variant="destructive"
|
||||||
variant={hasConflict === true ? 'destructive' : 'outline'}
|
onClick={handleSubmit}
|
||||||
className="flex-1"
|
disabled={declareCOI.isPending}
|
||||||
onClick={() => setHasConflict(true)}
|
|
||||||
>
|
>
|
||||||
Yes, I Have a Conflict
|
{declareCOI.isPending && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Yes, Confirm COI
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</AlertDialogFooter>
|
||||||
</div>
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<ShieldAlert className="h-5 w-5 text-amber-500" />
|
||||||
|
Conflict of Interest Declaration
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Before evaluating “{projectTitle}”, please declare whether
|
||||||
|
you have any conflict of interest with this project.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
{hasConflict && (
|
<div className="space-y-4 py-2">
|
||||||
<>
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<Label className="text-sm font-medium">
|
||||||
<Label htmlFor="conflict-type">Type of Conflict</Label>
|
Do you have a conflict of interest with this project?
|
||||||
<Select value={conflictType} onValueChange={setConflictType}>
|
|
||||||
<SelectTrigger id="conflict-type">
|
|
||||||
<SelectValue placeholder="Select conflict type..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="financial">Financial Interest</SelectItem>
|
|
||||||
<SelectItem value="personal">Personal Relationship</SelectItem>
|
|
||||||
<SelectItem value="organizational">Organizational Affiliation</SelectItem>
|
|
||||||
<SelectItem value="other">Other</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="conflict-description">
|
|
||||||
Description <span className="text-muted-foreground">(optional)</span>
|
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<div className="flex gap-3">
|
||||||
id="conflict-description"
|
<Button
|
||||||
placeholder="Briefly describe the nature of your conflict..."
|
type="button"
|
||||||
value={description}
|
variant={hasConflict === false ? 'default' : 'outline'}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
className="flex-1"
|
||||||
rows={3}
|
onClick={() => setHasConflict(false)}
|
||||||
maxLength={1000}
|
>
|
||||||
/>
|
No Conflict
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={hasConflict === true ? 'destructive' : 'outline'}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setHasConflict(true)}
|
||||||
|
>
|
||||||
|
Yes, I Have a Conflict
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AlertDialogFooter>
|
{hasConflict && (
|
||||||
<Button
|
<>
|
||||||
onClick={handleSubmit}
|
<div className="space-y-2">
|
||||||
disabled={!canSubmit}
|
<Label htmlFor="conflict-type">Type of Conflict</Label>
|
||||||
>
|
<Select value={conflictType} onValueChange={setConflictType}>
|
||||||
{declareCOI.isPending && (
|
<SelectTrigger id="conflict-type">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<SelectValue placeholder="Select conflict type..." />
|
||||||
)}
|
</SelectTrigger>
|
||||||
{hasConflict === null
|
<SelectContent>
|
||||||
? 'Select an option'
|
<SelectItem value="financial">Financial Interest</SelectItem>
|
||||||
: hasConflict
|
<SelectItem value="personal">Personal Relationship</SelectItem>
|
||||||
? 'Submit Declaration'
|
<SelectItem value="organizational">Organizational Affiliation</SelectItem>
|
||||||
: 'Confirm No Conflict'}
|
<SelectItem value="other">Other</SelectItem>
|
||||||
</Button>
|
</SelectContent>
|
||||||
</AlertDialogFooter>
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="conflict-description">
|
||||||
|
Description <span className="text-muted-foreground">(optional)</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="conflict-description"
|
||||||
|
placeholder="Briefly describe the nature of your conflict..."
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={1000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
>
|
||||||
|
{hasConflict === null
|
||||||
|
? 'Select an option'
|
||||||
|
: hasConflict
|
||||||
|
? 'Submit Declaration'
|
||||||
|
: 'Confirm No Conflict'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -303,7 +303,10 @@ export function EvaluationForm({
|
|||||||
|
|
||||||
// Submit handler
|
// Submit handler
|
||||||
const onSubmit = async (data: EvaluationFormData) => {
|
const onSubmit = async (data: EvaluationFormData) => {
|
||||||
if (!currentEvaluationId) return
|
if (!currentEvaluationId) {
|
||||||
|
toast.error('Evaluation is still being created. Please wait a moment and try again.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await submit.mutateAsync({
|
await submit.mutateAsync({
|
||||||
@@ -325,6 +328,7 @@ export function EvaluationForm({
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Submit failed:', error)
|
console.error('Submit failed:', error)
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to submit evaluation. Please try again.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,7 +363,7 @@ export function EvaluationForm({
|
|||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!isValid || submit.isPending}
|
disabled={!isValid || submit.isPending || startEvaluation.isPending}
|
||||||
>
|
>
|
||||||
{submit.isPending ? (
|
{submit.isPending ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
@@ -678,7 +682,7 @@ export function EvaluationForm({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={!isValid || submit.isPending}
|
disabled={!isValid || submit.isPending || startEvaluation.isPending}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{submit.isPending ? (
|
{submit.isPending ? (
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ import {
|
|||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
Layers,
|
Layers,
|
||||||
Scale,
|
Scale,
|
||||||
|
Eye,
|
||||||
|
ArrowRightLeft,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import type { UserRole } from '@prisma/client'
|
||||||
import { getInitials } from '@/lib/utils'
|
import { getInitials } from '@/lib/utils'
|
||||||
import { Logo } from '@/components/shared/logo'
|
import { Logo } from '@/components/shared/logo'
|
||||||
import { EditionSelector } from '@/components/shared/edition-selector'
|
import { EditionSelector } from '@/components/shared/edition-selector'
|
||||||
@@ -147,12 +150,21 @@ const roleLabels: Record<string, string> = {
|
|||||||
PROGRAM_ADMIN: 'Program Admin',
|
PROGRAM_ADMIN: 'Program Admin',
|
||||||
JURY_MEMBER: 'Jury Member',
|
JURY_MEMBER: 'Jury Member',
|
||||||
OBSERVER: 'Observer',
|
OBSERVER: 'Observer',
|
||||||
|
MENTOR: 'Mentor',
|
||||||
|
AWARD_MASTER: 'Award Master',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role switcher config — maps roles to their dashboard views
|
||||||
|
const ROLE_SWITCH_OPTIONS: Record<string, { label: string; path: string; icon: typeof LayoutDashboard }> = {
|
||||||
|
JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale },
|
||||||
|
MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake },
|
||||||
|
OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye },
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSidebar({ user }: AdminSidebarProps) {
|
export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||||
const { status: sessionStatus } = useSession()
|
const { data: session, status: sessionStatus } = useSession()
|
||||||
const isAuthenticated = sessionStatus === 'authenticated'
|
const isAuthenticated = sessionStatus === 'authenticated'
|
||||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
@@ -162,6 +174,12 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||||||
const isSuperAdmin = user.role === 'SUPER_ADMIN'
|
const isSuperAdmin = user.role === 'SUPER_ADMIN'
|
||||||
const roleLabel = roleLabels[user.role || ''] || 'User'
|
const roleLabel = roleLabels[user.role || ''] || 'User'
|
||||||
|
|
||||||
|
// Roles the user can switch to (non-admin roles they hold)
|
||||||
|
const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? []
|
||||||
|
const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS).filter(
|
||||||
|
([role]) => userRoles.includes(role as UserRole)
|
||||||
|
)
|
||||||
|
|
||||||
// Build dynamic admin nav with current edition's apply page
|
// Build dynamic admin nav with current edition's apply page
|
||||||
const dynamicAdminNav = adminNavigation.map((item) => {
|
const dynamicAdminNav = adminNavigation.map((item) => {
|
||||||
if (item.name === 'Apply Page' && currentEdition?.id) {
|
if (item.name === 'Apply Page' && currentEdition?.id) {
|
||||||
@@ -226,8 +244,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||||||
{navigation.map((item) => {
|
{navigation.map((item) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
pathname === item.href ||
|
pathname === item.href ||
|
||||||
(item.href !== '/admin' && pathname.startsWith(item.href)) ||
|
(item.href !== '/admin' && pathname.startsWith(item.href))
|
||||||
(item.href === '/admin/rounds' && pathname.startsWith('/admin/competitions'))
|
|
||||||
return (
|
return (
|
||||||
<div key={item.name}>
|
<div key={item.name}>
|
||||||
<Link
|
<Link
|
||||||
@@ -259,24 +276,12 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||||||
Administration
|
Administration
|
||||||
</p>
|
</p>
|
||||||
{dynamicAdminNav.map((item) => {
|
{dynamicAdminNav.map((item) => {
|
||||||
const isDisabled = item.name === 'Apply Page' && !currentEdition?.id
|
|
||||||
let isActive = pathname.startsWith(item.href)
|
let isActive = pathname.startsWith(item.href)
|
||||||
if (item.activeMatch) {
|
if (item.activeMatch) {
|
||||||
isActive = pathname.includes(item.activeMatch)
|
isActive = pathname.includes(item.activeMatch)
|
||||||
} else if (item.activeExclude && pathname.includes(item.activeExclude)) {
|
} else if (item.activeExclude && pathname.includes(item.activeExclude)) {
|
||||||
isActive = false
|
isActive = false
|
||||||
}
|
}
|
||||||
if (isDisabled) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={item.name}
|
|
||||||
className="group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium opacity-50 pointer-events-none text-muted-foreground"
|
|
||||||
>
|
|
||||||
<item.icon className="h-4 w-4 text-muted-foreground" />
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
@@ -357,6 +362,29 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{switchableRoles.length > 0 && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator className="my-1" />
|
||||||
|
<div className="px-2 py-1.5">
|
||||||
|
<p className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60">
|
||||||
|
<ArrowRightLeft className="h-3 w-3" />
|
||||||
|
Switch View
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{switchableRoles.map(([, opt]) => (
|
||||||
|
<DropdownMenuItem key={opt.path} asChild>
|
||||||
|
<Link
|
||||||
|
href={opt.path as Route}
|
||||||
|
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
|
||||||
|
>
|
||||||
|
<opt.icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenuSeparator className="my-1" />
|
<DropdownMenuSeparator className="my-1" />
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Home, Users, FileText, MessageSquare, Layers } from 'lucide-react'
|
import { Home, Users, FileText, MessageSquare, Trophy, Star, BookOpen } from 'lucide-react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||||
|
|
||||||
interface ApplicantNavProps {
|
interface ApplicantNavProps {
|
||||||
@@ -8,32 +9,22 @@ interface ApplicantNavProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ApplicantNav({ user }: ApplicantNavProps) {
|
export function ApplicantNav({ user }: ApplicantNavProps) {
|
||||||
|
const { data: flags } = trpc.applicant.getNavFlags.useQuery(undefined, {
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
const navigation: NavItem[] = [
|
const navigation: NavItem[] = [
|
||||||
{
|
{ name: 'Dashboard', href: '/applicant', icon: Home },
|
||||||
name: 'Dashboard',
|
{ name: 'Team', href: '/applicant/team', icon: Users },
|
||||||
href: '/applicant',
|
{ name: 'Competition', href: '/applicant/competition', icon: Trophy },
|
||||||
icon: Home,
|
{ name: 'Documents', href: '/applicant/documents', icon: FileText },
|
||||||
},
|
...(flags?.hasEvaluationRounds
|
||||||
{
|
? [{ name: 'Evaluations', href: '/applicant/evaluations', icon: Star }]
|
||||||
name: 'Team',
|
: []),
|
||||||
href: '/applicant/team',
|
...(flags?.hasMentor
|
||||||
icon: Users,
|
? [{ name: 'Mentoring', href: '/applicant/mentor', icon: MessageSquare }]
|
||||||
},
|
: []),
|
||||||
{
|
{ name: 'Resources', href: '/applicant/resources', icon: BookOpen },
|
||||||
name: 'Competitions',
|
|
||||||
href: '/applicant/competitions',
|
|
||||||
icon: Layers,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Documents',
|
|
||||||
href: '/applicant/documents',
|
|
||||||
icon: FileText,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Mentoring',
|
|
||||||
href: '/applicant/mentor',
|
|
||||||
icon: MessageSquare,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import { LogOut, Menu, Moon, Settings, Sun, User, X } from 'lucide-react'
|
import {
|
||||||
|
LogOut, Menu, Moon, Settings, Sun, User, X,
|
||||||
|
LayoutDashboard, Scale, Handshake, Eye, ArrowRightLeft,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { UserRole } from '@prisma/client'
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
import { Logo } from '@/components/shared/logo'
|
import { Logo } from '@/components/shared/logo'
|
||||||
import { NotificationBell } from '@/components/shared/notification-bell'
|
import { NotificationBell } from '@/components/shared/notification-bell'
|
||||||
@@ -45,6 +49,15 @@ type RoleNavProps = {
|
|||||||
editionSelector?: React.ReactNode
|
editionSelector?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Role switcher config — maps roles to their dashboard views
|
||||||
|
const ROLE_SWITCH_OPTIONS: Record<string, { label: string; path: string; icon: typeof LayoutDashboard }> = {
|
||||||
|
SUPER_ADMIN: { label: 'Admin View', path: '/admin', icon: LayoutDashboard },
|
||||||
|
PROGRAM_ADMIN: { label: 'Admin View', path: '/admin', icon: LayoutDashboard },
|
||||||
|
JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale },
|
||||||
|
MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake },
|
||||||
|
OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye },
|
||||||
|
}
|
||||||
|
|
||||||
function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
|
function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
|
||||||
return pathname === href || (href !== basePath && pathname.startsWith(href))
|
return pathname === href || (href !== basePath && pathname.startsWith(href))
|
||||||
}
|
}
|
||||||
@@ -52,7 +65,7 @@ function isNavItemActive(pathname: string, href: string, basePath: string): bool
|
|||||||
export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector }: RoleNavProps) {
|
export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector }: RoleNavProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||||
const { status: sessionStatus } = useSession()
|
const { data: session, status: sessionStatus } = useSession()
|
||||||
const isAuthenticated = sessionStatus === 'authenticated'
|
const isAuthenticated = sessionStatus === 'authenticated'
|
||||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
@@ -61,6 +74,13 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
useEffect(() => setMounted(true), [])
|
useEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
|
// Roles the user can switch to (excluding current view)
|
||||||
|
const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? []
|
||||||
|
const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS)
|
||||||
|
.filter(([role, opt]) => userRoles.includes(role as UserRole) && opt.path !== basePath)
|
||||||
|
// Deduplicate admin paths (SUPER_ADMIN and PROGRAM_ADMIN both go to /admin)
|
||||||
|
.filter((entry, i, arr) => arr.findIndex(([, o]) => o.path === entry[1].path) === i)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b bg-card">
|
<header className="sticky top-0 z-40 border-b bg-card">
|
||||||
<div className="container-app">
|
<div className="container-app">
|
||||||
@@ -136,6 +156,19 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
Settings
|
Settings
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{switchableRoles.length > 0 && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{switchableRoles.map(([, opt]) => (
|
||||||
|
<DropdownMenuItem key={opt.path} asChild>
|
||||||
|
<Link href={opt.path as Route} className="flex cursor-pointer items-center">
|
||||||
|
<opt.icon className="mr-2 h-4 w-4" />
|
||||||
|
{opt.label}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||||
@@ -198,6 +231,25 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
{editionSelector}
|
{editionSelector}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{switchableRoles.length > 0 && (
|
||||||
|
<div className="border-t pt-4 mt-4 space-y-1">
|
||||||
|
<p className="flex items-center gap-1.5 px-3 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60">
|
||||||
|
<ArrowRightLeft className="h-3 w-3" />
|
||||||
|
Switch View
|
||||||
|
</p>
|
||||||
|
{switchableRoles.map(([, opt]) => (
|
||||||
|
<Link
|
||||||
|
key={opt.path}
|
||||||
|
href={opt.path as Route}
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<opt.icon className="h-4 w-4" />
|
||||||
|
{opt.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="border-t pt-4 mt-4">
|
<div className="border-t pt-4 mt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -25,13 +25,11 @@ import {
|
|||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Webhook,
|
Webhook,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
FlaskConical,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { AISettingsForm } from './ai-settings-form'
|
import { AISettingsForm } from './ai-settings-form'
|
||||||
import { AIUsageCard } from './ai-usage-card'
|
import { AIUsageCard } from './ai-usage-card'
|
||||||
import { TestEnvironmentPanel } from './test-environment-panel'
|
|
||||||
import { BrandingSettingsForm } from './branding-settings-form'
|
import { BrandingSettingsForm } from './branding-settings-form'
|
||||||
import { EmailSettingsForm } from './email-settings-form'
|
import { EmailSettingsForm } from './email-settings-form'
|
||||||
import { StorageSettingsForm } from './storage-settings-form'
|
import { StorageSettingsForm } from './storage-settings-form'
|
||||||
@@ -228,12 +226,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||||||
Webhooks
|
Webhooks
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{isSuperAdmin && (
|
|
||||||
<TabsTrigger value="testenv" className="gap-2 shrink-0">
|
|
||||||
<FlaskConical className="h-4 w-4" />
|
|
||||||
Test Env
|
|
||||||
</TabsTrigger>
|
|
||||||
)}
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="lg:flex lg:gap-8">
|
<div className="lg:flex lg:gap-8">
|
||||||
@@ -327,12 +319,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||||||
Webhooks
|
Webhooks
|
||||||
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
|
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
|
||||||
</Link>
|
</Link>
|
||||||
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5 mt-1">
|
|
||||||
<TabsTrigger value="testenv" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
|
||||||
<FlaskConical className="h-4 w-4" />
|
|
||||||
Test Env
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -527,28 +513,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isSuperAdmin && (
|
|
||||||
<TabsContent value="testenv" className="space-y-6">
|
|
||||||
<AnimatedCard>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<FlaskConical className="h-5 w-5" />
|
|
||||||
Test Environment
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Create a sandboxed test competition with dummy data for testing all roles and workflows.
|
|
||||||
Fully isolated from production data.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<TestEnvironmentPanel />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
</TabsContent>
|
|
||||||
)}
|
|
||||||
</div>{/* end content area */}
|
</div>{/* end content area */}
|
||||||
</div>{/* end lg:flex */}
|
</div>{/* end lg:flex */}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -1,297 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useSession } from 'next-auth/react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from '@/components/ui/alert-dialog'
|
|
||||||
import {
|
|
||||||
FlaskConical,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
ExternalLink,
|
|
||||||
Loader2,
|
|
||||||
Users,
|
|
||||||
UserCog,
|
|
||||||
CheckCircle2,
|
|
||||||
AlertTriangle,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import type { UserRole } from '@prisma/client'
|
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
|
||||||
JURY_MEMBER: 'Jury Member',
|
|
||||||
APPLICANT: 'Applicant',
|
|
||||||
MENTOR: 'Mentor',
|
|
||||||
OBSERVER: 'Observer',
|
|
||||||
AWARD_MASTER: 'Award Master',
|
|
||||||
PROGRAM_ADMIN: 'Program Admin',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROLE_COLORS: Record<string, string> = {
|
|
||||||
JURY_MEMBER: 'bg-blue-100 text-blue-800',
|
|
||||||
APPLICANT: 'bg-green-100 text-green-800',
|
|
||||||
MENTOR: 'bg-purple-100 text-purple-800',
|
|
||||||
OBSERVER: 'bg-orange-100 text-orange-800',
|
|
||||||
AWARD_MASTER: 'bg-yellow-100 text-yellow-800',
|
|
||||||
PROGRAM_ADMIN: 'bg-red-100 text-red-800',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROLE_LANDING: Record<string, string> = {
|
|
||||||
JURY_MEMBER: '/jury',
|
|
||||||
APPLICANT: '/applicant',
|
|
||||||
MENTOR: '/mentor',
|
|
||||||
OBSERVER: '/observer',
|
|
||||||
AWARD_MASTER: '/admin',
|
|
||||||
PROGRAM_ADMIN: '/admin',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TestEnvironmentPanel() {
|
|
||||||
const { update } = useSession()
|
|
||||||
const router = useRouter()
|
|
||||||
const utils = trpc.useUtils()
|
|
||||||
|
|
||||||
const { data: status, isLoading } = trpc.testEnvironment.status.useQuery()
|
|
||||||
const createMutation = trpc.testEnvironment.create.useMutation({
|
|
||||||
onSuccess: () => utils.testEnvironment.status.invalidate(),
|
|
||||||
})
|
|
||||||
const tearDownMutation = trpc.testEnvironment.tearDown.useMutation({
|
|
||||||
onSuccess: () => utils.testEnvironment.status.invalidate(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const [confirmText, setConfirmText] = useState('')
|
|
||||||
const [tearDownOpen, setTearDownOpen] = useState(false)
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No test environment — show creation card
|
|
||||||
if (!status?.active) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-lg border-2 border-dashed p-8 text-center">
|
|
||||||
<FlaskConical className="mx-auto h-12 w-12 text-muted-foreground/50" />
|
|
||||||
<h3 className="mt-4 text-lg font-semibold">No Test Environment</h3>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground max-w-md mx-auto">
|
|
||||||
Create a sandboxed test competition with dummy users, projects, jury assignments,
|
|
||||||
and partial evaluations. All test data is fully isolated from production.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
className="mt-6"
|
|
||||||
onClick={() => createMutation.mutate()}
|
|
||||||
disabled={createMutation.isPending}
|
|
||||||
>
|
|
||||||
{createMutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Creating test environment...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Create Test Competition
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{createMutation.isError && (
|
|
||||||
<p className="mt-3 text-sm text-destructive">
|
|
||||||
{createMutation.error.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test environment is active
|
|
||||||
const { competition, rounds, users, emailRedirect } = status
|
|
||||||
|
|
||||||
// Group users by role for impersonation cards
|
|
||||||
const roleGroups = users.reduce(
|
|
||||||
(acc, u) => {
|
|
||||||
const role = u.role as string
|
|
||||||
if (!acc[role]) acc[role] = []
|
|
||||||
acc[role].push(u)
|
|
||||||
return acc
|
|
||||||
},
|
|
||||||
{} as Record<string, typeof users>
|
|
||||||
)
|
|
||||||
|
|
||||||
async function handleImpersonate(userId: string, role: UserRole) {
|
|
||||||
await update({ impersonateUserId: userId })
|
|
||||||
router.push((ROLE_LANDING[role] || '/admin') as any)
|
|
||||||
router.refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTearDown() {
|
|
||||||
if (confirmText !== 'DELETE TEST') return
|
|
||||||
tearDownMutation.mutate(undefined, {
|
|
||||||
onSuccess: () => {
|
|
||||||
setTearDownOpen(false)
|
|
||||||
setConfirmText('')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Status header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
|
|
||||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
|
||||||
Test Active
|
|
||||||
</Badge>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{competition.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<a href={`/admin/competitions/${competition.id}`} target="_blank" rel="noopener">
|
|
||||||
View Competition
|
|
||||||
<ExternalLink className="ml-1.5 h-3 w-3" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick stats */}
|
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
|
||||||
<div className="rounded-lg border p-3">
|
|
||||||
<p className="text-2xl font-bold">{rounds.length}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Rounds</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border p-3">
|
|
||||||
<p className="text-2xl font-bold">{users.length}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Test Users</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border p-3">
|
|
||||||
<p className="text-2xl font-bold truncate text-sm font-mono">
|
|
||||||
{emailRedirect || '—'}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Email Redirect</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Impersonation section */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<UserCog className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<h4 className="text-sm font-semibold">Impersonate Test User</h4>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
||||||
{Object.entries(roleGroups).map(([role, roleUsers]) => (
|
|
||||||
<Card key={role} className="overflow-hidden">
|
|
||||||
<CardHeader className="py-2 px-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Badge variant="secondary" className={ROLE_COLORS[role] || ''}>
|
|
||||||
{ROLE_LABELS[role] || role}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{roleUsers.length} user{roleUsers.length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="py-2 px-3 space-y-1.5">
|
|
||||||
{roleUsers.slice(0, 3).map((u) => (
|
|
||||||
<button
|
|
||||||
key={u.id}
|
|
||||||
onClick={() => handleImpersonate(u.id, u.role as UserRole)}
|
|
||||||
className="flex items-center justify-between w-full rounded-md px-2 py-1.5 text-sm hover:bg-muted transition-colors text-left"
|
|
||||||
>
|
|
||||||
<span className="truncate">{u.name || u.email}</span>
|
|
||||||
<span className="text-xs text-muted-foreground shrink-0 ml-2">
|
|
||||||
Impersonate
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{roleUsers.length > 3 && (
|
|
||||||
<p className="text-xs text-muted-foreground px-2">
|
|
||||||
+{roleUsers.length - 3} more (switch via banner)
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tear down */}
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<AlertDialog open={tearDownOpen} onOpenChange={setTearDownOpen}>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button variant="destructive" size="sm">
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Tear Down Test Environment
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle className="flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
|
||||||
Destroy Test Environment
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will permanently delete ALL test data: users, projects, competitions,
|
|
||||||
assignments, evaluations, and files. This action cannot be undone.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<div className="space-y-2 py-2">
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
Type <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">DELETE TEST</code> to confirm:
|
|
||||||
</p>
|
|
||||||
<Input
|
|
||||||
value={confirmText}
|
|
||||||
onChange={(e) => setConfirmText(e.target.value)}
|
|
||||||
placeholder="DELETE TEST"
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel onClick={() => setConfirmText('')}>
|
|
||||||
Cancel
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleTearDown}
|
|
||||||
disabled={confirmText !== 'DELETE TEST' || tearDownMutation.isPending}
|
|
||||||
>
|
|
||||||
{tearDownMutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Tearing down...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Destroy Test Environment'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useCreateBlockNote } from '@blocknote/react'
|
import { useCreateBlockNote } from '@blocknote/react'
|
||||||
import { BlockNoteView } from '@blocknote/mantine'
|
import { BlockNoteView } from '@blocknote/shadcn'
|
||||||
import '@blocknote/core/fonts/inter.css'
|
import '@blocknote/core/fonts/inter.css'
|
||||||
import '@blocknote/mantine/style.css'
|
import '@blocknote/shadcn/style.css'
|
||||||
|
|
||||||
import type { PartialBlock } from '@blocknote/core'
|
import type { PartialBlock } from '@blocknote/core'
|
||||||
|
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useSession } from 'next-auth/react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { ChevronDown, LogOut, UserCog } from 'lucide-react'
|
|
||||||
import type { UserRole } from '@prisma/client'
|
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
|
||||||
JURY_MEMBER: 'Jury Member',
|
|
||||||
APPLICANT: 'Applicant',
|
|
||||||
MENTOR: 'Mentor',
|
|
||||||
OBSERVER: 'Observer',
|
|
||||||
AWARD_MASTER: 'Award Master',
|
|
||||||
PROGRAM_ADMIN: 'Program Admin',
|
|
||||||
SUPER_ADMIN: 'Super Admin',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROLE_LANDING: Record<string, string> = {
|
|
||||||
JURY_MEMBER: '/jury',
|
|
||||||
APPLICANT: '/applicant',
|
|
||||||
MENTOR: '/mentor',
|
|
||||||
OBSERVER: '/observer',
|
|
||||||
AWARD_MASTER: '/admin',
|
|
||||||
PROGRAM_ADMIN: '/admin',
|
|
||||||
SUPER_ADMIN: '/admin',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ImpersonationBanner() {
|
|
||||||
const { data: session, update } = useSession()
|
|
||||||
const router = useRouter()
|
|
||||||
const [switching, setSwitching] = useState(false)
|
|
||||||
|
|
||||||
// Only fetch test users when impersonating (realRole check happens server-side)
|
|
||||||
const { data: testEnv } = trpc.testEnvironment.status.useQuery(undefined, {
|
|
||||||
enabled: !!session?.user?.isImpersonating,
|
|
||||||
staleTime: 60_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!session?.user?.isImpersonating) return null
|
|
||||||
|
|
||||||
const currentRole = session.user.role
|
|
||||||
const currentName = session.user.impersonatedName || session.user.name || 'Unknown'
|
|
||||||
|
|
||||||
// Group available test users by role (exclude currently impersonated user)
|
|
||||||
const availableUsers = testEnv?.active
|
|
||||||
? testEnv.users.filter((u) => u.id !== session.user.id)
|
|
||||||
: []
|
|
||||||
|
|
||||||
const roleGroups = availableUsers.reduce(
|
|
||||||
(acc, u) => {
|
|
||||||
const role = u.role as string
|
|
||||||
if (!acc[role]) acc[role] = []
|
|
||||||
acc[role].push(u)
|
|
||||||
return acc
|
|
||||||
},
|
|
||||||
{} as Record<string, typeof availableUsers>
|
|
||||||
)
|
|
||||||
|
|
||||||
async function handleSwitch(userId: string, role: UserRole) {
|
|
||||||
setSwitching(true)
|
|
||||||
await update({ impersonateUserId: userId })
|
|
||||||
router.push((ROLE_LANDING[role] || '/admin') as any)
|
|
||||||
router.refresh()
|
|
||||||
setSwitching(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStopImpersonation() {
|
|
||||||
setSwitching(true)
|
|
||||||
await update({ stopImpersonation: true })
|
|
||||||
router.push('/admin/settings' as any)
|
|
||||||
router.refresh()
|
|
||||||
setSwitching(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed top-0 left-0 right-0 z-50 bg-amber-500 text-amber-950 shadow-md">
|
|
||||||
<div className="mx-auto flex items-center justify-between px-4 py-1.5 text-sm font-medium">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<UserCog className="h-4 w-4" />
|
|
||||||
<span>
|
|
||||||
Viewing as <strong>{currentName}</strong>{' '}
|
|
||||||
<span className="rounded bg-amber-600/30 px-1.5 py-0.5 text-xs font-semibold">
|
|
||||||
{ROLE_LABELS[currentRole] || currentRole}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* Quick-switch dropdown */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 gap-1 bg-amber-600/20 text-amber-950 hover:bg-amber-600/40"
|
|
||||||
disabled={switching}
|
|
||||||
>
|
|
||||||
Switch Role
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
{Object.entries(roleGroups).map(([role, users]) => (
|
|
||||||
<div key={role}>
|
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
||||||
{ROLE_LABELS[role] || role}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{users.map((u) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={u.id}
|
|
||||||
onClick={() => handleSwitch(u.id, u.role as UserRole)}
|
|
||||||
disabled={switching}
|
|
||||||
>
|
|
||||||
<span className="truncate">{u.name || u.email}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
{/* Return to admin */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 gap-1 bg-amber-600/20 text-amber-950 hover:bg-amber-600/40"
|
|
||||||
onClick={handleStopImpersonation}
|
|
||||||
disabled={switching}
|
|
||||||
>
|
|
||||||
<LogOut className="h-3 w-3" />
|
|
||||||
Return to Admin
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
71
src/components/shared/resource-renderer.tsx
Normal file
71
src/components/shared/resource-renderer.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
|
const BlockViewer = dynamic(
|
||||||
|
() => import('@/components/shared/block-editor').then((mod) => mod.BlockViewer),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ResourceRendererProps {
|
||||||
|
title: string
|
||||||
|
description?: string | null
|
||||||
|
contentJson: unknown // BlockNote PartialBlock[] stored as JSON
|
||||||
|
coverImageUrl?: string | null
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceRenderer({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
contentJson,
|
||||||
|
coverImageUrl,
|
||||||
|
className,
|
||||||
|
}: ResourceRendererProps) {
|
||||||
|
const contentString =
|
||||||
|
typeof contentJson === 'string' ? contentJson : JSON.stringify(contentJson)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={`mx-auto max-w-3xl ${className ?? ''}`}>
|
||||||
|
{/* Cover image */}
|
||||||
|
{coverImageUrl && (
|
||||||
|
<div className="mb-8 overflow-hidden rounded-lg">
|
||||||
|
<img
|
||||||
|
src={coverImageUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-auto w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{description && (
|
||||||
|
<p className="mt-3 text-lg text-muted-foreground leading-relaxed">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<hr className="my-6 border-border" />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{contentJson ? (
|
||||||
|
<div className="prose-renderer">
|
||||||
|
<BlockViewer content={contentString} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground italic">No content</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
'z-50 overflow-hidden rounded-md border bg-white px-3 py-1.5 text-xs text-gray-900 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ export async function requireRole(...allowedRoles: UserRole[]) {
|
|||||||
redirect('/login')
|
redirect('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRole = session.user.role
|
// Use roles array, fallback to [role] for stale JWT tokens
|
||||||
|
const userRoles = session.user.roles?.length ? session.user.roles : [session.user.role]
|
||||||
|
|
||||||
if (!allowedRoles.includes(userRole)) {
|
if (!allowedRoles.some(r => userRoles.includes(r))) {
|
||||||
const dashboard = ROLE_DASHBOARDS[userRole]
|
const dashboard = ROLE_DASHBOARDS[session.user.role]
|
||||||
redirect((dashboard || '/login') as Route)
|
redirect((dashboard || '/login') as Route)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user