Compare commits
No commits in common. "d787a2492111580aa8cc7614f8a553d0acbd5aa0" and "5cae78fe0cf52254d28b2db0e8db82087aa8e8c9" have entirely different histories.
d787a24921
...
5cae78fe0c
|
|
@ -48,9 +48,6 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.4",
|
"cmdk": "^1.0.4",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"html2canvas": "^1.4.1",
|
|
||||||
"jspdf": "^4.1.0",
|
|
||||||
"jspdf-autotable": "^5.0.7",
|
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"minio": "^8.0.2",
|
"minio": "^8.0.2",
|
||||||
|
|
@ -5110,12 +5107,6 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/pako": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/papaparse": {
|
"node_modules/@types/papaparse": {
|
||||||
"version": "5.5.2",
|
"version": "5.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
|
||||||
|
|
@ -5126,13 +5117,6 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/raf": {
|
|
||||||
"version": "3.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
|
||||||
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.10",
|
"version": "19.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
|
||||||
|
|
@ -5151,13 +5135,6 @@
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
|
||||||
"version": "2.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/@types/unist": {
|
"node_modules/@types/unist": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
|
|
@ -6154,15 +6131,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/base64-arraybuffer": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6.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",
|
||||||
|
|
@ -6326,26 +6294,6 @@
|
||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
"node_modules/canvg": {
|
|
||||||
"version": "3.0.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
|
||||||
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.12.5",
|
|
||||||
"@types/raf": "^3.4.0",
|
|
||||||
"core-js": "^3.8.3",
|
|
||||||
"raf": "^3.4.1",
|
|
||||||
"regenerator-runtime": "^0.13.7",
|
|
||||||
"rgbcolor": "^1.0.1",
|
|
||||||
"stackblur-canvas": "^2.0.0",
|
|
||||||
"svg-pathdata": "^6.0.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ccount": {
|
"node_modules/ccount": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
||||||
|
|
@ -6569,18 +6517,6 @@
|
||||||
"url": "https://github.com/sponsors/mesqueeb"
|
"url": "https://github.com/sponsors/mesqueeb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/core-js": {
|
|
||||||
"version": "3.48.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz",
|
|
||||||
"integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/core-js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/country-flag-icons": {
|
"node_modules/country-flag-icons": {
|
||||||
"version": "1.6.12",
|
"version": "1.6.12",
|
||||||
"resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.6.12.tgz",
|
"resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.6.12.tgz",
|
||||||
|
|
@ -6608,15 +6544,6 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/css-line-break": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"utrie": "^1.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
|
@ -6997,16 +6924,6 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
|
||||||
"version": "3.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
|
||||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
|
||||||
"optional": true,
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@types/trusted-types": "^2.0.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
|
|
@ -7883,17 +7800,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-png": {
|
|
||||||
"version": "6.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
|
||||||
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/pako": "^2.0.3",
|
|
||||||
"iobuffer": "^5.3.2",
|
|
||||||
"pako": "^2.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fast-xml-parser": {
|
"node_modules/fast-xml-parser": {
|
||||||
"version": "4.5.3",
|
"version": "4.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
|
||||||
|
|
@ -7940,12 +7846,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fflate": {
|
|
||||||
"version": "0.8.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
|
||||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
|
|
@ -8674,19 +8574,6 @@
|
||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/html2canvas": {
|
|
||||||
"version": "1.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
|
||||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"css-line-break": "^2.1.0",
|
|
||||||
"text-segmentation": "^1.0.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/icu-minify": {
|
"node_modules/icu-minify": {
|
||||||
"version": "4.8.2",
|
"version": "4.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.2.tgz",
|
||||||
|
|
@ -8812,12 +8699,6 @@
|
||||||
"tslib": "^2.8.1"
|
"tslib": "^2.8.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iobuffer": {
|
|
||||||
"version": "5.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
|
||||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
|
||||||
|
|
@ -9376,32 +9257,6 @@
|
||||||
"json5": "lib/cli.js"
|
"json5": "lib/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jspdf": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.28.4",
|
|
||||||
"fast-png": "^6.2.0",
|
|
||||||
"fflate": "^0.8.1"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"canvg": "^3.0.11",
|
|
||||||
"core-js": "^3.6.0",
|
|
||||||
"dompurify": "^3.3.1",
|
|
||||||
"html2canvas": "^1.0.0-rc.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jspdf-autotable": {
|
|
||||||
"version": "5.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.7.tgz",
|
|
||||||
"integrity": "sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"jspdf": "^2 || ^3 || ^4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jsx-ast-utils": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||||
|
|
@ -11407,12 +11262,6 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pako": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
|
||||||
"license": "(MIT AND Zlib)"
|
|
||||||
},
|
|
||||||
"node_modules/papaparse": {
|
"node_modules/papaparse": {
|
||||||
"version": "5.5.3",
|
"version": "5.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
||||||
|
|
@ -11497,13 +11346,6 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/performance-now": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|
@ -12103,16 +11945,6 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/raf": {
|
|
||||||
"version": "3.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
|
||||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"performance-now": "^2.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/rc9": {
|
"node_modules/rc9": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||||
|
|
@ -12457,13 +12289,6 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/regenerator-runtime": {
|
|
||||||
"version": "0.13.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
|
||||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/regexp.prototype.flags": {
|
"node_modules/regexp.prototype.flags": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||||
|
|
@ -12684,16 +12509,6 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rgbcolor": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
|
||||||
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8.15"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.57.0",
|
"version": "4.57.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz",
|
||||||
|
|
@ -13122,16 +12937,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/stackblur-canvas": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.1.14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/std-env": {
|
"node_modules/std-env": {
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
|
|
@ -13409,16 +13214,6 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/svg-pathdata": {
|
|
||||||
"version": "6.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
|
||||||
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tabbable": {
|
"node_modules/tabbable": {
|
||||||
"version": "6.4.0",
|
"version": "6.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
||||||
|
|
@ -13464,15 +13259,6 @@
|
||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/text-segmentation": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"utrie": "^1.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/through2": {
|
"node_modules/through2": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
|
||||||
|
|
@ -14090,15 +13876,6 @@
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/utrie": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"base64-arraybuffer": "^1.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "8.3.2",
|
"version": "8.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,6 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.4",
|
"cmdk": "^1.0.4",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"html2canvas": "^1.4.1",
|
|
||||||
"jspdf": "^4.1.0",
|
|
||||||
"jspdf-autotable": "^5.0.7",
|
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"minio": "^8.0.2",
|
"minio": "^8.0.2",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ CREATE SCHEMA IF NOT EXISTS "public";
|
||||||
CREATE TYPE "UserRole" AS ENUM ('SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT');
|
CREATE TYPE "UserRole" AS ENUM ('SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT');
|
||||||
|
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
CREATE TYPE "UserStatus" AS ENUM ('NONE', 'INVITED', 'ACTIVE', 'SUSPENDED');
|
CREATE TYPE "UserStatus" AS ENUM ('INVITED', 'ACTIVE', 'SUSPENDED');
|
||||||
|
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
CREATE TYPE "ProgramStatus" AS ENUM ('DRAFT', 'ACTIVE', 'ARCHIVED');
|
CREATE TYPE "ProgramStatus" AS ENUM ('DRAFT', 'ACTIVE', 'ARCHIVED');
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ enum UserRole {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UserStatus {
|
enum UserStatus {
|
||||||
NONE
|
|
||||||
INVITED
|
INVITED
|
||||||
ACTIVE
|
ACTIVE
|
||||||
SUSPENDED
|
SUSPENDED
|
||||||
|
|
|
||||||
|
|
@ -346,7 +346,7 @@ async function main() {
|
||||||
email,
|
email,
|
||||||
name: row['Full name']?.trim() || 'Unknown',
|
name: row['Full name']?.trim() || 'Unknown',
|
||||||
role: 'APPLICANT',
|
role: 'APPLICANT',
|
||||||
status: 'NONE',
|
status: 'INVITED',
|
||||||
phoneNumber: row['Téléphone']?.trim() || null,
|
phoneNumber: row['Téléphone']?.trim() || null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -424,7 +424,7 @@ async function main() {
|
||||||
email: memberEmail,
|
email: memberEmail,
|
||||||
name: member.name,
|
name: member.name,
|
||||||
role: 'APPLICANT',
|
role: 'APPLICANT',
|
||||||
status: 'NONE',
|
status: 'INVITED',
|
||||||
metadataJson: {
|
metadataJson: {
|
||||||
isPendingEmailVerification: true,
|
isPendingEmailVerification: true,
|
||||||
originalName: member.name,
|
originalName: member.name,
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ export default function MemberDetailPage() {
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [role, setRole] = useState<string>('JURY_MEMBER')
|
const [role, setRole] = useState<string>('JURY_MEMBER')
|
||||||
const [status, setStatus] = useState<string>('NONE')
|
const [status, setStatus] = useState<string>('INVITED')
|
||||||
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
||||||
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
||||||
const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false)
|
const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false)
|
||||||
|
|
@ -96,7 +96,7 @@ export default function MemberDetailPage() {
|
||||||
id: userId,
|
id: userId,
|
||||||
name: name || null,
|
name: name || null,
|
||||||
role: role as 'SUPER_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN',
|
role: role as 'SUPER_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN',
|
||||||
status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED',
|
status: status as 'INVITED' | 'ACTIVE' | 'SUSPENDED',
|
||||||
expertiseTags,
|
expertiseTags,
|
||||||
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
|
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
|
||||||
})
|
})
|
||||||
|
|
@ -180,11 +180,11 @@ export default function MemberDetailPage() {
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<p className="text-muted-foreground">{user.email}</p>
|
<p className="text-muted-foreground">{user.email}</p>
|
||||||
<Badge variant={user.status === 'ACTIVE' ? 'success' : user.status === 'SUSPENDED' ? 'destructive' : 'secondary'}>
|
<Badge variant={user.status === 'ACTIVE' ? 'success' : user.status === 'SUSPENDED' ? 'destructive' : 'secondary'}>
|
||||||
{user.status === 'NONE' ? 'Not Invited' : user.status}
|
{user.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(user.status === 'NONE' || user.status === 'INVITED') && (
|
{user.status === 'INVITED' && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleSendInvitation}
|
onClick={handleSendInvitation}
|
||||||
|
|
@ -235,7 +235,6 @@ export default function MemberDetailPage() {
|
||||||
setRole(v)
|
setRole(v)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!isSuperAdmin && (user.role === 'SUPER_ADMIN' || user.role === 'PROGRAM_ADMIN')}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger id="role">
|
<SelectTrigger id="role">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -244,9 +243,7 @@ export default function MemberDetailPage() {
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<SelectItem value="SUPER_ADMIN">Super Admin</SelectItem>
|
<SelectItem value="SUPER_ADMIN">Super Admin</SelectItem>
|
||||||
)}
|
)}
|
||||||
{isSuperAdmin && (
|
|
||||||
<SelectItem value="PROGRAM_ADMIN">Program Admin</SelectItem>
|
<SelectItem value="PROGRAM_ADMIN">Program Admin</SelectItem>
|
||||||
)}
|
|
||||||
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
|
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
|
||||||
<SelectItem value="MENTOR">Mentor</SelectItem>
|
<SelectItem value="MENTOR">Mentor</SelectItem>
|
||||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||||
|
|
@ -260,7 +257,6 @@ export default function MemberDetailPage() {
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="NONE">Not Invited</SelectItem>
|
|
||||||
<SelectItem value="INVITED">Invited</SelectItem>
|
<SelectItem value="INVITED">Invited</SelectItem>
|
||||||
<SelectItem value="ACTIVE">Active</SelectItem>
|
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||||
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
||||||
|
|
@ -383,16 +379,6 @@ export default function MemberDetailPage() {
|
||||||
<UserActivityLog userId={userId} />
|
<UserActivityLog userId={userId} />
|
||||||
|
|
||||||
{/* Status Alert */}
|
{/* Status Alert */}
|
||||||
{user.status === 'NONE' && (
|
|
||||||
<Alert>
|
|
||||||
<Mail className="h-4 w-4" />
|
|
||||||
<AlertTitle>Not Yet Invited</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
This member was added to the platform via project import but hasn't been
|
|
||||||
invited yet. Send them an invitation using the button above.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{user.status === 'INVITED' && (
|
{user.status === 'INVITED' && (
|
||||||
<Alert>
|
<Alert>
|
||||||
<Mail className="h-4 w-4" />
|
<Mail className="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
|
||||||
prisma.user.count({
|
prisma.user.count({
|
||||||
where: {
|
where: {
|
||||||
role: 'JURY_MEMBER',
|
role: 'JURY_MEMBER',
|
||||||
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
|
status: { in: ['ACTIVE', 'INVITED'] },
|
||||||
assignments: { some: { round: { programId: editionId } } },
|
assignments: { some: { round: { programId: editionId } } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
@ -751,7 +751,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-brand-teal transition-all"
|
className="h-full rounded-full bg-accent transition-all"
|
||||||
style={{ width: `${(issue.count / maxIssueCount) * 100}%` }}
|
style={{ width: `${(issue.count / maxIssueCount) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,7 @@ import {
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { TagInput } from '@/components/shared/tag-input'
|
import { TagInput } from '@/components/shared/tag-input'
|
||||||
import { CountrySelect } from '@/components/ui/country-select'
|
import { CountrySelect } from '@/components/ui/country-select'
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
import { PhoneInput } from '@/components/ui/phone-input'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
|
@ -36,52 +33,8 @@ import {
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
Users,
|
|
||||||
UserPlus,
|
|
||||||
Trash2,
|
|
||||||
Mail,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
type TeamMemberEntry = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
email: string
|
|
||||||
role: 'LEAD' | 'MEMBER' | 'ADVISOR'
|
|
||||||
title: string
|
|
||||||
phone: string
|
|
||||||
sendInvite: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROLE_COLORS: Record<string, string> = {
|
|
||||||
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
|
||||||
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400',
|
|
||||||
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROLE_AVATAR_COLORS: Record<string, string> = {
|
|
||||||
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
|
||||||
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
|
|
||||||
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
|
||||||
LEAD: 'Lead',
|
|
||||||
MEMBER: 'Member',
|
|
||||||
ADVISOR: 'Advisor',
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
|
||||||
return name
|
|
||||||
.split(' ')
|
|
||||||
.map((w) => w[0])
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 2)
|
|
||||||
.join('')
|
|
||||||
.toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROLE_SORT_ORDER: Record<string, number> = { LEAD: 0, MEMBER: 1, ADVISOR: 2 }
|
|
||||||
|
|
||||||
function NewProjectPageContent() {
|
function NewProjectPageContent() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|
@ -96,20 +49,15 @@ function NewProjectPageContent() {
|
||||||
const [teamName, setTeamName] = useState('')
|
const [teamName, setTeamName] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [tags, setTags] = useState<string[]>([])
|
const [tags, setTags] = useState<string[]>([])
|
||||||
|
const [contactEmail, setContactEmail] = useState('')
|
||||||
|
const [contactName, setContactName] = useState('')
|
||||||
|
const [contactPhone, setContactPhone] = useState('')
|
||||||
const [country, setCountry] = useState('')
|
const [country, setCountry] = useState('')
|
||||||
|
const [city, setCity] = useState('')
|
||||||
const [institution, setInstitution] = useState('')
|
const [institution, setInstitution] = useState('')
|
||||||
const [competitionCategory, setCompetitionCategory] = useState<string>('')
|
const [competitionCategory, setCompetitionCategory] = useState<string>('')
|
||||||
const [oceanIssue, setOceanIssue] = useState<string>('')
|
const [oceanIssue, setOceanIssue] = useState<string>('')
|
||||||
|
|
||||||
// Team members state
|
|
||||||
const [teamMembers, setTeamMembers] = useState<TeamMemberEntry[]>([])
|
|
||||||
const [memberName, setMemberName] = useState('')
|
|
||||||
const [memberEmail, setMemberEmail] = useState('')
|
|
||||||
const [memberRole, setMemberRole] = useState<'LEAD' | 'MEMBER' | 'ADVISOR'>('MEMBER')
|
|
||||||
const [memberTitle, setMemberTitle] = useState('')
|
|
||||||
const [memberPhone, setMemberPhone] = useState('')
|
|
||||||
const [memberSendInvite, setMemberSendInvite] = useState(false)
|
|
||||||
|
|
||||||
// Fetch programs
|
// Fetch programs
|
||||||
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
|
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
|
|
@ -144,66 +92,6 @@ function NewProjectPageContent() {
|
||||||
const categoryOptions = wizardConfig?.competitionCategories || []
|
const categoryOptions = wizardConfig?.competitionCategories || []
|
||||||
const oceanIssueOptions = wizardConfig?.oceanIssues || []
|
const oceanIssueOptions = wizardConfig?.oceanIssues || []
|
||||||
|
|
||||||
const handleAddMember = () => {
|
|
||||||
if (!memberName.trim()) {
|
|
||||||
toast.error('Please enter a member name')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!memberEmail.trim()) {
|
|
||||||
toast.error('Please enter a member email')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Basic email validation
|
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(memberEmail.trim())) {
|
|
||||||
toast.error('Please enter a valid email address')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Check for duplicates
|
|
||||||
if (teamMembers.some((m) => m.email.toLowerCase() === memberEmail.trim().toLowerCase())) {
|
|
||||||
toast.error('A member with this email already exists')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (teamMembers.length >= 10) {
|
|
||||||
toast.error('Maximum 10 team members allowed')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setTeamMembers((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
name: memberName.trim(),
|
|
||||||
email: memberEmail.trim(),
|
|
||||||
role: memberRole,
|
|
||||||
title: memberTitle.trim(),
|
|
||||||
phone: memberPhone.trim(),
|
|
||||||
sendInvite: memberSendInvite,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
setMemberName('')
|
|
||||||
setMemberEmail('')
|
|
||||||
setMemberRole('MEMBER')
|
|
||||||
setMemberTitle('')
|
|
||||||
setMemberPhone('')
|
|
||||||
setMemberSendInvite(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemoveMember = (id: string) => {
|
|
||||||
setTeamMembers((prev) => prev.filter((m) => m.id !== id))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleInvite = (id: string) => {
|
|
||||||
setTeamMembers((prev) =>
|
|
||||||
prev.map((m) => (m.id === id ? { ...m, sendInvite: !m.sendInvite } : m))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedMembers = [...teamMembers].sort(
|
|
||||||
(a, b) => (ROLE_SORT_ORDER[a.role] ?? 9) - (ROLE_SORT_ORDER[b.role] ?? 9)
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
toast.error('Please enter a project title')
|
toast.error('Please enter a project title')
|
||||||
|
|
@ -225,16 +113,10 @@ function NewProjectPageContent() {
|
||||||
competitionCategory: competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT' | undefined || undefined,
|
competitionCategory: competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT' | undefined || undefined,
|
||||||
oceanIssue: oceanIssue as 'POLLUTION_REDUCTION' | 'CLIMATE_MITIGATION' | 'TECHNOLOGY_INNOVATION' | 'SUSTAINABLE_SHIPPING' | 'BLUE_CARBON' | 'HABITAT_RESTORATION' | 'COMMUNITY_CAPACITY' | 'SUSTAINABLE_FISHING' | 'CONSUMER_AWARENESS' | 'OCEAN_ACIDIFICATION' | 'OTHER' | undefined || undefined,
|
oceanIssue: oceanIssue as 'POLLUTION_REDUCTION' | 'CLIMATE_MITIGATION' | 'TECHNOLOGY_INNOVATION' | 'SUSTAINABLE_SHIPPING' | 'BLUE_CARBON' | 'HABITAT_RESTORATION' | 'COMMUNITY_CAPACITY' | 'SUSTAINABLE_FISHING' | 'CONSUMER_AWARENESS' | 'OCEAN_ACIDIFICATION' | 'OTHER' | undefined || undefined,
|
||||||
institution: institution.trim() || undefined,
|
institution: institution.trim() || undefined,
|
||||||
teamMembers: teamMembers.length > 0
|
contactPhone: contactPhone.trim() || undefined,
|
||||||
? teamMembers.map(({ name, email, role, title: t, phone, sendInvite }) => ({
|
contactEmail: contactEmail.trim() || undefined,
|
||||||
name,
|
contactName: contactName.trim() || undefined,
|
||||||
email,
|
city: city.trim() || undefined,
|
||||||
role,
|
|
||||||
title: t || undefined,
|
|
||||||
phone: phone || undefined,
|
|
||||||
sendInvite,
|
|
||||||
}))
|
|
||||||
: undefined,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -422,6 +304,47 @@ function NewProjectPageContent() {
|
||||||
placeholder="e.g., University of Monaco"
|
placeholder="e.g., University of Monaco"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Contact Info */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Contact Information</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Contact details for the project team
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="contactName">Contact Name</Label>
|
||||||
|
<Input
|
||||||
|
id="contactName"
|
||||||
|
value={contactName}
|
||||||
|
onChange={(e) => setContactName(e.target.value)}
|
||||||
|
placeholder="e.g., John Smith"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="contactEmail">Contact Email</Label>
|
||||||
|
<Input
|
||||||
|
id="contactEmail"
|
||||||
|
type="email"
|
||||||
|
value={contactEmail}
|
||||||
|
onChange={(e) => setContactEmail(e.target.value)}
|
||||||
|
placeholder="e.g., john@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Contact Phone</Label>
|
||||||
|
<PhoneInput
|
||||||
|
value={contactPhone}
|
||||||
|
onChange={setContactPhone}
|
||||||
|
defaultCountry="MC"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Country</Label>
|
<Label>Country</Label>
|
||||||
|
|
@ -430,171 +353,16 @@ function NewProjectPageContent() {
|
||||||
onChange={setCountry}
|
onChange={setCountry}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Team Members */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle>Team Members</CardTitle>
|
|
||||||
<Badge variant="secondary">{teamMembers.length} / 10</Badge>
|
|
||||||
</div>
|
|
||||||
<CardDescription>
|
|
||||||
Add team members and optionally invite them to the platform
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{teamMembers.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-8 text-center">
|
|
||||||
<Users className="h-10 w-10 text-muted-foreground/40" />
|
|
||||||
<p className="mt-2 text-sm font-medium">No team members yet</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Add members below to link them to this project
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{sortedMembers.map((member) => (
|
<Label htmlFor="city">City</Label>
|
||||||
<div
|
|
||||||
key={member.id}
|
|
||||||
className="flex items-center gap-3 rounded-lg border p-3"
|
|
||||||
>
|
|
||||||
<Avatar className={`h-9 w-9 ${ROLE_AVATAR_COLORS[member.role]}`}>
|
|
||||||
<AvatarFallback className={ROLE_AVATAR_COLORS[member.role]}>
|
|
||||||
{getInitials(member.name)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="truncate text-sm font-medium">{member.name}</span>
|
|
||||||
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${ROLE_COLORS[member.role]}`}>
|
|
||||||
{ROLE_LABELS[member.role]}
|
|
||||||
</Badge>
|
|
||||||
{member.title && (
|
|
||||||
<span className="hidden truncate text-xs text-muted-foreground sm:inline">
|
|
||||||
{member.title}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="truncate text-xs text-muted-foreground">{member.email}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleToggleInvite(member.id)}
|
|
||||||
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors ${
|
|
||||||
member.sendInvite
|
|
||||||
? 'bg-primary/10 text-primary'
|
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
title={member.sendInvite ? 'Invitation will be sent' : 'Click to send invitation'}
|
|
||||||
>
|
|
||||||
<Mail className="h-3.5 w-3.5" />
|
|
||||||
<span className="hidden sm:inline">
|
|
||||||
{member.sendInvite ? 'Invite' : 'No invite'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() => handleRemoveMember(member.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{teamMembers.length > 0 && teamMembers.length < 10 && <Separator />}
|
|
||||||
|
|
||||||
{/* Add member form */}
|
|
||||||
{teamMembers.length < 10 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-sm font-medium flex items-center gap-1.5">
|
|
||||||
<UserPlus className="h-4 w-4" />
|
|
||||||
Add Member
|
|
||||||
</p>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="memberName" className="text-xs">Name *</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="memberName"
|
id="city"
|
||||||
value={memberName}
|
value={city}
|
||||||
onChange={(e) => setMemberName(e.target.value)}
|
onChange={(e) => setCity(e.target.value)}
|
||||||
placeholder="Full name"
|
placeholder="e.g., Monaco"
|
||||||
className="h-9"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="memberEmail" className="text-xs">Email *</Label>
|
|
||||||
<Input
|
|
||||||
id="memberEmail"
|
|
||||||
type="email"
|
|
||||||
value={memberEmail}
|
|
||||||
onChange={(e) => setMemberEmail(e.target.value)}
|
|
||||||
placeholder="email@example.com"
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Role</Label>
|
|
||||||
<Select value={memberRole} onValueChange={(v) => setMemberRole(v as 'LEAD' | 'MEMBER' | 'ADVISOR')}>
|
|
||||||
<SelectTrigger className="h-9">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="LEAD">Lead</SelectItem>
|
|
||||||
<SelectItem value="MEMBER">Member</SelectItem>
|
|
||||||
<SelectItem value="ADVISOR">Advisor</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="memberTitle" className="text-xs">Title (optional)</Label>
|
|
||||||
<Input
|
|
||||||
id="memberTitle"
|
|
||||||
value={memberTitle}
|
|
||||||
onChange={(e) => setMemberTitle(e.target.value)}
|
|
||||||
placeholder="e.g., CEO, CTO"
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="memberSendInvite"
|
|
||||||
checked={memberSendInvite}
|
|
||||||
onCheckedChange={(checked) => setMemberSendInvite(checked === true)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="memberSendInvite" className="text-xs cursor-pointer">
|
|
||||||
Send platform invitation
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleAddMember}
|
|
||||||
>
|
|
||||||
<UserPlus className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{teamMembers.length >= 10 && (
|
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
|
||||||
Maximum of 10 team members reached
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -82,17 +82,10 @@ import {
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip'
|
|
||||||
import { truncate } from '@/lib/utils'
|
import { truncate } from '@/lib/utils'
|
||||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||||
import { StatusBadge } from '@/components/shared/status-badge'
|
import { StatusBadge } from '@/components/shared/status-badge'
|
||||||
import { Pagination } from '@/components/shared/pagination'
|
import { Pagination } from '@/components/shared/pagination'
|
||||||
import { getCountryFlag, getCountryName, normalizeCountryToCode } from '@/lib/countries'
|
|
||||||
import {
|
import {
|
||||||
ProjectFiltersBar,
|
ProjectFiltersBar,
|
||||||
type ProjectFilters,
|
type ProjectFilters,
|
||||||
|
|
@ -382,7 +375,6 @@ export default function ProjectsPage() {
|
||||||
|
|
||||||
// Bulk selection state
|
// Bulk selection state
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [allMatchingSelected, setAllMatchingSelected] = useState(false)
|
|
||||||
const [bulkStatus, setBulkStatus] = useState<string>('')
|
const [bulkStatus, setBulkStatus] = useState<string>('')
|
||||||
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
|
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
|
||||||
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
|
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
|
||||||
|
|
@ -390,52 +382,10 @@ export default function ProjectsPage() {
|
||||||
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
|
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
|
||||||
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false)
|
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false)
|
||||||
|
|
||||||
// Query for fetching all matching IDs (used for "select all across pages")
|
|
||||||
const allIdsQuery = trpc.project.listAllIds.useQuery(
|
|
||||||
{
|
|
||||||
search: filters.search || undefined,
|
|
||||||
statuses:
|
|
||||||
filters.statuses.length > 0
|
|
||||||
? (filters.statuses as Array<
|
|
||||||
| 'SUBMITTED'
|
|
||||||
| 'ELIGIBLE'
|
|
||||||
| 'ASSIGNED'
|
|
||||||
| 'SEMIFINALIST'
|
|
||||||
| 'FINALIST'
|
|
||||||
| 'REJECTED'
|
|
||||||
>)
|
|
||||||
: undefined,
|
|
||||||
roundId: filters.roundId || undefined,
|
|
||||||
competitionCategory:
|
|
||||||
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
|
|
||||||
undefined,
|
|
||||||
oceanIssue: filters.oceanIssue
|
|
||||||
? (filters.oceanIssue as
|
|
||||||
| 'POLLUTION_REDUCTION'
|
|
||||||
| 'CLIMATE_MITIGATION'
|
|
||||||
| 'TECHNOLOGY_INNOVATION'
|
|
||||||
| 'SUSTAINABLE_SHIPPING'
|
|
||||||
| 'BLUE_CARBON'
|
|
||||||
| 'HABITAT_RESTORATION'
|
|
||||||
| 'COMMUNITY_CAPACITY'
|
|
||||||
| 'SUSTAINABLE_FISHING'
|
|
||||||
| 'CONSUMER_AWARENESS'
|
|
||||||
| 'OCEAN_ACIDIFICATION'
|
|
||||||
| 'OTHER')
|
|
||||||
: undefined,
|
|
||||||
country: filters.country || undefined,
|
|
||||||
wantsMentorship: filters.wantsMentorship,
|
|
||||||
hasFiles: filters.hasFiles,
|
|
||||||
hasAssignments: filters.hasAssignments,
|
|
||||||
},
|
|
||||||
{ enabled: false } // Only fetch on demand
|
|
||||||
)
|
|
||||||
|
|
||||||
const bulkUpdateStatus = trpc.project.bulkUpdateStatus.useMutation({
|
const bulkUpdateStatus = trpc.project.bulkUpdateStatus.useMutation({
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
toast.success(`${result.updated} project${result.updated !== 1 ? 's' : ''} updated successfully`)
|
toast.success(`${result.updated} project${result.updated !== 1 ? 's' : ''} updated successfully`)
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
setAllMatchingSelected(false)
|
|
||||||
setBulkStatus('')
|
setBulkStatus('')
|
||||||
setBulkConfirmOpen(false)
|
setBulkConfirmOpen(false)
|
||||||
utils.project.list.invalidate()
|
utils.project.list.invalidate()
|
||||||
|
|
@ -449,7 +399,6 @@ export default function ProjectsPage() {
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to ${result.roundName}`)
|
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to ${result.roundName}`)
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
setAllMatchingSelected(false)
|
|
||||||
setBulkAssignRoundId('')
|
setBulkAssignRoundId('')
|
||||||
setBulkAssignDialogOpen(false)
|
setBulkAssignDialogOpen(false)
|
||||||
utils.project.list.invalidate()
|
utils.project.list.invalidate()
|
||||||
|
|
@ -463,7 +412,6 @@ export default function ProjectsPage() {
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
toast.success(`${result.deleted} project${result.deleted !== 1 ? 's' : ''} deleted`)
|
toast.success(`${result.deleted} project${result.deleted !== 1 ? 's' : ''} deleted`)
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
setAllMatchingSelected(false)
|
|
||||||
setBulkDeleteConfirmOpen(false)
|
setBulkDeleteConfirmOpen(false)
|
||||||
utils.project.list.invalidate()
|
utils.project.list.invalidate()
|
||||||
},
|
},
|
||||||
|
|
@ -473,7 +421,6 @@ export default function ProjectsPage() {
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleToggleSelect = (id: string) => {
|
const handleToggleSelect = (id: string) => {
|
||||||
setAllMatchingSelected(false)
|
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(id)) {
|
if (next.has(id)) {
|
||||||
|
|
@ -487,7 +434,6 @@ export default function ProjectsPage() {
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
if (!data) return
|
if (!data) return
|
||||||
setAllMatchingSelected(false)
|
|
||||||
const allVisible = data.projects.map((p) => p.id)
|
const allVisible = data.projects.map((p) => p.id)
|
||||||
const allSelected = allVisible.every((id) => selectedIds.has(id))
|
const allSelected = allVisible.every((id) => selectedIds.has(id))
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
|
|
@ -505,20 +451,6 @@ export default function ProjectsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectAllMatching = async () => {
|
|
||||||
const result = await allIdsQuery.refetch()
|
|
||||||
if (result.data) {
|
|
||||||
setSelectedIds(new Set(result.data.ids))
|
|
||||||
setAllMatchingSelected(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClearSelection = () => {
|
|
||||||
setSelectedIds(new Set())
|
|
||||||
setAllMatchingSelected(false)
|
|
||||||
setBulkStatus('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkApply = () => {
|
const handleBulkApply = () => {
|
||||||
if (!bulkStatus || selectedIds.size === 0) return
|
if (!bulkStatus || selectedIds.size === 0) return
|
||||||
setBulkConfirmOpen(true)
|
setBulkConfirmOpen(true)
|
||||||
|
|
@ -688,47 +620,6 @@ export default function ProjectsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Select All Banner */}
|
|
||||||
{data && allVisibleSelected && data.total > data.projects.length && !allMatchingSelected && (
|
|
||||||
<div className="flex items-center justify-center gap-2 rounded-lg border border-primary/20 bg-primary/5 px-4 py-2.5 text-sm">
|
|
||||||
<span>
|
|
||||||
All <strong>{data.projects.length}</strong> projects on this page are selected.
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
size="sm"
|
|
||||||
className="h-auto p-0 font-semibold"
|
|
||||||
onClick={handleSelectAllMatching}
|
|
||||||
disabled={allIdsQuery.isFetching}
|
|
||||||
>
|
|
||||||
{allIdsQuery.isFetching ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
|
||||||
Loading...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
`Select all ${data.total} matching projects`
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{allMatchingSelected && data && (
|
|
||||||
<div className="flex items-center justify-center gap-2 rounded-lg border border-primary/20 bg-primary/5 px-4 py-2.5 text-sm">
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-primary" />
|
|
||||||
<span>
|
|
||||||
All <strong>{selectedIds.size}</strong> matching projects are selected.
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
size="sm"
|
|
||||||
className="h-auto p-0"
|
|
||||||
onClick={handleClearSelection}
|
|
||||||
>
|
|
||||||
Clear selection
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -837,23 +728,9 @@ export default function ProjectsPage() {
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{project.teamName}
|
{project.teamName}
|
||||||
{project.country && (() => {
|
{project.country && (
|
||||||
const code = normalizeCountryToCode(project.country)
|
|
||||||
const flag = code ? getCountryFlag(code) : null
|
|
||||||
const name = code ? getCountryName(code) : project.country
|
|
||||||
return flag ? (
|
|
||||||
<TooltipProvider delayDuration={200}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="text-xs cursor-default"> · <span className="text-sm">{flag}</span></span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top"><p>{name}</p></TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
|
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
|
||||||
)
|
)}
|
||||||
})()}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -1106,23 +983,9 @@ export default function ProjectsPage() {
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="mt-0.5">
|
<CardDescription className="mt-0.5">
|
||||||
{project.teamName}
|
{project.teamName}
|
||||||
{project.country && (() => {
|
{project.country && (
|
||||||
const code = normalizeCountryToCode(project.country)
|
|
||||||
const flag = code ? getCountryFlag(code) : null
|
|
||||||
const name = code ? getCountryName(code) : project.country
|
|
||||||
return flag ? (
|
|
||||||
<TooltipProvider delayDuration={200}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="text-xs cursor-default"> · <span className="text-sm">{flag}</span></span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top"><p>{name}</p></TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
|
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
|
||||||
)
|
)}
|
||||||
})()}
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1255,7 +1118,10 @@ export default function ProjectsPage() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleClearSelection}
|
onClick={() => {
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
setBulkStatus('')
|
||||||
|
}}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<X className="mr-1 h-4 w-4" />
|
<X className="mr-1 h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,12 @@ import {
|
||||||
Users,
|
Users,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
PieChart,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
GitCompare,
|
GitCompare,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
Globe,
|
Globe,
|
||||||
|
Printer,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
|
|
@ -55,7 +57,6 @@ import {
|
||||||
JurorConsistencyChart,
|
JurorConsistencyChart,
|
||||||
DiversityMetricsChart,
|
DiversityMetricsChart,
|
||||||
} from '@/components/charts'
|
} from '@/components/charts'
|
||||||
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
|
||||||
|
|
||||||
function ReportsOverview() {
|
function ReportsOverview() {
|
||||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||||
|
|
@ -630,19 +631,6 @@ function DiversityTab() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const [pdfRoundId, setPdfRoundId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const { data: pdfPrograms } = trpc.program.list.useQuery({ includeRounds: true })
|
|
||||||
const pdfRounds = pdfPrograms?.flatMap((p) =>
|
|
||||||
p.rounds.map((r) => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
|
|
||||||
) || []
|
|
||||||
|
|
||||||
if (pdfRounds.length && !pdfRoundId) {
|
|
||||||
setPdfRoundId(pdfRounds[0].id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedPdfRound = pdfRounds.find((r) => r.id === pdfRoundId)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -678,27 +666,16 @@ export default function ReportsPage() {
|
||||||
Diversity
|
Diversity
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<div className="flex items-center gap-2">
|
<Button
|
||||||
<Select value={pdfRoundId || ''} onValueChange={setPdfRoundId}>
|
variant="outline"
|
||||||
<SelectTrigger className="w-[220px]">
|
size="sm"
|
||||||
<SelectValue placeholder="Select round for PDF" />
|
onClick={() => {
|
||||||
</SelectTrigger>
|
window.print()
|
||||||
<SelectContent>
|
}}
|
||||||
{pdfRounds.map((round) => (
|
>
|
||||||
<SelectItem key={round.id} value={round.id}>
|
<Printer className="mr-2 h-4 w-4" />
|
||||||
{round.programName} - {round.name}
|
Export PDF
|
||||||
</SelectItem>
|
</Button>
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{pdfRoundId && (
|
|
||||||
<ExportPdfButton
|
|
||||||
roundId={pdfRoundId}
|
|
||||||
roundName={selectedPdfRound?.name}
|
|
||||||
programName={selectedPdfRound?.programName}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="overview">
|
<TabsContent value="overview">
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ interface PageProps {
|
||||||
const updateRoundSchema = z
|
const updateRoundSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1, 'Name is required').max(255),
|
name: z.string().min(1, 'Name is required').max(255),
|
||||||
requiredReviews: z.number().int().min(0).max(10),
|
requiredReviews: z.number().int().min(1).max(10),
|
||||||
minAssignmentsPerJuror: z.number().int().min(1).max(50),
|
minAssignmentsPerJuror: z.number().int().min(1).max(50),
|
||||||
maxAssignmentsPerJuror: z.number().int().min(1).max(100),
|
maxAssignmentsPerJuror: z.number().int().min(1).max(100),
|
||||||
votingStartAt: z.date().nullable().optional(),
|
votingStartAt: z.date().nullable().optional(),
|
||||||
|
|
@ -206,7 +206,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||||
await updateRound.mutateAsync({
|
await updateRound.mutateAsync({
|
||||||
id: roundId,
|
id: roundId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
requiredReviews: roundType === 'FILTERING' ? 0 : data.requiredReviews,
|
requiredReviews: data.requiredReviews,
|
||||||
minAssignmentsPerJuror: data.minAssignmentsPerJuror,
|
minAssignmentsPerJuror: data.minAssignmentsPerJuror,
|
||||||
maxAssignmentsPerJuror: data.maxAssignmentsPerJuror,
|
maxAssignmentsPerJuror: data.maxAssignmentsPerJuror,
|
||||||
roundType,
|
roundType,
|
||||||
|
|
@ -301,7 +301,6 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{roundType !== 'FILTERING' && (
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="requiredReviews"
|
name="requiredReviews"
|
||||||
|
|
@ -326,7 +325,6 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
|
|
|
||||||
|
|
@ -105,8 +105,6 @@ export default function FilteringResultsPage({
|
||||||
: undefined,
|
: undefined,
|
||||||
page,
|
page,
|
||||||
perPage,
|
perPage,
|
||||||
}, {
|
|
||||||
staleTime: 0, // Always refetch - results change after filtering runs
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,6 @@ export default function FilteringRulesPage({
|
||||||
|
|
||||||
// AI screening config state
|
// AI screening config state
|
||||||
const [criteriaText, setCriteriaText] = useState('')
|
const [criteriaText, setCriteriaText] = useState('')
|
||||||
const [aiAction, setAiAction] = useState<'REJECT' | 'FLAG'>('REJECT')
|
|
||||||
const [aiBatchSize, setAiBatchSize] = useState('20')
|
const [aiBatchSize, setAiBatchSize] = useState('20')
|
||||||
const [aiParallelBatches, setAiParallelBatches] = useState('1')
|
const [aiParallelBatches, setAiParallelBatches] = useState('1')
|
||||||
|
|
||||||
|
|
@ -145,7 +144,7 @@ export default function FilteringRulesPage({
|
||||||
} else if (newRuleType === 'AI_SCREENING') {
|
} else if (newRuleType === 'AI_SCREENING') {
|
||||||
configJson = {
|
configJson = {
|
||||||
criteriaText,
|
criteriaText,
|
||||||
action: aiAction,
|
action: 'FLAG',
|
||||||
batchSize: parseInt(aiBatchSize) || 20,
|
batchSize: parseInt(aiBatchSize) || 20,
|
||||||
parallelBatches: parseInt(aiParallelBatches) || 1,
|
parallelBatches: parseInt(aiParallelBatches) || 1,
|
||||||
}
|
}
|
||||||
|
|
@ -419,23 +418,9 @@ export default function FilteringRulesPage({
|
||||||
placeholder="Describe the criteria for AI to evaluate projects against..."
|
placeholder="Describe the criteria for AI to evaluate projects against..."
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Action for Non-Matching Projects</Label>
|
|
||||||
<Select value={aiAction} onValueChange={(v) => setAiAction(v as 'REJECT' | 'FLAG')}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="REJECT">Auto Filter Out</SelectItem>
|
|
||||||
<SelectItem value="FLAG">Flag for Review</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{aiAction === 'REJECT'
|
AI screening always flags projects for human review, never
|
||||||
? 'Projects that don\'t meet criteria will be automatically filtered out.'
|
auto-rejects.
|
||||||
: 'Projects that don\'t meet criteria will be flagged for human review.'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Suspense, use, useState, useEffect, useCallback } from 'react'
|
import { Suspense, use, useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
|
@ -16,31 +16,6 @@ import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -52,12 +27,6 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip'
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Edit,
|
Edit,
|
||||||
|
|
@ -83,39 +52,13 @@ import {
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
ShieldCheck,
|
|
||||||
Download,
|
|
||||||
RotateCcw,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
||||||
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
|
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
|
||||||
import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog'
|
import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog'
|
||||||
import { Pagination } from '@/components/shared/pagination'
|
|
||||||
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
|
||||||
import { format, formatDistanceToNow, isFuture } from 'date-fns'
|
import { format, formatDistanceToNow, isFuture } from 'date-fns'
|
||||||
|
|
||||||
const OUTCOME_BADGES: Record<
|
|
||||||
string,
|
|
||||||
{ variant: 'default' | 'destructive' | 'secondary' | 'outline'; icon: React.ReactNode; label: string }
|
|
||||||
> = {
|
|
||||||
PASSED: {
|
|
||||||
variant: 'default',
|
|
||||||
icon: <CheckCircle2 className="mr-1 h-3 w-3" />,
|
|
||||||
label: 'Passed',
|
|
||||||
},
|
|
||||||
FILTERED_OUT: {
|
|
||||||
variant: 'destructive',
|
|
||||||
icon: <XCircle className="mr-1 h-3 w-3" />,
|
|
||||||
label: 'Filtered Out',
|
|
||||||
},
|
|
||||||
FLAGGED: {
|
|
||||||
variant: 'secondary',
|
|
||||||
icon: <AlertTriangle className="mr-1 h-3 w-3" />,
|
|
||||||
label: 'Flagged',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}
|
}
|
||||||
|
|
@ -127,18 +70,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
const [removeOpen, setRemoveOpen] = useState(false)
|
const [removeOpen, setRemoveOpen] = useState(false)
|
||||||
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
||||||
|
|
||||||
// Inline filtering results state
|
|
||||||
const [outcomeFilter, setOutcomeFilter] = useState<string>('')
|
|
||||||
const [resultsPage, setResultsPage] = useState(1)
|
|
||||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
|
||||||
const [overrideDialog, setOverrideDialog] = useState<{
|
|
||||||
id: string
|
|
||||||
currentOutcome: string
|
|
||||||
} | null>(null)
|
|
||||||
const [overrideOutcome, setOverrideOutcome] = useState<string>('PASSED')
|
|
||||||
const [overrideReason, setOverrideReason] = useState('')
|
|
||||||
const [showExportDialog, setShowExportDialog] = useState(false)
|
|
||||||
|
|
||||||
const { data: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
|
const { data: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
|
||||||
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
|
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
|
||||||
|
|
||||||
|
|
@ -146,10 +77,10 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
const isFilteringRound = round?.roundType === 'FILTERING'
|
const isFilteringRound = round?.roundType === 'FILTERING'
|
||||||
|
|
||||||
// Filtering queries (only fetch for FILTERING rounds)
|
// Filtering queries (only fetch for FILTERING rounds)
|
||||||
const { data: filteringStats, isLoading: isLoadingFilteringStats, refetch: refetchFilteringStats } =
|
const { data: filteringStats, refetch: refetchFilteringStats } =
|
||||||
trpc.filtering.getResultStats.useQuery(
|
trpc.filtering.getResultStats.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
{ enabled: isFilteringRound, staleTime: 0 }
|
{ enabled: isFilteringRound }
|
||||||
)
|
)
|
||||||
const { data: filteringRules } = trpc.filtering.getRules.useQuery(
|
const { data: filteringRules } = trpc.filtering.getRules.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
|
|
@ -162,7 +93,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
const { data: latestJob, refetch: refetchLatestJob } =
|
const { data: latestJob, refetch: refetchLatestJob } =
|
||||||
trpc.filtering.getLatestJob.useQuery(
|
trpc.filtering.getLatestJob.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
{ enabled: isFilteringRound, staleTime: 0 }
|
{ enabled: isFilteringRound }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Poll for job status when there's an active job
|
// Poll for job status when there's an active job
|
||||||
|
|
@ -171,7 +102,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
{
|
{
|
||||||
enabled: !!activeJobId,
|
enabled: !!activeJobId,
|
||||||
refetchInterval: activeJobId ? 2000 : false,
|
refetchInterval: activeJobId ? 2000 : false,
|
||||||
staleTime: 0,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -204,30 +134,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Inline filtering results
|
|
||||||
const resultsPerPage = 20
|
|
||||||
const { data: filteringResults, refetch: refetchResults } =
|
|
||||||
trpc.filtering.getResults.useQuery(
|
|
||||||
{
|
|
||||||
roundId,
|
|
||||||
outcome: outcomeFilter
|
|
||||||
? (outcomeFilter as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED')
|
|
||||||
: undefined,
|
|
||||||
page: resultsPage,
|
|
||||||
perPage: resultsPerPage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: isFilteringRound && (filteringStats?.total ?? 0) > 0,
|
|
||||||
staleTime: 0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const overrideResult = trpc.filtering.overrideResult.useMutation()
|
|
||||||
const reinstateProject = trpc.filtering.reinstateProject.useMutation()
|
|
||||||
const exportResults = trpc.export.filteringResults.useQuery(
|
|
||||||
{ roundId },
|
|
||||||
{ enabled: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Save as template
|
// Save as template
|
||||||
const saveAsTemplate = trpc.roundTemplate.createFromRound.useMutation({
|
const saveAsTemplate = trpc.roundTemplate.createFromRound.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
|
|
@ -274,14 +180,13 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
)
|
)
|
||||||
setActiveJobId(null)
|
setActiveJobId(null)
|
||||||
refetchFilteringStats()
|
refetchFilteringStats()
|
||||||
refetchResults()
|
|
||||||
refetchLatestJob()
|
refetchLatestJob()
|
||||||
} else if (jobStatus?.status === 'FAILED') {
|
} else if (jobStatus?.status === 'FAILED') {
|
||||||
toast.error(`Filtering failed: ${jobStatus.errorMessage || 'Unknown error'}`)
|
toast.error(`Filtering failed: ${jobStatus.errorMessage || 'Unknown error'}`)
|
||||||
setActiveJobId(null)
|
setActiveJobId(null)
|
||||||
refetchLatestJob()
|
refetchLatestJob()
|
||||||
}
|
}
|
||||||
}, [jobStatus?.status, jobStatus?.passedCount, jobStatus?.filteredCount, jobStatus?.flaggedCount, jobStatus?.errorMessage, refetchFilteringStats, refetchResults, refetchLatestJob])
|
}, [jobStatus?.status, jobStatus?.passedCount, jobStatus?.filteredCount, jobStatus?.flaggedCount, jobStatus?.errorMessage, refetchFilteringStats, refetchLatestJob])
|
||||||
|
|
||||||
const handleStartFiltering = async () => {
|
const handleStartFiltering = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -319,50 +224,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline results handlers
|
|
||||||
const toggleResultRow = (id: string) => {
|
|
||||||
const next = new Set(expandedRows)
|
|
||||||
if (next.has(id)) next.delete(id)
|
|
||||||
else next.add(id)
|
|
||||||
setExpandedRows(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOverride = async () => {
|
|
||||||
if (!overrideDialog || !overrideReason.trim()) return
|
|
||||||
try {
|
|
||||||
await overrideResult.mutateAsync({
|
|
||||||
id: overrideDialog.id,
|
|
||||||
finalOutcome: overrideOutcome as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED',
|
|
||||||
reason: overrideReason.trim(),
|
|
||||||
})
|
|
||||||
toast.success('Result overridden')
|
|
||||||
setOverrideDialog(null)
|
|
||||||
setOverrideReason('')
|
|
||||||
refetchResults()
|
|
||||||
refetchFilteringStats()
|
|
||||||
utils.project.list.invalidate()
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to override result')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleReinstate = async (projectId: string) => {
|
|
||||||
try {
|
|
||||||
await reinstateProject.mutateAsync({ roundId, projectId })
|
|
||||||
toast.success('Project reinstated')
|
|
||||||
refetchResults()
|
|
||||||
refetchFilteringStats()
|
|
||||||
utils.project.list.invalidate()
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to reinstate project')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRequestExportData = useCallback(async () => {
|
|
||||||
const result = await exportResults.refetch()
|
|
||||||
return result.data ?? undefined
|
|
||||||
}, [exportResults])
|
|
||||||
|
|
||||||
const isJobRunning = jobStatus?.status === 'RUNNING' || jobStatus?.status === 'PENDING'
|
const isJobRunning = jobStatus?.status === 'RUNNING' || jobStatus?.status === 'PENDING'
|
||||||
const progressPercent = jobStatus?.totalBatches
|
const progressPercent = jobStatus?.totalBatches
|
||||||
? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100)
|
? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100)
|
||||||
|
|
@ -805,19 +666,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
{isLoadingFilteringStats && !isJobRunning ? (
|
{filteringStats && filteringStats.total > 0 ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-4">
|
|
||||||
{[1, 2, 3, 4].map((i) => (
|
|
||||||
<div key={i} className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
|
||||||
<Skeleton className="h-10 w-10 rounded-full" />
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Skeleton className="h-7 w-12" />
|
|
||||||
<Skeleton className="h-4 w-16" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : filteringStats && filteringStats.total > 0 ? (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-4">
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
|
||||||
|
|
@ -872,332 +721,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Inline Filtering Results Table */}
|
|
||||||
{filteringStats && filteringStats.total > 0 && (
|
|
||||||
<>
|
|
||||||
<Separator />
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Outcome Filter Tabs */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{['', 'PASSED', 'FILTERED_OUT', 'FLAGGED'].map((outcome) => (
|
|
||||||
<Button
|
|
||||||
key={outcome || 'all'}
|
|
||||||
variant={outcomeFilter === outcome ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setOutcomeFilter(outcome)
|
|
||||||
setResultsPage(1)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{outcome ? (
|
|
||||||
<>
|
|
||||||
{OUTCOME_BADGES[outcome].icon}
|
|
||||||
{OUTCOME_BADGES[outcome].label}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'All'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowExportDialog(true)}
|
|
||||||
disabled={exportResults.isFetching}
|
|
||||||
>
|
|
||||||
{exportResults.isFetching ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Export CSV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Results Table */}
|
|
||||||
{filteringResults && filteringResults.results.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Project</TableHead>
|
|
||||||
<TableHead>Category</TableHead>
|
|
||||||
<TableHead>Outcome</TableHead>
|
|
||||||
<TableHead className="w-[300px]">AI Reason</TableHead>
|
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteringResults.results.map((result) => {
|
|
||||||
const isExpanded = expandedRows.has(result.id)
|
|
||||||
const effectiveOutcome =
|
|
||||||
result.finalOutcome || result.outcome
|
|
||||||
const badge = OUTCOME_BADGES[effectiveOutcome]
|
|
||||||
|
|
||||||
const aiScreening = result.aiScreeningJson as Record<string, {
|
|
||||||
meetsCriteria?: boolean
|
|
||||||
confidence?: number
|
|
||||||
reasoning?: string
|
|
||||||
qualityScore?: number
|
|
||||||
spamRisk?: boolean
|
|
||||||
}> | null
|
|
||||||
const firstAiResult = aiScreening ? Object.values(aiScreening)[0] : null
|
|
||||||
const aiReasoning = firstAiResult?.reasoning
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TableRow
|
|
||||||
key={result.id}
|
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => toggleResultRow(result.id)}
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">
|
|
||||||
{result.project.title}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{result.project.teamName}
|
|
||||||
{result.project.country && ` · ${result.project.country}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{result.project.competitionCategory ? (
|
|
||||||
<Badge variant="outline">
|
|
||||||
{result.project.competitionCategory.replace(
|
|
||||||
'_',
|
|
||||||
' '
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
'-'
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Badge variant={badge?.variant || 'secondary'}>
|
|
||||||
{badge?.icon}
|
|
||||||
{badge?.label || effectiveOutcome}
|
|
||||||
</Badge>
|
|
||||||
{result.overriddenByUser && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Overridden by {result.overriddenByUser.name || result.overriddenByUser.email}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{aiReasoning ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm line-clamp-2">
|
|
||||||
{aiReasoning}
|
|
||||||
</p>
|
|
||||||
{firstAiResult && (
|
|
||||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
|
||||||
{firstAiResult.confidence !== undefined && (
|
|
||||||
<span>Confidence: {Math.round(firstAiResult.confidence * 100)}%</span>
|
|
||||||
)}
|
|
||||||
{firstAiResult.qualityScore !== undefined && (
|
|
||||||
<span>Quality: {firstAiResult.qualityScore}/10</span>
|
|
||||||
)}
|
|
||||||
{firstAiResult.spamRisk && (
|
|
||||||
<Badge variant="destructive" className="text-xs">Spam Risk</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-muted-foreground italic">
|
|
||||||
No AI screening
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div
|
|
||||||
className="flex justify-end gap-1"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setOverrideOutcome('PASSED')
|
|
||||||
setOverrideDialog({
|
|
||||||
id: result.id,
|
|
||||||
currentOutcome: effectiveOutcome,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ShieldCheck className="mr-1 h-3 w-3" />
|
|
||||||
Override
|
|
||||||
</Button>
|
|
||||||
{effectiveOutcome === 'FILTERED_OUT' && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
handleReinstate(result.projectId)
|
|
||||||
}
|
|
||||||
disabled={reinstateProject.isPending}
|
|
||||||
>
|
|
||||||
<RotateCcw className="mr-1 h-3 w-3" />
|
|
||||||
Reinstate
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
{isExpanded && (
|
|
||||||
<TableRow key={`${result.id}-detail`}>
|
|
||||||
<TableCell colSpan={5} className="bg-muted/30">
|
|
||||||
<div className="p-4 space-y-4">
|
|
||||||
{/* Rule Results */}
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium mb-2">
|
|
||||||
Rule Results
|
|
||||||
</p>
|
|
||||||
{result.ruleResultsJson &&
|
|
||||||
Array.isArray(result.ruleResultsJson) ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(
|
|
||||||
result.ruleResultsJson as Array<{
|
|
||||||
ruleName: string
|
|
||||||
ruleType: string
|
|
||||||
passed: boolean
|
|
||||||
action: string
|
|
||||||
reasoning?: string
|
|
||||||
}>
|
|
||||||
).filter((rr) => rr.ruleType !== 'AI_SCREENING').map((rr, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex items-start gap-2 text-sm"
|
|
||||||
>
|
|
||||||
{rr.passed ? (
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5 flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">
|
|
||||||
{rr.ruleName}
|
|
||||||
</span>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{rr.ruleType.replace('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{rr.reasoning && (
|
|
||||||
<p className="text-muted-foreground mt-0.5">
|
|
||||||
{rr.reasoning}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No detailed rule results available
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI Screening Details */}
|
|
||||||
{aiScreening && Object.keys(aiScreening).length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium mb-2">
|
|
||||||
AI Screening Analysis
|
|
||||||
</p>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{Object.entries(aiScreening).map(([ruleId, screening]) => (
|
|
||||||
<div key={ruleId} className="p-3 bg-background rounded-lg border">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
{screening.meetsCriteria ? (
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="h-4 w-4 text-red-600" />
|
|
||||||
)}
|
|
||||||
<span className="font-medium text-sm">
|
|
||||||
{screening.meetsCriteria ? 'Meets Criteria' : 'Does Not Meet Criteria'}
|
|
||||||
</span>
|
|
||||||
{screening.spamRisk && (
|
|
||||||
<Badge variant="destructive" className="text-xs">
|
|
||||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
|
||||||
Spam Risk
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{screening.reasoning && (
|
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
|
||||||
{screening.reasoning}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
|
||||||
{screening.confidence !== undefined && (
|
|
||||||
<span>
|
|
||||||
Confidence: <strong>{Math.round(screening.confidence * 100)}%</strong>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{screening.qualityScore !== undefined && (
|
|
||||||
<span>
|
|
||||||
Quality Score: <strong>{screening.qualityScore}/10</strong>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Override Info */}
|
|
||||||
{result.overriddenByUser && (
|
|
||||||
<div className="pt-3 border-t">
|
|
||||||
<p className="text-sm font-medium mb-1">Manual Override</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Overridden to <strong>{result.finalOutcome}</strong> by{' '}
|
|
||||||
{result.overriddenByUser.name || result.overriddenByUser.email}
|
|
||||||
</p>
|
|
||||||
{result.overrideReason && (
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Reason: {result.overrideReason}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Pagination
|
|
||||||
page={filteringResults.page}
|
|
||||||
totalPages={filteringResults.totalPages}
|
|
||||||
total={filteringResults.total}
|
|
||||||
perPage={resultsPerPage}
|
|
||||||
onPageChange={setResultsPage}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : filteringResults ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
|
||||||
<CheckCircle2 className="h-8 w-8 text-muted-foreground/50" />
|
|
||||||
<p className="mt-2 text-sm font-medium">No results match this filter</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick links */}
|
{/* Quick links */}
|
||||||
<div className="flex flex-wrap gap-3 pt-2 border-t">
|
<div className="flex flex-wrap gap-3 pt-2 border-t">
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
|
|
@ -1209,6 +732,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
</Badge>
|
</Badge>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={`/admin/rounds/${round.id}/filtering/results`}>
|
||||||
|
<ClipboardCheck className="mr-2 h-4 w-4" />
|
||||||
|
Review Results
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
{filteringStats && filteringStats.total > 0 && (
|
{filteringStats && filteringStats.total > 0 && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleFinalizeFiltering}
|
onClick={handleFinalizeFiltering}
|
||||||
|
|
@ -1275,9 +804,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
Jury Assignments
|
Jury Assignments
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -1291,12 +817,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
)}
|
)}
|
||||||
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
|
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" className="max-w-xs">
|
|
||||||
<p>Uses AI to analyze all submitted evaluations for projects in this round and generate summary insights including strengths, weaknesses, and scoring patterns.</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -1341,82 +861,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
onOpenChange={setRemoveOpen}
|
onOpenChange={setRemoveOpen}
|
||||||
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
|
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Override Dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={!!overrideDialog}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setOverrideDialog(null)
|
|
||||||
setOverrideReason('')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Override Filtering Result</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Change the outcome for this project. This will be logged in the
|
|
||||||
audit trail.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>New Outcome</Label>
|
|
||||||
<Select
|
|
||||||
value={overrideOutcome}
|
|
||||||
onValueChange={setOverrideOutcome}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="PASSED">Passed</SelectItem>
|
|
||||||
<SelectItem value="FILTERED_OUT">Filtered Out</SelectItem>
|
|
||||||
<SelectItem value="FLAGGED">Flagged</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Reason</Label>
|
|
||||||
<Input
|
|
||||||
value={overrideReason}
|
|
||||||
onChange={(e) => setOverrideReason(e.target.value)}
|
|
||||||
placeholder="Explain why you're overriding..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setOverrideDialog(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleOverride}
|
|
||||||
disabled={
|
|
||||||
overrideResult.isPending || !overrideReason.trim()
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{overrideResult.isPending && (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
Override
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* CSV Export Dialog */}
|
|
||||||
<CsvExportDialog
|
|
||||||
open={showExportDialog}
|
|
||||||
onOpenChange={setShowExportDialog}
|
|
||||||
exportData={exportResults.data ?? undefined}
|
|
||||||
isLoading={exportResults.isFetching}
|
|
||||||
filename="filtering-results"
|
|
||||||
onRequestData={handleRequestExportData}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ const TEAM_NOTIFICATION_OPTIONS = [
|
||||||
const createRoundSchema = z.object({
|
const createRoundSchema = z.object({
|
||||||
programId: z.string().min(1, 'Please select a program'),
|
programId: z.string().min(1, 'Please select a program'),
|
||||||
name: z.string().min(1, 'Name is required').max(255),
|
name: z.string().min(1, 'Name is required').max(255),
|
||||||
requiredReviews: z.number().int().min(0).max(10),
|
requiredReviews: z.number().int().min(1).max(10),
|
||||||
votingStartAt: z.date().nullable().optional(),
|
votingStartAt: z.date().nullable().optional(),
|
||||||
votingEndAt: z.date().nullable().optional(),
|
votingEndAt: z.date().nullable().optional(),
|
||||||
}).refine((data) => {
|
}).refine((data) => {
|
||||||
|
|
@ -128,7 +128,7 @@ function CreateRoundContent() {
|
||||||
programId: data.programId,
|
programId: data.programId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
roundType,
|
roundType,
|
||||||
requiredReviews: roundType === 'FILTERING' ? 0 : data.requiredReviews,
|
requiredReviews: data.requiredReviews,
|
||||||
settingsJson: roundSettings,
|
settingsJson: roundSettings,
|
||||||
votingStartAt: data.votingStartAt ?? undefined,
|
votingStartAt: data.votingStartAt ?? undefined,
|
||||||
votingEndAt: data.votingEndAt ?? undefined,
|
votingEndAt: data.votingEndAt ?? undefined,
|
||||||
|
|
@ -291,7 +291,6 @@ function CreateRoundContent() {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{roundType !== 'FILTERING' && (
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="requiredReviews"
|
name="requiredReviews"
|
||||||
|
|
@ -314,7 +313,6 @@ function CreateRoundContent() {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,22 +8,15 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { SettingsContent } from '@/components/settings/settings-content'
|
import { SettingsContent } from '@/components/settings/settings-content'
|
||||||
|
|
||||||
// Categories that only super admins can access
|
async function SettingsLoader() {
|
||||||
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY'])
|
|
||||||
|
|
||||||
async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
|
|
||||||
const settings = await prisma.systemSettings.findMany({
|
const settings = await prisma.systemSettings.findMany({
|
||||||
orderBy: [{ category: 'asc' }, { key: 'asc' }],
|
orderBy: [{ category: 'asc' }, { key: 'asc' }],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Convert settings array to key-value map
|
// Convert settings array to key-value map
|
||||||
// For secrets, pass a marker but not the actual value
|
// For secrets, pass a marker but not the actual value
|
||||||
// For non-super-admins, filter out infrastructure categories
|
|
||||||
const settingsMap: Record<string, string> = {}
|
const settingsMap: Record<string, string> = {}
|
||||||
settings.forEach((setting) => {
|
settings.forEach((setting) => {
|
||||||
if (!isSuperAdmin && SUPER_ADMIN_CATEGORIES.has(setting.category)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (setting.isSecret && setting.value) {
|
if (setting.isSecret && setting.value) {
|
||||||
// Pass marker for UI to show "existing" state
|
// Pass marker for UI to show "existing" state
|
||||||
settingsMap[setting.key] = '********'
|
settingsMap[setting.key] = '********'
|
||||||
|
|
@ -32,7 +25,7 @@ async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return <SettingsContent initialSettings={settingsMap} isSuperAdmin={isSuperAdmin} />
|
return <SettingsContent initialSettings={settingsMap} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsSkeleton() {
|
function SettingsSkeleton() {
|
||||||
|
|
@ -59,13 +52,11 @@ function SettingsSkeleton() {
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
|
||||||
// Only admins (super admin + program admin) can access settings
|
// Only super admins can access settings
|
||||||
if (session?.user?.role !== 'SUPER_ADMIN' && session?.user?.role !== 'PROGRAM_ADMIN') {
|
if (session?.user?.role !== 'SUPER_ADMIN') {
|
||||||
redirect('/admin')
|
redirect('/admin')
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSuperAdmin = session?.user?.role === 'SUPER_ADMIN'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -78,7 +69,7 @@ export default async function SettingsPage() {
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Suspense fallback={<SettingsSkeleton />}>
|
<Suspense fallback={<SettingsSkeleton />}>
|
||||||
<SettingsLoader isSuperAdmin={isSuperAdmin} />
|
<SettingsLoader />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,21 +15,22 @@ import {
|
||||||
} from '@/components/ui/card'
|
} 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 { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
import {
|
import {
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
GitCompare,
|
GitCompare,
|
||||||
Zap,
|
Zap,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Target,
|
Target,
|
||||||
Waves,
|
|
||||||
} 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'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
function getGreeting(): string {
|
function getGreeting(): string {
|
||||||
|
|
@ -185,33 +186,29 @@ async function JuryDashboardContent() {
|
||||||
label: 'Total Assignments',
|
label: 'Total Assignments',
|
||||||
value: totalAssignments,
|
value: totalAssignments,
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
accentColor: 'border-l-blue-500',
|
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||||
iconBg: 'bg-blue-50 dark:bg-blue-950/40',
|
|
||||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Completed',
|
label: 'Completed',
|
||||||
value: completedAssignments,
|
value: completedAssignments,
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
accentColor: 'border-l-emerald-500',
|
iconBg: 'bg-green-100 dark:bg-green-900/30',
|
||||||
iconBg: 'bg-emerald-50 dark:bg-emerald-950/40',
|
iconColor: 'text-green-600 dark:text-green-400',
|
||||||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'In Progress',
|
label: 'In Progress',
|
||||||
value: inProgressAssignments,
|
value: inProgressAssignments,
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
accentColor: 'border-l-amber-500',
|
iconBg: 'bg-amber-100 dark:bg-amber-900/30',
|
||||||
iconBg: 'bg-amber-50 dark:bg-amber-950/40',
|
|
||||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Pending',
|
label: 'Pending',
|
||||||
value: pendingAssignments,
|
value: pendingAssignments,
|
||||||
icon: Target,
|
icon: Target,
|
||||||
accentColor: 'border-l-slate-400',
|
iconBg: 'bg-slate-100 dark:bg-slate-800',
|
||||||
iconBg: 'bg-slate-50 dark:bg-slate-800/50',
|
iconColor: 'text-slate-500',
|
||||||
iconColor: 'text-slate-500 dark:text-slate-400',
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -219,16 +216,14 @@ async function JuryDashboardContent() {
|
||||||
<>
|
<>
|
||||||
{/* Hero CTA - Jump to next evaluation */}
|
{/* Hero CTA - Jump to next evaluation */}
|
||||||
{nextUnevaluated && activeRemaining > 0 && (
|
{nextUnevaluated && activeRemaining > 0 && (
|
||||||
<AnimatedCard index={0}>
|
<Card className="border-primary/20 bg-gradient-to-r from-primary/5 to-accent/5">
|
||||||
<Card className="overflow-hidden border-0 shadow-lg">
|
<CardContent className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 py-5">
|
||||||
<div className="bg-gradient-to-r from-brand-blue to-brand-teal p-[1px] rounded-lg">
|
<div className="flex items-center gap-3">
|
||||||
<CardContent className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 py-5 px-6 rounded-[7px] bg-background">
|
<div className="rounded-full bg-primary/10 p-2.5">
|
||||||
<div className="flex items-center gap-4">
|
<Zap className="h-5 w-5 text-primary" />
|
||||||
<div className="rounded-xl bg-gradient-to-br from-brand-blue to-brand-teal p-3 shadow-sm">
|
|
||||||
<Zap className="h-5 w-5 text-white" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-base">
|
<p className="font-semibold">
|
||||||
{activeRemaining} evaluation{activeRemaining > 1 ? 's' : ''} remaining
|
{activeRemaining} evaluation{activeRemaining > 1 ? 's' : ''} remaining
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|
@ -236,87 +231,59 @@ async function JuryDashboardContent() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild size="lg" className="bg-brand-blue hover:bg-brand-blue-light shadow-md">
|
<Button asChild>
|
||||||
<Link href={`/jury/projects/${nextUnevaluated.project.id}/evaluate`}>
|
<Link href={`/jury/projects/${nextUnevaluated.project.id}/evaluate`}>
|
||||||
{nextUnevaluated.evaluation?.status === 'DRAFT' ? 'Continue Evaluation' : 'Start Evaluation'}
|
{nextUnevaluated.evaluation?.status === 'DRAFT' ? 'Continue Evaluation' : 'Start Evaluation'}
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{stats.map((stat, i) => (
|
{stats.map((stat) => (
|
||||||
<AnimatedCard key={stat.label} index={i + 1}>
|
<Card key={stat.label} className="transition-all hover:shadow-md">
|
||||||
<Card className={cn(
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
'border-l-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
<div className={cn('rounded-full p-2.5', stat.iconBg)}>
|
||||||
stat.accentColor,
|
|
||||||
)}>
|
|
||||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
|
||||||
<div className={cn('rounded-xl p-3', stat.iconBg)}>
|
|
||||||
<stat.icon className={cn('h-5 w-5', stat.iconColor)} />
|
<stat.icon className={cn('h-5 w-5', stat.iconColor)} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold tabular-nums tracking-tight">{stat.value}</p>
|
<p className="text-2xl font-bold tabular-nums">{stat.value}</p>
|
||||||
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p>
|
<p className="text-sm text-muted-foreground">{stat.label}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Overall Progress */}
|
{/* Overall Progress */}
|
||||||
<AnimatedCard index={5}>
|
<Card>
|
||||||
<Card className="overflow-hidden">
|
<CardContent className="py-4">
|
||||||
<div className="h-1 w-full bg-gradient-to-r from-brand-teal via-brand-blue to-brand-teal" />
|
<div className="flex items-center justify-between mb-2">
|
||||||
<CardContent className="py-5 px-6">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||||
<div className="flex items-center gap-2.5">
|
<span className="text-sm font-medium">Overall Completion</span>
|
||||||
<div className="rounded-lg bg-brand-blue/10 p-2 dark:bg-brand-blue/20">
|
|
||||||
<BarChart3 className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-semibold">Overall Completion</span>
|
<span className="text-sm font-semibold tabular-nums">
|
||||||
</div>
|
{completedAssignments}/{totalAssignments} ({completionRate.toFixed(0)}%)
|
||||||
<div className="flex items-baseline gap-1">
|
|
||||||
<span className="text-2xl font-bold tabular-nums text-brand-blue dark:text-brand-teal">
|
|
||||||
{completionRate.toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground ml-1">
|
|
||||||
({completedAssignments}/{totalAssignments})
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Progress value={completionRate} className="h-2.5" />
|
||||||
<div className="relative h-3 w-full overflow-hidden rounded-full bg-muted/60">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-blue transition-all duration-500 ease-out"
|
|
||||||
style={{ width: `${completionRate}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
|
||||||
|
|
||||||
{/* Main content -- two column layout */}
|
{/* Main content — two column layout */}
|
||||||
<div className="grid gap-6 lg:grid-cols-12">
|
<div className="grid gap-6 lg:grid-cols-12">
|
||||||
{/* Left column */}
|
{/* Left column */}
|
||||||
<div className="lg:col-span-7 space-y-6">
|
<div className="lg:col-span-7 space-y-6">
|
||||||
{/* Recent Assignments */}
|
{/* Recent Assignments */}
|
||||||
<AnimatedCard index={6}>
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="rounded-lg bg-brand-blue/10 p-1.5 dark:bg-brand-blue/20">
|
|
||||||
<ClipboardList className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-lg">My Assignments</CardTitle>
|
<CardTitle className="text-lg">My Assignments</CardTitle>
|
||||||
</div>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Button variant="ghost" size="sm" asChild className="text-brand-teal hover:text-brand-blue">
|
|
||||||
<Link href="/jury/assignments">
|
<Link href="/jury/assignments">
|
||||||
View all
|
View all
|
||||||
<ArrowRight className="ml-1 h-3 w-3" />
|
<ArrowRight className="ml-1 h-3 w-3" />
|
||||||
|
|
@ -326,8 +293,8 @@ async function JuryDashboardContent() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{recentAssignments.length > 0 ? (
|
{recentAssignments.length > 0 ? (
|
||||||
<div className="space-y-1">
|
<div className="divide-y">
|
||||||
{recentAssignments.map((assignment, idx) => {
|
{recentAssignments.map((assignment) => {
|
||||||
const evaluation = assignment.evaluation
|
const evaluation = assignment.evaluation
|
||||||
const isCompleted = evaluation?.status === 'SUBMITTED'
|
const isCompleted = evaluation?.status === 'SUBMITTED'
|
||||||
const isDraft = evaluation?.status === 'DRAFT'
|
const isDraft = evaluation?.status === 'DRAFT'
|
||||||
|
|
@ -341,24 +308,20 @@ async function JuryDashboardContent() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={assignment.id}
|
key={assignment.id}
|
||||||
className={cn(
|
className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"
|
||||||
'flex items-center justify-between gap-3 py-3 px-3 -mx-3 rounded-lg transition-colors duration-150',
|
|
||||||
'hover:bg-muted/50',
|
|
||||||
idx !== recentAssignments.length - 1 && 'border-b border-border/50',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/jury/projects/${assignment.project.id}`}
|
href={`/jury/projects/${assignment.project.id}`}
|
||||||
className="flex-1 min-w-0 group"
|
className="flex-1 min-w-0 group"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium truncate group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">
|
<p className="text-sm font-medium truncate group-hover:text-primary group-hover:underline transition-colors">
|
||||||
{assignment.project.title}
|
{assignment.project.title}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
{assignment.project.teamName}
|
{assignment.project.teamName}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-brand-blue/5 text-brand-blue/80 dark:bg-brand-teal/10 dark:text-brand-teal/80 border-0">
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||||
{assignment.round.name}
|
{assignment.round.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -384,7 +347,7 @@ async function JuryDashboardContent() {
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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-2">
|
||||||
<Link href={`/jury/projects/${assignment.project.id}/evaluate`}>
|
<Link href={`/jury/projects/${assignment.project.id}/evaluate`}>
|
||||||
{isDraft ? 'Continue' : 'Evaluate'}
|
{isDraft ? 'Continue' : 'Evaluate'}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -402,84 +365,56 @@ async function JuryDashboardContent() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-3">
|
<ClipboardList className="h-10 w-10 text-muted-foreground/30" />
|
||||||
<ClipboardList className="h-8 w-8 text-brand-teal/60" />
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
</div>
|
|
||||||
<p className="font-medium text-muted-foreground">
|
|
||||||
No assignments yet
|
No assignments yet
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground/70 mt-1 max-w-[240px]">
|
|
||||||
Assignments will appear here once an administrator assigns projects to you.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<AnimatedCard index={7}>
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20">
|
|
||||||
<Zap className="h-4 w-4 text-brand-teal" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<Link
|
<Button variant="outline" className="justify-start h-auto py-3" asChild>
|
||||||
href="/jury/assignments"
|
<Link href="/jury/assignments">
|
||||||
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
|
<ClipboardList className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
>
|
|
||||||
<div className="rounded-xl bg-blue-50 p-3 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40 dark:group-hover:bg-blue-950/60">
|
|
||||||
<ClipboardList className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">All Assignments</p>
|
<p className="font-medium">All Assignments</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">View and manage evaluations</p>
|
<p className="text-xs text-muted-foreground">View and manage evaluations</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
</Button>
|
||||||
href="/jury/compare"
|
<Button variant="outline" className="justify-start h-auto py-3" asChild>
|
||||||
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
<Link href="/jury/compare">
|
||||||
>
|
<GitCompare className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40 dark:group-hover:bg-teal-950/60">
|
|
||||||
<GitCompare className="h-5 w-5 text-brand-teal" />
|
|
||||||
</div>
|
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="font-semibold text-sm group-hover:text-brand-teal transition-colors">Compare Projects</p>
|
<p className="font-medium">Compare Projects</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Side-by-side comparison</p>
|
<p className="text-xs text-muted-foreground">Side-by-side comparison</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right column */}
|
{/* Right column */}
|
||||||
<div className="lg:col-span-5 space-y-6">
|
<div className="lg:col-span-5 space-y-6">
|
||||||
{/* Active Rounds */}
|
{/* Active Rounds */}
|
||||||
{activeRounds.length > 0 && (
|
{activeRounds.length > 0 && (
|
||||||
<AnimatedCard index={8}>
|
<Card>
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="rounded-lg bg-brand-blue/10 p-1.5 dark:bg-brand-blue/20">
|
|
||||||
<Waves className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-lg">Active Voting Rounds</CardTitle>
|
<CardTitle className="text-lg">Active Voting Rounds</CardTitle>
|
||||||
<CardDescription className="mt-0.5">
|
<CardDescription>
|
||||||
Rounds currently open for evaluation
|
Rounds currently open for evaluation
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{activeRounds.map(({ round, assignments: roundAssignments }) => {
|
{activeRounds.map(({ round, assignments: roundAssignments }) => {
|
||||||
|
|
@ -497,39 +432,32 @@ async function JuryDashboardContent() {
|
||||||
<div
|
<div
|
||||||
key={round.id}
|
key={round.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
'rounded-lg border p-4 space-y-3 transition-all hover:-translate-y-0.5 hover:shadow-md',
|
||||||
isUrgent
|
isUrgent && 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20'
|
||||||
? 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20'
|
|
||||||
: 'border-border/60 bg-muted/20 dark:bg-muted/10'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{round.name}</h3>
|
<h3 className="font-medium">{round.name}</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground">
|
||||||
{round.program.name} · {round.program.year}
|
{round.program.name} · {round.program.year}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isAlmostDone ? (
|
{isAlmostDone ? (
|
||||||
<Badge variant="success">Almost done</Badge>
|
<Badge variant="success">Almost done</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="info">Active</Badge>
|
<Badge variant="default">Active</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Progress</span>
|
<span className="text-muted-foreground">Progress</span>
|
||||||
<span className="font-semibold tabular-nums">
|
<span className="font-medium tabular-nums">
|
||||||
{roundCompleted}/{roundTotal}
|
{roundCompleted}/{roundTotal}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative h-2.5 w-full overflow-hidden rounded-full bg-muted/60">
|
<Progress value={roundProgress} className="h-2" />
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-blue transition-all duration-500 ease-out"
|
|
||||||
style={{ width: `${roundProgress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{deadline && (
|
{deadline && (
|
||||||
|
|
@ -546,7 +474,7 @@ async function JuryDashboardContent() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button asChild size="sm" className="w-full bg-brand-blue hover:bg-brand-blue-light shadow-sm">
|
<Button asChild size="sm" className="w-full">
|
||||||
<Link href={`/jury/assignments?round=${round.id}`}>
|
<Link href={`/jury/assignments?round=${round.id}`}>
|
||||||
View Assignments
|
View Assignments
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
|
@ -557,84 +485,65 @@ async function JuryDashboardContent() {
|
||||||
})}
|
})}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No active rounds */}
|
{/* No active rounds */}
|
||||||
{activeRounds.length === 0 && totalAssignments > 0 && (
|
{activeRounds.length === 0 && totalAssignments > 0 && (
|
||||||
<AnimatedCard index={8}>
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-10 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-3 dark:bg-brand-teal/20">
|
<div className="rounded-full bg-muted p-3 mb-3">
|
||||||
<Clock className="h-7 w-7 text-brand-teal/70" />
|
<Clock className="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<p className="font-semibold text-sm">No active voting rounds</p>
|
<p className="font-medium">No active voting rounds</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1 max-w-[220px]">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Check back later when a voting window opens
|
Check back later when a voting window opens
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Completion Summary by Round */}
|
{/* Completion Summary by Round */}
|
||||||
{Object.keys(assignmentsByRound).length > 0 && (
|
{Object.keys(assignmentsByRound).length > 0 && (
|
||||||
<AnimatedCard index={9}>
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20">
|
|
||||||
<BarChart3 className="h-4 w-4 text-brand-teal" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-lg">Round Summary</CardTitle>
|
<CardTitle className="text-lg">Round Summary</CardTitle>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-3">
|
||||||
{Object.values(assignmentsByRound).map(({ round, assignments: roundAssignments }) => {
|
{Object.values(assignmentsByRound).map(({ round, assignments: roundAssignments }) => {
|
||||||
const done = roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length
|
const done = roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length
|
||||||
const total = roundAssignments.length
|
const total = roundAssignments.length
|
||||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0
|
const pct = total > 0 ? Math.round((done / total) * 100) : 0
|
||||||
return (
|
return (
|
||||||
<div key={round.id} className="space-y-2">
|
<div key={round.id} className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="font-medium truncate">{round.name}</span>
|
<span className="font-medium truncate">{round.name}</span>
|
||||||
<div className="flex items-baseline gap-1 shrink-0 ml-2">
|
<span className="text-muted-foreground tabular-nums shrink-0 ml-2">
|
||||||
<span className="font-bold tabular-nums text-brand-blue dark:text-brand-teal">{pct}%</span>
|
{done}/{total} ({pct}%)
|
||||||
<span className="text-xs text-muted-foreground">({done}/{total})</span>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative h-2 w-full overflow-hidden rounded-full bg-muted/60">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-blue transition-all duration-500 ease-out"
|
|
||||||
style={{ width: `${pct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Progress value={pct} className="h-1.5" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* No assignments at all */}
|
{/* No assignments at all */}
|
||||||
{totalAssignments === 0 && (
|
{totalAssignments === 0 && (
|
||||||
<AnimatedCard index={1}>
|
<Card>
|
||||||
<Card className="overflow-hidden">
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<div className="h-1 w-full bg-gradient-to-r from-brand-teal/40 via-brand-blue/40 to-brand-teal/40" />
|
<div className="rounded-full bg-muted p-4 mb-4">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-14 text-center">
|
<ClipboardList className="h-8 w-8 text-muted-foreground" />
|
||||||
<div className="rounded-2xl bg-gradient-to-br from-brand-teal/10 to-brand-blue/10 p-5 mb-4 dark:from-brand-teal/20 dark:to-brand-blue/20">
|
|
||||||
<ClipboardList className="h-10 w-10 text-brand-teal/60" />
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-semibold">No assignments yet</p>
|
<p className="text-lg font-medium">No assignments yet</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1.5 max-w-sm">
|
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
|
||||||
You'll see your project assignments here once they're assigned to you by an administrator.
|
You'll see your project assignments here once they're assigned to you by an administrator.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
@ -643,42 +552,34 @@ async function JuryDashboardContent() {
|
||||||
function DashboardSkeleton() {
|
function DashboardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Stats skeleton */}
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{[...Array(4)].map((_, i) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
<Card key={i} className="border-l-4 border-l-muted">
|
<Card key={i}>
|
||||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
<Skeleton className="h-11 w-11 rounded-xl" />
|
<Skeleton className="h-10 w-10 rounded-full" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-7 w-12" />
|
<Skeleton className="h-6 w-12" />
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="h-4 w-20" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Progress bar skeleton */}
|
<Card>
|
||||||
<Card className="overflow-hidden">
|
<CardContent className="py-4">
|
||||||
<div className="h-1 w-full bg-muted" />
|
<Skeleton className="h-2.5 w-full" />
|
||||||
<CardContent className="py-5 px-6">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<Skeleton className="h-4 w-36" />
|
|
||||||
<Skeleton className="h-7 w-16" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-3 w-full rounded-full" />
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{/* Two-column skeleton */}
|
|
||||||
<div className="grid gap-6 lg:grid-cols-12">
|
<div className="grid gap-6 lg:grid-cols-12">
|
||||||
<div className="lg:col-span-7">
|
<div className="lg:col-span-7">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader>
|
||||||
<Skeleton className="h-5 w-40" />
|
<Skeleton className="h-5 w-32" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{[...Array(4)].map((_, i) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
<div key={i} className="flex items-center justify-between py-2">
|
<div key={i} className="flex items-center justify-between">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<Skeleton className="h-4 w-48" />
|
<Skeleton className="h-4 w-48" />
|
||||||
<Skeleton className="h-3 w-32" />
|
<Skeleton className="h-3 w-32" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -688,27 +589,13 @@ function DashboardSkeleton() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-5 space-y-6">
|
<div className="lg:col-span-5">
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<div className="h-1 w-full bg-muted" />
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<Skeleton className="h-5 w-44" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<Skeleton className="h-28 w-full rounded-xl" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader>
|
||||||
<Skeleton className="h-5 w-36" />
|
<Skeleton className="h-5 w-40" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{[...Array(2)].map((_, i) => (
|
<Skeleton className="h-24 w-full rounded-lg" />
|
||||||
<div key={i} className="space-y-2">
|
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className="h-2 w-full rounded-full" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -723,17 +610,14 @@ export default async function JuryDashboardPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="relative">
|
<div>
|
||||||
<div className="absolute -top-6 -left-6 -right-6 h-32 bg-gradient-to-b from-brand-blue/[0.03] to-transparent dark:from-brand-blue/[0.06] pointer-events-none rounded-xl" />
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
<div className="relative">
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
|
||||||
{getGreeting()}, {session?.user?.name || 'Juror'}
|
{getGreeting()}, {session?.user?.name || 'Juror'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-0.5">
|
<p className="text-muted-foreground">
|
||||||
Here's an overview of your evaluation progress
|
Here's an overview of your evaluation progress
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Suspense fallback={<DashboardSkeleton />}>
|
<Suspense fallback={<DashboardSkeleton />}>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,346 @@
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
import { Suspense } from 'react'
|
||||||
import { auth } from '@/lib/auth'
|
import { auth } from '@/lib/auth'
|
||||||
import { ObserverDashboardContent } from '@/components/observer/observer-dashboard-content'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Observer Dashboard' }
|
export const metadata: Metadata = { title: 'Observer Dashboard' }
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
FolderKanban,
|
||||||
|
ClipboardList,
|
||||||
|
Users,
|
||||||
|
CheckCircle2,
|
||||||
|
Eye,
|
||||||
|
BarChart3,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { cn, formatDateOnly } from '@/lib/utils'
|
||||||
|
|
||||||
|
async function ObserverDashboardContent() {
|
||||||
|
const [
|
||||||
|
programCount,
|
||||||
|
activeRoundCount,
|
||||||
|
projectCount,
|
||||||
|
jurorCount,
|
||||||
|
evaluationStats,
|
||||||
|
recentRounds,
|
||||||
|
evaluationScores,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.program.count(),
|
||||||
|
prisma.round.count({ where: { status: 'ACTIVE' } }),
|
||||||
|
prisma.project.count(),
|
||||||
|
prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }),
|
||||||
|
prisma.evaluation.groupBy({
|
||||||
|
by: ['status'],
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
prisma.round.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
include: {
|
||||||
|
program: { select: { name: true, year: true } },
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
projects: true,
|
||||||
|
assignments: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
assignments: {
|
||||||
|
select: {
|
||||||
|
evaluation: { select: { status: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.evaluation.findMany({
|
||||||
|
where: { status: 'SUBMITTED', globalScore: { not: null } },
|
||||||
|
select: { globalScore: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const submittedCount =
|
||||||
|
evaluationStats.find((e) => e.status === 'SUBMITTED')?._count || 0
|
||||||
|
const draftCount =
|
||||||
|
evaluationStats.find((e) => e.status === 'DRAFT')?._count || 0
|
||||||
|
const totalEvaluations = submittedCount + draftCount
|
||||||
|
const completionRate =
|
||||||
|
totalEvaluations > 0 ? (submittedCount / totalEvaluations) * 100 : 0
|
||||||
|
|
||||||
|
// Score distribution computation
|
||||||
|
const scores = evaluationScores.map(e => e.globalScore!).filter(s => s != null)
|
||||||
|
const buckets = [
|
||||||
|
{ label: '9-10', min: 9, max: 10, color: 'bg-green-500' },
|
||||||
|
{ label: '7-8', min: 7, max: 8.99, color: 'bg-emerald-400' },
|
||||||
|
{ label: '5-6', min: 5, max: 6.99, color: 'bg-amber-400' },
|
||||||
|
{ label: '3-4', min: 3, max: 4.99, color: 'bg-orange-400' },
|
||||||
|
{ label: '1-2', min: 1, max: 2.99, color: 'bg-red-400' },
|
||||||
|
]
|
||||||
|
const maxCount = Math.max(...buckets.map(b => scores.filter(s => s >= b.min && s <= b.max).length), 1)
|
||||||
|
const scoreDistribution = buckets.map(b => {
|
||||||
|
const count = scores.filter(s => s >= b.min && s <= b.max).length
|
||||||
|
return { ...b, count, percentage: (count / maxCount) * 100 }
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Observer Notice */}
|
||||||
|
<div className="rounded-lg border-2 border-blue-300 bg-blue-50 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<Eye className="h-4 w-4 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-semibold text-blue-900">Observer Mode</p>
|
||||||
|
<Badge variant="outline" className="border-blue-300 text-blue-700 text-xs">
|
||||||
|
Read-Only
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
You have read-only access to view platform statistics and reports.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card className="transition-all hover:shadow-md">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Programs</CardTitle>
|
||||||
|
<FolderKanban className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{programCount}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="transition-all hover:shadow-md">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
||||||
|
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{projectCount}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Across all rounds</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="transition-all hover:shadow-md">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{jurorCount}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Active members</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="transition-all hover:shadow-md">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{submittedCount}</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Progress value={completionRate} className="h-2" />
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{completionRate.toFixed(0)}% completion rate
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Rounds */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Rounds</CardTitle>
|
||||||
|
<CardDescription>Overview of the latest voting rounds</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{recentRounds.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<FolderKanban className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
No rounds created yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recentRounds.map((round) => (
|
||||||
|
<div
|
||||||
|
key={round.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border p-4 transition-all hover:shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium">{round.name}</p>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
round.status === 'ACTIVE'
|
||||||
|
? 'default'
|
||||||
|
: round.status === 'CLOSED'
|
||||||
|
? 'secondary'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{round.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{round.program.year} Edition
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-sm">
|
||||||
|
<p>{round._count.projects} projects</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{round._count.assignments} assignments
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Score Distribution */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Score Distribution</CardTitle>
|
||||||
|
<CardDescription>Distribution of global scores across all evaluations</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{scoreDistribution.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<BarChart3 className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">No completed evaluations yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{scoreDistribution.map((bucket) => (
|
||||||
|
<div key={bucket.label} className="flex items-center gap-3">
|
||||||
|
<span className="text-sm w-16 text-right tabular-nums">{bucket.label}</span>
|
||||||
|
<div className="flex-1 h-6 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn('h-full rounded-full transition-all', bucket.color)}
|
||||||
|
style={{ width: `${bucket.percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm tabular-nums w-8">{bucket.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Jury Completion by Round */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Jury Completion by Round</CardTitle>
|
||||||
|
<CardDescription>Evaluation completion rate per round</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{recentRounds.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<FolderKanban className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">No rounds available</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recentRounds.map((round) => {
|
||||||
|
const submittedInRound = round.assignments.filter(a => a.evaluation?.status === 'SUBMITTED').length
|
||||||
|
const totalAssignments = round.assignments.length
|
||||||
|
const percent = totalAssignments > 0 ? Math.round((submittedInRound / totalAssignments) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div key={round.id} className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{round.name}</span>
|
||||||
|
<Badge variant={round.status === 'ACTIVE' ? 'default' : 'secondary'}>{round.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold tabular-nums">{percent}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={percent} className="h-2" />
|
||||||
|
<p className="text-xs text-muted-foreground">{submittedInRound} of {totalAssignments} evaluations submitted</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader className="space-y-0 pb-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-24" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-20 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ObserverDashboardPage() {
|
export default async function ObserverDashboardPage() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
|
||||||
return <ObserverDashboardContent userName={session?.user?.name || undefined} />
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Welcome, {session?.user?.name || 'Observer'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Suspense fallback={<DashboardSkeleton />}>
|
||||||
|
<ObserverDashboardContent />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ import {
|
||||||
GitCompare,
|
GitCompare,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
Globe,
|
Globe,
|
||||||
|
Printer,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
ScoreDistributionChart,
|
ScoreDistributionChart,
|
||||||
|
|
@ -51,7 +53,6 @@ import {
|
||||||
JurorConsistencyChart,
|
JurorConsistencyChart,
|
||||||
DiversityMetricsChart,
|
DiversityMetricsChart,
|
||||||
} from '@/components/charts'
|
} from '@/components/charts'
|
||||||
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
|
||||||
|
|
||||||
function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
|
function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
|
||||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||||
|
|
@ -607,13 +608,14 @@ export default function ObserverReportsPage() {
|
||||||
Diversity
|
Diversity
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{selectedRoundId && (
|
<Button
|
||||||
<ExportPdfButton
|
variant="outline"
|
||||||
roundId={selectedRoundId}
|
size="sm"
|
||||||
roundName={rounds.find((r) => r.id === selectedRoundId)?.name}
|
onClick={() => window.print()}
|
||||||
programName={rounds.find((r) => r.id === selectedRoundId)?.programName}
|
>
|
||||||
/>
|
<Printer className="mr-2 h-4 w-4" />
|
||||||
)}
|
Export PDF
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="overview">
|
<TabsContent value="overview">
|
||||||
|
|
|
||||||
|
|
@ -142,13 +142,13 @@
|
||||||
:root {
|
:root {
|
||||||
/* MOPC Brand Colors - mapped to shadcn/ui variables */
|
/* MOPC Brand Colors - mapped to shadcn/ui variables */
|
||||||
--background: 0 0% 99.5%;
|
--background: 0 0% 99.5%;
|
||||||
--foreground: 220 13% 18%;
|
--foreground: 198 85% 18%;
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 220 13% 18%;
|
--card-foreground: 198 85% 18%;
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 220 13% 18%;
|
--popover-foreground: 198 85% 18%;
|
||||||
|
|
||||||
/* Primary - MOPC Red */
|
/* Primary - MOPC Red */
|
||||||
--primary: 354 90% 47%;
|
--primary: 354 90% 47%;
|
||||||
|
|
@ -156,14 +156,14 @@
|
||||||
|
|
||||||
/* Secondary - Warm gray */
|
/* Secondary - Warm gray */
|
||||||
--secondary: 30 6% 96%;
|
--secondary: 30 6% 96%;
|
||||||
--secondary-foreground: 220 13% 18%;
|
--secondary-foreground: 198 85% 18%;
|
||||||
|
|
||||||
--muted: 30 6% 96%;
|
--muted: 30 6% 96%;
|
||||||
--muted-foreground: 220 8% 46%;
|
--muted-foreground: 30 8% 38%;
|
||||||
|
|
||||||
/* Accent - Light teal tint for hover states */
|
/* Accent - MOPC Teal */
|
||||||
--accent: 194 30% 94%;
|
--accent: 194 25% 44%;
|
||||||
--accent-foreground: 220 13% 18%;
|
--accent-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--destructive: 0 84% 60%;
|
--destructive: 0 84% 60%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
|
@ -181,32 +181,32 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 220 15% 8%;
|
--background: 198 85% 8%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
|
|
||||||
--card: 220 15% 10%;
|
--card: 198 85% 10%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--popover: 220 15% 10%;
|
--popover: 198 85% 10%;
|
||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--primary: 354 90% 50%;
|
--primary: 354 90% 50%;
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--secondary: 220 15% 18%;
|
--secondary: 198 30% 18%;
|
||||||
--secondary-foreground: 0 0% 98%;
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--muted: 220 15% 18%;
|
--muted: 198 30% 18%;
|
||||||
--muted-foreground: 0 0% 64%;
|
--muted-foreground: 0 0% 64%;
|
||||||
|
|
||||||
--accent: 194 20% 18%;
|
--accent: 194 25% 50%;
|
||||||
--accent-foreground: 0 0% 98%;
|
--accent-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--destructive: 0 84% 55%;
|
--destructive: 0 84% 55%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--border: 220 15% 22%;
|
--border: 198 30% 22%;
|
||||||
--input: 220 15% 22%;
|
--input: 198 30% 22%;
|
||||||
--ring: 354 90% 50%;
|
--ring: 354 90% 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,16 +42,11 @@ const TAB_ROLES: Record<TabKey, RoleValue[] | undefined> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
|
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
|
||||||
NONE: 'secondary',
|
|
||||||
ACTIVE: 'success',
|
ACTIVE: 'success',
|
||||||
INVITED: 'secondary',
|
INVITED: 'secondary',
|
||||||
SUSPENDED: 'destructive',
|
SUSPENDED: 'destructive',
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
|
||||||
NONE: 'Not Invited',
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
||||||
JURY_MEMBER: 'default',
|
JURY_MEMBER: 'default',
|
||||||
MENTOR: 'secondary',
|
MENTOR: 'secondary',
|
||||||
|
|
@ -97,9 +92,6 @@ export function MembersContent() {
|
||||||
|
|
||||||
const roles = TAB_ROLES[tab]
|
const roles = TAB_ROLES[tab]
|
||||||
|
|
||||||
const { data: currentUser } = trpc.user.me.useQuery()
|
|
||||||
const currentUserRole = currentUser?.role as RoleValue | undefined
|
|
||||||
|
|
||||||
const { data, isLoading } = trpc.user.list.useQuery({
|
const { data, isLoading } = trpc.user.list.useQuery({
|
||||||
roles: roles,
|
roles: roles,
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
|
|
@ -224,7 +216,7 @@ export function MembersContent() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={statusColors[user.status] || 'secondary'}>
|
<Badge variant={statusColors[user.status] || 'secondary'}>
|
||||||
{statusLabels[user.status] || user.status}
|
{user.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
@ -241,8 +233,6 @@ export function MembersContent() {
|
||||||
userId={user.id}
|
userId={user.id}
|
||||||
userEmail={user.email}
|
userEmail={user.email}
|
||||||
userStatus={user.status}
|
userStatus={user.status}
|
||||||
userRole={user.role as RoleValue}
|
|
||||||
currentUserRole={currentUserRole}
|
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -273,7 +263,7 @@ export function MembersContent() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={statusColors[user.status] || 'secondary'}>
|
<Badge variant={statusColors[user.status] || 'secondary'}>
|
||||||
{statusLabels[user.status] || user.status}
|
{user.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -315,8 +305,6 @@ export function MembersContent() {
|
||||||
userId={user.id}
|
userId={user.id}
|
||||||
userEmail={user.email}
|
userEmail={user.email}
|
||||||
userStatus={user.status}
|
userStatus={user.status}
|
||||||
userRole={user.role as RoleValue}
|
|
||||||
currentUserRole={currentUserRole}
|
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,108 @@ import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { FileDown, Loader2 } from 'lucide-react'
|
import { FileDown, Loader2 } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
|
||||||
createReportDocument,
|
|
||||||
addCoverPage,
|
|
||||||
addPageBreak,
|
|
||||||
addHeader,
|
|
||||||
addSectionTitle,
|
|
||||||
addStatCards,
|
|
||||||
addTable,
|
|
||||||
addAllPageFooters,
|
|
||||||
savePdf,
|
|
||||||
} from '@/lib/pdf-generator'
|
|
||||||
|
|
||||||
interface PdfReportProps {
|
interface PdfReportProps {
|
||||||
roundId: string
|
roundId: string
|
||||||
sections: string[]
|
sections: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildReportHtml(reportData: Record<string, unknown>): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
parts.push(`<!DOCTYPE html><html><head>
|
||||||
|
<title>Round Report - ${String(reportData.roundName || 'Report')}</title>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;600;700&display=swap');
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Montserrat', sans-serif; color: #1a1a1a; padding: 40px; max-width: 1000px; margin: 0 auto; }
|
||||||
|
h1 { color: #053d57; font-size: 24px; font-weight: 700; margin-bottom: 8px; }
|
||||||
|
h2 { color: #053d57; font-size: 18px; font-weight: 600; margin: 24px 0 12px; border-bottom: 2px solid #053d57; padding-bottom: 4px; }
|
||||||
|
p { font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
|
||||||
|
.subtitle { color: #557f8c; font-size: 14px; margin-bottom: 24px; }
|
||||||
|
.generated { color: #888; font-size: 10px; margin-bottom: 32px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 11px; }
|
||||||
|
th { background: #053d57; color: white; text-align: left; padding: 8px 12px; font-weight: 600; }
|
||||||
|
td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; }
|
||||||
|
tr:nth-child(even) td { background: #f8f8f8; }
|
||||||
|
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin: 16px 0; }
|
||||||
|
.stat-card { background: #f0f4f8; border-radius: 8px; padding: 16px; text-align: center; }
|
||||||
|
.stat-value { font-size: 28px; font-weight: 700; color: #053d57; }
|
||||||
|
.stat-label { font-size: 11px; color: #557f8c; margin-top: 4px; }
|
||||||
|
@media print { body { padding: 20px; } .no-print { display: none; } }
|
||||||
|
</style>
|
||||||
|
</head><body>`)
|
||||||
|
|
||||||
|
parts.push(`<div class="no-print" style="margin-bottom: 20px;">
|
||||||
|
<button onclick="window.print()" style="background: #053d57; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-family: Montserrat; font-weight: 600;">
|
||||||
|
Print / Save as PDF
|
||||||
|
</button>
|
||||||
|
</div>`)
|
||||||
|
|
||||||
|
parts.push(`<h1>${escapeHtml(String(reportData.roundName || 'Round Report'))}</h1>`)
|
||||||
|
parts.push(`<p class="subtitle">${escapeHtml(String(reportData.programName || ''))}</p>`)
|
||||||
|
parts.push(`<p class="generated">Generated on ${new Date().toLocaleString()}</p>`)
|
||||||
|
|
||||||
|
const summary = reportData.summary as Record<string, unknown> | undefined
|
||||||
|
if (summary) {
|
||||||
|
parts.push(`<h2>Summary</h2><div class="stat-grid">`)
|
||||||
|
parts.push(statCard(summary.totalProjects, 'Projects'))
|
||||||
|
parts.push(statCard(summary.totalEvaluations, 'Evaluations'))
|
||||||
|
parts.push(statCard(summary.averageScore != null ? Number(summary.averageScore).toFixed(1) : '--', 'Avg Score'))
|
||||||
|
parts.push(statCard(summary.completionRate != null ? Number(summary.completionRate).toFixed(0) + '%' : '--', 'Completion'))
|
||||||
|
parts.push(`</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rankings = reportData.rankings as Array<Record<string, unknown>> | undefined
|
||||||
|
if (rankings && rankings.length > 0) {
|
||||||
|
parts.push(`<h2>Project Rankings</h2><table><thead><tr>
|
||||||
|
<th>#</th><th>Project</th><th>Team</th><th>Avg Score</th><th>Evaluations</th>
|
||||||
|
</tr></thead><tbody>`)
|
||||||
|
for (const p of rankings) {
|
||||||
|
parts.push(`<tr>
|
||||||
|
<td>${escapeHtml(String(p.rank ?? ''))}</td>
|
||||||
|
<td>${escapeHtml(String(p.title ?? ''))}</td>
|
||||||
|
<td>${escapeHtml(String(p.team ?? ''))}</td>
|
||||||
|
<td>${Number(p.avgScore ?? 0).toFixed(2)}</td>
|
||||||
|
<td>${String(p.evalCount ?? 0)}</td>
|
||||||
|
</tr>`)
|
||||||
|
}
|
||||||
|
parts.push(`</tbody></table>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const jurorStats = reportData.jurorStats as Array<Record<string, unknown>> | undefined
|
||||||
|
if (jurorStats && jurorStats.length > 0) {
|
||||||
|
parts.push(`<h2>Juror Statistics</h2><table><thead><tr>
|
||||||
|
<th>Juror</th><th>Assigned</th><th>Completed</th><th>Completion %</th><th>Avg Score Given</th>
|
||||||
|
</tr></thead><tbody>`)
|
||||||
|
for (const j of jurorStats) {
|
||||||
|
parts.push(`<tr>
|
||||||
|
<td>${escapeHtml(String(j.name ?? ''))}</td>
|
||||||
|
<td>${String(j.assigned ?? 0)}</td>
|
||||||
|
<td>${String(j.completed ?? 0)}</td>
|
||||||
|
<td>${Number(j.completionRate ?? 0).toFixed(0)}%</td>
|
||||||
|
<td>${Number(j.avgScore ?? 0).toFixed(2)}</td>
|
||||||
|
</tr>`)
|
||||||
|
}
|
||||||
|
parts.push(`</tbody></table>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(`</body></html>`)
|
||||||
|
return parts.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
function statCard(value: unknown, label: string): string {
|
||||||
|
return `<div class="stat-card"><div class="stat-value">${escapeHtml(String(value ?? 0))}</div><div class="stat-label">${escapeHtml(label)}</div></div>`
|
||||||
|
}
|
||||||
|
|
||||||
export function PdfReportGenerator({ roundId, sections }: PdfReportProps) {
|
export function PdfReportGenerator({ roundId, sections }: PdfReportProps) {
|
||||||
const [generating, setGenerating] = useState(false)
|
const [generating, setGenerating] = useState(false)
|
||||||
|
|
||||||
|
|
@ -32,8 +117,6 @@ export function PdfReportGenerator({ roundId, sections }: PdfReportProps) {
|
||||||
|
|
||||||
const handleGenerate = useCallback(async () => {
|
const handleGenerate = useCallback(async () => {
|
||||||
setGenerating(true)
|
setGenerating(true)
|
||||||
toast.info('Generating PDF report...')
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await refetch()
|
const result = await refetch()
|
||||||
if (!result.data) {
|
if (!result.data) {
|
||||||
|
|
@ -41,113 +124,20 @@ export function PdfReportGenerator({ roundId, sections }: PdfReportProps) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = result.data as Record<string, unknown>
|
const html = buildReportHtml(result.data as Record<string, unknown>)
|
||||||
const rName = String(data.roundName || 'Report')
|
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
||||||
const pName = String(data.programName || '')
|
const url = URL.createObjectURL(blob)
|
||||||
|
const newWindow = window.open(url, '_blank')
|
||||||
// 1. Create document
|
if (!newWindow) {
|
||||||
const doc = await createReportDocument()
|
toast.error('Pop-up blocked. Please allow pop-ups and try again.')
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
// 2. Cover page
|
return
|
||||||
await addCoverPage(doc, {
|
|
||||||
title: 'Round Report',
|
|
||||||
subtitle: `${pName} ${data.programYear ? `(${data.programYear})` : ''}`.trim(),
|
|
||||||
roundName: rName,
|
|
||||||
programName: pName,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. Summary
|
|
||||||
const summary = data.summary as Record<string, unknown> | undefined
|
|
||||||
if (summary) {
|
|
||||||
addPageBreak(doc)
|
|
||||||
await addHeader(doc, rName)
|
|
||||||
let y = addSectionTitle(doc, 'Summary', 28)
|
|
||||||
|
|
||||||
y = addStatCards(doc, [
|
|
||||||
{ label: 'Projects', value: String(summary.projectCount ?? 0) },
|
|
||||||
{ label: 'Evaluations', value: String(summary.evaluationCount ?? 0) },
|
|
||||||
{
|
|
||||||
label: 'Avg Score',
|
|
||||||
value: summary.averageScore != null
|
|
||||||
? Number(summary.averageScore).toFixed(1)
|
|
||||||
: '--',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Completion',
|
|
||||||
value: summary.completionRate != null
|
|
||||||
? `${Number(summary.completionRate).toFixed(0)}%`
|
|
||||||
: '--',
|
|
||||||
},
|
|
||||||
], y)
|
|
||||||
}
|
}
|
||||||
|
// Clean up after a delay
|
||||||
// 4. Rankings
|
setTimeout(() => URL.revokeObjectURL(url), 5000)
|
||||||
const rankings = data.rankings as Array<Record<string, unknown>> | undefined
|
toast.success('Report generated. Use the Print button or Ctrl+P to save as PDF.')
|
||||||
if (rankings && rankings.length > 0) {
|
} catch {
|
||||||
addPageBreak(doc)
|
toast.error('Failed to generate report')
|
||||||
await addHeader(doc, rName)
|
|
||||||
let y = addSectionTitle(doc, 'Project Rankings', 28)
|
|
||||||
|
|
||||||
const headers = ['#', 'Project', 'Team', 'Avg Score', 'Evaluations', 'Yes %']
|
|
||||||
const rows = rankings.map((r, i) => [
|
|
||||||
i + 1,
|
|
||||||
String(r.title ?? ''),
|
|
||||||
String(r.teamName ?? ''),
|
|
||||||
r.averageScore != null ? Number(r.averageScore).toFixed(2) : '-',
|
|
||||||
String(r.evaluationCount ?? 0),
|
|
||||||
r.yesPercentage != null ? `${Number(r.yesPercentage).toFixed(0)}%` : '-',
|
|
||||||
])
|
|
||||||
|
|
||||||
y = addTable(doc, headers, rows, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Juror stats
|
|
||||||
const jurorStats = data.jurorStats as Array<Record<string, unknown>> | undefined
|
|
||||||
if (jurorStats && jurorStats.length > 0) {
|
|
||||||
addPageBreak(doc)
|
|
||||||
await addHeader(doc, rName)
|
|
||||||
let y = addSectionTitle(doc, 'Juror Statistics', 28)
|
|
||||||
|
|
||||||
const headers = ['Juror', 'Assigned', 'Completed', 'Completion %', 'Avg Score']
|
|
||||||
const rows = jurorStats.map((j) => [
|
|
||||||
String(j.name ?? ''),
|
|
||||||
String(j.assigned ?? 0),
|
|
||||||
String(j.completed ?? 0),
|
|
||||||
`${Number(j.completionRate ?? 0).toFixed(0)}%`,
|
|
||||||
j.averageScore != null ? Number(j.averageScore).toFixed(2) : '-',
|
|
||||||
])
|
|
||||||
|
|
||||||
y = addTable(doc, headers, rows, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Criteria breakdown
|
|
||||||
const criteriaBreakdown = data.criteriaBreakdown as Array<Record<string, unknown>> | undefined
|
|
||||||
if (criteriaBreakdown && criteriaBreakdown.length > 0) {
|
|
||||||
addPageBreak(doc)
|
|
||||||
await addHeader(doc, rName)
|
|
||||||
let y = addSectionTitle(doc, 'Criteria Breakdown', 28)
|
|
||||||
|
|
||||||
const headers = ['Criterion', 'Avg Score', 'Responses']
|
|
||||||
const rows = criteriaBreakdown.map((c) => [
|
|
||||||
String(c.label ?? ''),
|
|
||||||
c.averageScore != null ? Number(c.averageScore).toFixed(2) : '-',
|
|
||||||
String(c.count ?? 0),
|
|
||||||
])
|
|
||||||
|
|
||||||
y = addTable(doc, headers, rows, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Footers
|
|
||||||
addAllPageFooters(doc)
|
|
||||||
|
|
||||||
// 8. Save
|
|
||||||
const dateStr = new Date().toISOString().split('T')[0]
|
|
||||||
savePdf(doc, `MOPC-Report-${rName.replace(/\s+/g, '-')}-${dateStr}.pdf`)
|
|
||||||
|
|
||||||
toast.success('PDF report downloaded successfully')
|
|
||||||
} catch (err) {
|
|
||||||
console.error('PDF generation error:', err)
|
|
||||||
toast.error('Failed to generate PDF report')
|
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false)
|
setGenerating(false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,6 @@ import {
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
|
|
@ -31,29 +28,15 @@ import {
|
||||||
UserCog,
|
UserCog,
|
||||||
Trash2,
|
Trash2,
|
||||||
Loader2,
|
Loader2,
|
||||||
Shield,
|
|
||||||
Check,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
|
||||||
|
|
||||||
const ROLE_LABELS: Record<Role, string> = {
|
|
||||||
SUPER_ADMIN: 'Super Admin',
|
|
||||||
PROGRAM_ADMIN: 'Program Admin',
|
|
||||||
JURY_MEMBER: 'Jury Member',
|
|
||||||
MENTOR: 'Mentor',
|
|
||||||
OBSERVER: 'Observer',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserActionsProps {
|
interface UserActionsProps {
|
||||||
userId: string
|
userId: string
|
||||||
userEmail: string
|
userEmail: string
|
||||||
userStatus: string
|
userStatus: string
|
||||||
userRole: Role
|
|
||||||
currentUserRole?: Role
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserActions({ userId, userEmail, userStatus, userRole, currentUserRole }: UserActionsProps) {
|
export function UserActions({ userId, userEmail, userStatus }: UserActionsProps) {
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
const [isSending, setIsSending] = useState(false)
|
const [isSending, setIsSending] = useState(false)
|
||||||
|
|
||||||
|
|
@ -61,40 +44,13 @@ export function UserActions({ userId, userEmail, userStatus, userRole, currentUs
|
||||||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
||||||
const deleteUser = trpc.user.delete.useMutation({
|
const deleteUser = trpc.user.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
// Invalidate user list to refresh the members table
|
||||||
utils.user.list.invalidate()
|
utils.user.list.invalidate()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const updateUser = trpc.user.update.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.user.list.invalidate()
|
|
||||||
toast.success('Role updated successfully')
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message || 'Failed to update role')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const isSuperAdmin = currentUserRole === 'SUPER_ADMIN'
|
|
||||||
|
|
||||||
// Determine which roles can be assigned
|
|
||||||
const getAvailableRoles = (): Role[] => {
|
|
||||||
if (isSuperAdmin) {
|
|
||||||
return ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']
|
|
||||||
}
|
|
||||||
// Program admins can only assign lower roles
|
|
||||||
return ['JURY_MEMBER', 'MENTOR', 'OBSERVER']
|
|
||||||
}
|
|
||||||
|
|
||||||
// Can this user's role be changed by the current user?
|
|
||||||
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
|
|
||||||
|
|
||||||
const handleRoleChange = (newRole: Role) => {
|
|
||||||
if (newRole === userRole) return
|
|
||||||
updateUser.mutate({ id: userId, role: newRole })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSendInvitation = async () => {
|
const handleSendInvitation = async () => {
|
||||||
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
|
if (userStatus !== 'INVITED') {
|
||||||
toast.error('User has already accepted their invitation')
|
toast.error('User has already accepted their invitation')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -142,31 +98,9 @@ export function UserActions({ userId, userEmail, userStatus, userRole, currentUs
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{canChangeRole && (
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger disabled={updateUser.isPending}>
|
|
||||||
<Shield className="mr-2 h-4 w-4" />
|
|
||||||
{updateUser.isPending ? 'Updating...' : 'Change Role'}
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent>
|
|
||||||
{getAvailableRoles().map((role) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={role}
|
|
||||||
onClick={() => handleRoleChange(role)}
|
|
||||||
disabled={role === userRole}
|
|
||||||
>
|
|
||||||
{role === userRole && <Check className="mr-2 h-4 w-4" />}
|
|
||||||
<span className={role === userRole ? 'font-medium' : role !== userRole ? 'ml-6' : ''}>
|
|
||||||
{ROLE_LABELS[role]}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={handleSendInvitation}
|
onClick={handleSendInvitation}
|
||||||
disabled={(userStatus !== 'NONE' && userStatus !== 'INVITED') || isSending}
|
disabled={userStatus !== 'INVITED' || isSending}
|
||||||
>
|
>
|
||||||
<Mail className="mr-2 h-4 w-4" />
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
{isSending ? 'Sending...' : 'Send Invite'}
|
{isSending ? 'Sending...' : 'Send Invite'}
|
||||||
|
|
@ -213,35 +147,18 @@ interface UserMobileActionsProps {
|
||||||
userId: string
|
userId: string
|
||||||
userEmail: string
|
userEmail: string
|
||||||
userStatus: string
|
userStatus: string
|
||||||
userRole: Role
|
|
||||||
currentUserRole?: Role
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserMobileActions({
|
export function UserMobileActions({
|
||||||
userId,
|
userId,
|
||||||
userEmail,
|
userEmail,
|
||||||
userStatus,
|
userStatus,
|
||||||
userRole,
|
|
||||||
currentUserRole,
|
|
||||||
}: UserMobileActionsProps) {
|
}: UserMobileActionsProps) {
|
||||||
const [isSending, setIsSending] = useState(false)
|
const [isSending, setIsSending] = useState(false)
|
||||||
const utils = trpc.useUtils()
|
|
||||||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
||||||
const updateUser = trpc.user.update.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.user.list.invalidate()
|
|
||||||
toast.success('Role updated successfully')
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message || 'Failed to update role')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const isSuperAdmin = currentUserRole === 'SUPER_ADMIN'
|
|
||||||
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
|
|
||||||
|
|
||||||
const handleSendInvitation = async () => {
|
const handleSendInvitation = async () => {
|
||||||
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
|
if (userStatus !== 'INVITED') {
|
||||||
toast.error('User has already accepted their invitation')
|
toast.error('User has already accepted their invitation')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -258,8 +175,7 @@ export function UserMobileActions({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" size="sm" className="flex-1" asChild>
|
<Button variant="outline" size="sm" className="flex-1" asChild>
|
||||||
<Link href={`/admin/members/${userId}`}>
|
<Link href={`/admin/members/${userId}`}>
|
||||||
<UserCog className="mr-2 h-4 w-4" />
|
<UserCog className="mr-2 h-4 w-4" />
|
||||||
|
|
@ -271,7 +187,7 @@ export function UserMobileActions({
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={handleSendInvitation}
|
onClick={handleSendInvitation}
|
||||||
disabled={(userStatus !== 'NONE' && userStatus !== 'INVITED') || isSending}
|
disabled={userStatus !== 'INVITED' || isSending}
|
||||||
>
|
>
|
||||||
{isSending ? (
|
{isSending ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
|
@ -281,23 +197,5 @@ export function UserMobileActions({
|
||||||
Invite
|
Invite
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{canChangeRole && (
|
|
||||||
<select
|
|
||||||
value={userRole}
|
|
||||||
onChange={(e) => updateUser.mutate({ id: userId, role: e.target.value as Role })}
|
|
||||||
disabled={updateUser.isPending}
|
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
|
||||||
>
|
|
||||||
{(isSuperAdmin
|
|
||||||
? (['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
|
|
||||||
: (['JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
|
|
||||||
).map((role) => (
|
|
||||||
<option key={role} value={role}>
|
|
||||||
{ROLE_LABELS[role]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -213,17 +213,17 @@ function FilteringSettings({
|
||||||
<Input
|
<Input
|
||||||
id="minReviews"
|
id="minReviews"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="1"
|
||||||
value={settings.autoEliminationMinReviews}
|
value={settings.autoEliminationMinReviews}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange({
|
onChange({
|
||||||
...settings,
|
...settings,
|
||||||
autoEliminationMinReviews: parseInt(e.target.value) || 0,
|
autoEliminationMinReviews: parseInt(e.target.value) || 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Min reviews before auto-elimination applies (0 for AI-only filtering)
|
Min reviews before auto-elimination applies
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,476 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Progress } from '@/components/ui/progress'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { StatusBadge } from '@/components/shared/status-badge'
|
|
||||||
import {
|
|
||||||
FolderKanban,
|
|
||||||
ClipboardList,
|
|
||||||
Users,
|
|
||||||
CheckCircle2,
|
|
||||||
Eye,
|
|
||||||
BarChart3,
|
|
||||||
Search,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useDebouncedCallback } from 'use-debounce'
|
|
||||||
|
|
||||||
const PER_PAGE_OPTIONS = [10, 20, 50]
|
|
||||||
|
|
||||||
export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|
||||||
const [selectedRoundId, setSelectedRoundId] = useState<string>('all')
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [perPage, setPerPage] = useState(20)
|
|
||||||
|
|
||||||
const debouncedSetSearch = useDebouncedCallback((value: string) => {
|
|
||||||
setDebouncedSearch(value)
|
|
||||||
setPage(1)
|
|
||||||
}, 300)
|
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
|
||||||
setSearch(value)
|
|
||||||
debouncedSetSearch(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRoundChange = (value: string) => {
|
|
||||||
setSelectedRoundId(value)
|
|
||||||
setPage(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleStatusChange = (value: string) => {
|
|
||||||
setStatusFilter(value)
|
|
||||||
setPage(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch programs/rounds for the filter dropdown
|
|
||||||
const { data: programs } = trpc.program.list.useQuery({ includeRounds: true })
|
|
||||||
|
|
||||||
const rounds = programs?.flatMap((p) =>
|
|
||||||
p.rounds.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
name: r.name,
|
|
||||||
programName: `${p.year} Edition`,
|
|
||||||
status: r.status,
|
|
||||||
}))
|
|
||||||
) || []
|
|
||||||
|
|
||||||
// Fetch dashboard stats
|
|
||||||
const roundIdParam = selectedRoundId !== 'all' ? selectedRoundId : undefined
|
|
||||||
const { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery(
|
|
||||||
{ roundId: roundIdParam }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Fetch projects
|
|
||||||
const { data: projectsData, isLoading: projectsLoading } = trpc.analytics.getAllProjects.useQuery({
|
|
||||||
roundId: roundIdParam,
|
|
||||||
search: debouncedSearch || undefined,
|
|
||||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fetch recent rounds for jury completion
|
|
||||||
const { data: recentRoundsData } = trpc.program.list.useQuery({ includeRounds: true })
|
|
||||||
const recentRounds = recentRoundsData?.flatMap((p) =>
|
|
||||||
p.rounds.map((r) => ({
|
|
||||||
...r,
|
|
||||||
programName: `${p.year} Edition`,
|
|
||||||
}))
|
|
||||||
)?.slice(0, 5) || []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Welcome, {userName || 'Observer'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Observer Notice */}
|
|
||||||
<div className="rounded-lg border-2 border-blue-300 bg-blue-50 px-4 py-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100">
|
|
||||||
<Eye className="h-4 w-4 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="font-semibold text-blue-900">Observer Mode</p>
|
|
||||||
<Badge variant="outline" className="border-blue-300 text-blue-700 text-xs">
|
|
||||||
Read-Only
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-blue-700">
|
|
||||||
You have read-only access to view platform statistics and reports.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Round Filter */}
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
|
||||||
<label className="text-sm font-medium">Filter by Round:</label>
|
|
||||||
<Select value={selectedRoundId} onValueChange={handleRoundChange}>
|
|
||||||
<SelectTrigger className="w-full sm:w-[300px]">
|
|
||||||
<SelectValue placeholder="All Rounds" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Rounds</SelectItem>
|
|
||||||
{rounds.map((round) => (
|
|
||||||
<SelectItem key={round.id} value={round.id}>
|
|
||||||
{round.programName} - {round.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Grid */}
|
|
||||||
{statsLoading ? (
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{[...Array(4)].map((_, i) => (
|
|
||||||
<Card key={i}>
|
|
||||||
<CardHeader className="space-y-0 pb-2">
|
|
||||||
<Skeleton className="h-4 w-20" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Skeleton className="h-8 w-16" />
|
|
||||||
<Skeleton className="mt-2 h-3 w-24" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : stats ? (
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<Card className="transition-all hover:shadow-md">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Programs</CardTitle>
|
|
||||||
<FolderKanban className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stats.programCount}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{stats.activeRoundCount} active round{stats.activeRoundCount !== 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="transition-all hover:shadow-md">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
|
||||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stats.projectCount}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{selectedRoundId !== 'all' ? 'In selected round' : 'Across all rounds'}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="transition-all hover:shadow-md">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
|
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stats.jurorCount}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">Active members</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="transition-all hover:shadow-md">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stats.submittedEvaluations}</div>
|
|
||||||
<div className="mt-2">
|
|
||||||
<Progress value={stats.completionRate} className="h-2" />
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{stats.completionRate}% completion rate
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Projects Table */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>All Projects</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{projectsData ? `${projectsData.total} project${projectsData.total !== 1 ? 's' : ''} found` : 'Loading projects...'}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* Search & Filter Bar */}
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search by title or team..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => handleSearchChange(e.target.value)}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Select value={statusFilter} onValueChange={handleStatusChange}>
|
|
||||||
<SelectTrigger className="w-full sm:w-[180px]">
|
|
||||||
<SelectValue placeholder="All Statuses" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Statuses</SelectItem>
|
|
||||||
<SelectItem value="SUBMITTED">Submitted</SelectItem>
|
|
||||||
<SelectItem value="ELIGIBLE">Eligible</SelectItem>
|
|
||||||
<SelectItem value="ASSIGNED">Assigned</SelectItem>
|
|
||||||
<SelectItem value="UNDER_REVIEW">Under Review</SelectItem>
|
|
||||||
<SelectItem value="SHORTLISTED">Shortlisted</SelectItem>
|
|
||||||
<SelectItem value="SEMIFINALIST">Semi-finalist</SelectItem>
|
|
||||||
<SelectItem value="FINALIST">Finalist</SelectItem>
|
|
||||||
<SelectItem value="WINNER">Winner</SelectItem>
|
|
||||||
<SelectItem value="REJECTED">Rejected</SelectItem>
|
|
||||||
<SelectItem value="WITHDRAWN">Withdrawn</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select value={String(perPage)} onValueChange={(v) => { setPerPage(Number(v)); setPage(1) }}>
|
|
||||||
<SelectTrigger className="w-full sm:w-[100px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{PER_PAGE_OPTIONS.map((n) => (
|
|
||||||
<SelectItem key={n} value={String(n)}>{n} / page</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{projectsLoading ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-12 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : projectsData && projectsData.projects.length > 0 ? (
|
|
||||||
<>
|
|
||||||
{/* Desktop Table */}
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Title</TableHead>
|
|
||||||
<TableHead>Team</TableHead>
|
|
||||||
<TableHead>Round</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead className="text-right">Avg Score</TableHead>
|
|
||||||
<TableHead className="text-right">Evaluations</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{projectsData.projects.map((project) => (
|
|
||||||
<TableRow key={project.id}>
|
|
||||||
<TableCell className="font-medium max-w-[250px] truncate">
|
|
||||||
{project.title}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="max-w-[150px] truncate">{project.teamName || '-'}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline" className="text-xs whitespace-nowrap">
|
|
||||||
{project.roundName}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<StatusBadge status={project.status} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right tabular-nums">
|
|
||||||
{project.averageScore !== null
|
|
||||||
? project.averageScore.toFixed(2)
|
|
||||||
: '-'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right tabular-nums">
|
|
||||||
{project.evaluationCount}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Cards */}
|
|
||||||
<div className="space-y-3 md:hidden">
|
|
||||||
{projectsData.projects.map((project) => (
|
|
||||||
<Card key={project.id}>
|
|
||||||
<CardContent className="pt-4 space-y-2">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<p className="font-medium text-sm leading-tight">{project.title}</p>
|
|
||||||
<StatusBadge status={project.status} />
|
|
||||||
</div>
|
|
||||||
{project.teamName && (
|
|
||||||
<p className="text-xs text-muted-foreground">{project.teamName}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{project.roundName}
|
|
||||||
</Badge>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<span>Score: {project.averageScore !== null ? project.averageScore.toFixed(2) : '-'}</span>
|
|
||||||
<span>{project.evaluationCount} eval{project.evaluationCount !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{projectsData.totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between pt-2">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Page {projectsData.page} of {projectsData.totalPages}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={page <= 1}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((p) => Math.min(projectsData.totalPages, p + 1))}
|
|
||||||
disabled={page >= projectsData.totalPages}
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
|
||||||
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
{debouncedSearch || statusFilter !== 'all'
|
|
||||||
? 'No projects match your filters'
|
|
||||||
: 'No projects found'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Score Distribution */}
|
|
||||||
{stats && stats.scoreDistribution.some((b) => b.count > 0) && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Score Distribution</CardTitle>
|
|
||||||
<CardDescription>Distribution of global scores across evaluations</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(() => {
|
|
||||||
const maxCount = Math.max(...stats.scoreDistribution.map((b) => b.count), 1)
|
|
||||||
const colors = ['bg-green-500', 'bg-emerald-400', 'bg-amber-400', 'bg-orange-400', 'bg-red-400']
|
|
||||||
return stats.scoreDistribution.map((bucket, i) => (
|
|
||||||
<div key={bucket.label} className="flex items-center gap-3">
|
|
||||||
<span className="text-sm w-16 text-right tabular-nums">{bucket.label}</span>
|
|
||||||
<div className="flex-1 h-6 bg-muted rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={cn('h-full rounded-full transition-all', colors[i])}
|
|
||||||
style={{ width: `${maxCount > 0 ? (bucket.count / maxCount) * 100 : 0}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm tabular-nums w-8">{bucket.count}</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recent Rounds */}
|
|
||||||
{recentRounds.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Recent Rounds</CardTitle>
|
|
||||||
<CardDescription>Overview of the latest voting rounds</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{recentRounds.map((round) => (
|
|
||||||
<div
|
|
||||||
key={round.id}
|
|
||||||
className="flex items-center justify-between rounded-lg border p-4 transition-all hover:shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="font-medium">{round.name}</p>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
round.status === 'ACTIVE'
|
|
||||||
? 'default'
|
|
||||||
: round.status === 'CLOSED'
|
|
||||||
? 'secondary'
|
|
||||||
: 'outline'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{round.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{round.programName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right text-sm">
|
|
||||||
<p>{round._count?.projects || 0} projects</p>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{round._count?.assignments || 0} assignments
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -61,10 +61,9 @@ function SettingsSkeleton() {
|
||||||
|
|
||||||
interface SettingsContentProps {
|
interface SettingsContentProps {
|
||||||
initialSettings: Record<string, string>
|
initialSettings: Record<string, string>
|
||||||
isSuperAdmin?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsContent({ initialSettings, isSuperAdmin = true }: SettingsContentProps) {
|
export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||||
// We use the initial settings passed from the server
|
// We use the initial settings passed from the server
|
||||||
// Forms will refetch on mutation success
|
// Forms will refetch on mutation success
|
||||||
|
|
||||||
|
|
@ -169,12 +168,10 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
<Globe className="h-4 w-4" />
|
<Globe className="h-4 w-4" />
|
||||||
Locale
|
Locale
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{isSuperAdmin && (
|
|
||||||
<TabsTrigger value="email" className="gap-2 shrink-0">
|
<TabsTrigger value="email" className="gap-2 shrink-0">
|
||||||
<Mail className="h-4 w-4" />
|
<Mail className="h-4 w-4" />
|
||||||
Email
|
Email
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
|
||||||
<TabsTrigger value="notifications" className="gap-2 shrink-0">
|
<TabsTrigger value="notifications" className="gap-2 shrink-0">
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
Notif.
|
Notif.
|
||||||
|
|
@ -183,22 +180,18 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
<Newspaper className="h-4 w-4" />
|
<Newspaper className="h-4 w-4" />
|
||||||
Digest
|
Digest
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{isSuperAdmin && (
|
|
||||||
<TabsTrigger value="security" className="gap-2 shrink-0">
|
<TabsTrigger value="security" className="gap-2 shrink-0">
|
||||||
<Shield className="h-4 w-4" />
|
<Shield className="h-4 w-4" />
|
||||||
Security
|
Security
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
|
||||||
<TabsTrigger value="audit" className="gap-2 shrink-0">
|
<TabsTrigger value="audit" className="gap-2 shrink-0">
|
||||||
<ShieldAlert className="h-4 w-4" />
|
<ShieldAlert className="h-4 w-4" />
|
||||||
Audit
|
Audit
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{isSuperAdmin && (
|
|
||||||
<TabsTrigger value="ai" className="gap-2 shrink-0">
|
<TabsTrigger value="ai" className="gap-2 shrink-0">
|
||||||
<Bot className="h-4 w-4" />
|
<Bot className="h-4 w-4" />
|
||||||
AI
|
AI
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
|
||||||
<TabsTrigger value="tags" className="gap-2 shrink-0">
|
<TabsTrigger value="tags" className="gap-2 shrink-0">
|
||||||
<Tags className="h-4 w-4" />
|
<Tags className="h-4 w-4" />
|
||||||
Tags
|
Tags
|
||||||
|
|
@ -207,12 +200,10 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
<BarChart3 className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
Analytics
|
Analytics
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{isSuperAdmin && (
|
|
||||||
<TabsTrigger value="storage" className="gap-2 shrink-0">
|
<TabsTrigger value="storage" className="gap-2 shrink-0">
|
||||||
<HardDrive className="h-4 w-4" />
|
<HardDrive className="h-4 w-4" />
|
||||||
Storage
|
Storage
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="lg:flex lg:gap-8">
|
<div className="lg:flex lg:gap-8">
|
||||||
|
|
@ -239,12 +230,10 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Communication</p>
|
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Communication</p>
|
||||||
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||||
{isSuperAdmin && (
|
|
||||||
<TabsTrigger value="email" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
<TabsTrigger value="email" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
<Mail className="h-4 w-4" />
|
<Mail className="h-4 w-4" />
|
||||||
Email
|
Email
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
|
||||||
<TabsTrigger value="notifications" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
<TabsTrigger value="notifications" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
Notifications
|
Notifications
|
||||||
|
|
@ -258,12 +247,10 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Security</p>
|
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Security</p>
|
||||||
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||||
{isSuperAdmin && (
|
|
||||||
<TabsTrigger value="security" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
<TabsTrigger value="security" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
<Shield className="h-4 w-4" />
|
<Shield className="h-4 w-4" />
|
||||||
Security
|
Security
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
|
||||||
<TabsTrigger value="audit" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
<TabsTrigger value="audit" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
<ShieldAlert className="h-4 w-4" />
|
<ShieldAlert className="h-4 w-4" />
|
||||||
Audit
|
Audit
|
||||||
|
|
@ -273,12 +260,10 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Features</p>
|
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Features</p>
|
||||||
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||||
{isSuperAdmin && (
|
|
||||||
<TabsTrigger value="ai" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
<TabsTrigger value="ai" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
<Bot className="h-4 w-4" />
|
<Bot className="h-4 w-4" />
|
||||||
AI
|
AI
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
|
||||||
<TabsTrigger value="tags" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
<TabsTrigger value="tags" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
<Tags className="h-4 w-4" />
|
<Tags className="h-4 w-4" />
|
||||||
Tags
|
Tags
|
||||||
|
|
@ -289,7 +274,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
{isSuperAdmin && (
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Infrastructure</p>
|
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Infrastructure</p>
|
||||||
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||||
|
|
@ -299,14 +283,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content area */}
|
{/* Content area */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|
||||||
{isSuperAdmin && (
|
|
||||||
<TabsContent value="ai" className="space-y-6">
|
<TabsContent value="ai" className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -321,7 +303,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
</Card>
|
</Card>
|
||||||
<AIUsageCard />
|
<AIUsageCard />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
|
||||||
|
|
||||||
<TabsContent value="tags">
|
<TabsContent value="tags">
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -369,7 +350,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{isSuperAdmin && (
|
|
||||||
<TabsContent value="email">
|
<TabsContent value="email">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -383,7 +363,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
|
||||||
|
|
||||||
<TabsContent value="notifications">
|
<TabsContent value="notifications">
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -399,7 +378,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{isSuperAdmin && (
|
|
||||||
<TabsContent value="storage">
|
<TabsContent value="storage">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -413,9 +391,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
|
||||||
|
|
||||||
{isSuperAdmin && (
|
|
||||||
<TabsContent value="security">
|
<TabsContent value="security">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -429,7 +405,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
|
||||||
|
|
||||||
<TabsContent value="defaults">
|
<TabsContent value="defaults">
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -527,7 +502,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{isSuperAdmin && (
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
|
@ -548,7 +522,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -132,16 +132,13 @@ export function CsvExportDialog({
|
||||||
),
|
),
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|
||||||
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' })
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = `${filename}-${new Date().toISOString().split('T')[0]}.csv`
|
link.download = `${filename}-${new Date().toISOString().split('T')[0]}.csv`
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
link.click()
|
||||||
document.body.removeChild(link)
|
URL.revokeObjectURL(url)
|
||||||
// Delay revoking to ensure download starts before URL is invalidated
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useCallback, type RefObject } from 'react'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { FileDown, Loader2 } from 'lucide-react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import {
|
|
||||||
createReportDocument,
|
|
||||||
addCoverPage,
|
|
||||||
addPageBreak,
|
|
||||||
addHeader,
|
|
||||||
addSectionTitle,
|
|
||||||
addStatCards,
|
|
||||||
addTable,
|
|
||||||
addChartImage,
|
|
||||||
addAllPageFooters,
|
|
||||||
savePdf,
|
|
||||||
} from '@/lib/pdf-generator'
|
|
||||||
|
|
||||||
interface ExportPdfButtonProps {
|
|
||||||
roundId: string
|
|
||||||
roundName?: string
|
|
||||||
programName?: string
|
|
||||||
chartRefs?: Record<string, RefObject<HTMLDivElement | null>>
|
|
||||||
variant?: 'default' | 'outline' | 'secondary' | 'ghost'
|
|
||||||
size?: 'default' | 'sm' | 'lg' | 'icon'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExportPdfButton({
|
|
||||||
roundId,
|
|
||||||
roundName,
|
|
||||||
programName,
|
|
||||||
chartRefs,
|
|
||||||
variant = 'outline',
|
|
||||||
size = 'sm',
|
|
||||||
}: ExportPdfButtonProps) {
|
|
||||||
const [generating, setGenerating] = useState(false)
|
|
||||||
|
|
||||||
const { refetch } = trpc.export.getReportData.useQuery(
|
|
||||||
{ roundId, sections: [] },
|
|
||||||
{ enabled: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleGenerate = useCallback(async () => {
|
|
||||||
setGenerating(true)
|
|
||||||
toast.info('Generating PDF report...')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await refetch()
|
|
||||||
if (!result.data) {
|
|
||||||
toast.error('Failed to fetch report data')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = result.data as Record<string, unknown>
|
|
||||||
const rName = roundName || String(data.roundName || 'Report')
|
|
||||||
const pName = programName || String(data.programName || '')
|
|
||||||
|
|
||||||
// 1. Create document
|
|
||||||
const doc = await createReportDocument()
|
|
||||||
|
|
||||||
// 2. Cover page
|
|
||||||
await addCoverPage(doc, {
|
|
||||||
title: 'Round Report',
|
|
||||||
subtitle: `${pName} ${data.programYear ? `(${data.programYear})` : ''}`.trim(),
|
|
||||||
roundName: rName,
|
|
||||||
programName: pName,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. Summary section
|
|
||||||
const summary = data.summary as Record<string, unknown> | undefined
|
|
||||||
if (summary) {
|
|
||||||
addPageBreak(doc)
|
|
||||||
await addHeader(doc, rName)
|
|
||||||
let y = addSectionTitle(doc, 'Summary', 28)
|
|
||||||
|
|
||||||
y = addStatCards(doc, [
|
|
||||||
{ label: 'Projects', value: String(summary.projectCount ?? 0) },
|
|
||||||
{ label: 'Evaluations', value: String(summary.evaluationCount ?? 0) },
|
|
||||||
{
|
|
||||||
label: 'Avg Score',
|
|
||||||
value: summary.averageScore != null
|
|
||||||
? Number(summary.averageScore).toFixed(1)
|
|
||||||
: '--',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Completion',
|
|
||||||
value: summary.completionRate != null
|
|
||||||
? `${Number(summary.completionRate).toFixed(0)}%`
|
|
||||||
: '--',
|
|
||||||
},
|
|
||||||
], y)
|
|
||||||
|
|
||||||
// Capture chart images if refs provided
|
|
||||||
if (chartRefs) {
|
|
||||||
for (const [, ref] of Object.entries(chartRefs)) {
|
|
||||||
if (ref.current) {
|
|
||||||
try {
|
|
||||||
y = await addChartImage(doc, ref.current, y, { maxHeight: 90 })
|
|
||||||
} catch {
|
|
||||||
// Skip chart if capture fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Rankings section
|
|
||||||
const rankings = data.rankings as Array<Record<string, unknown>> | undefined
|
|
||||||
if (rankings && rankings.length > 0) {
|
|
||||||
addPageBreak(doc)
|
|
||||||
await addHeader(doc, rName)
|
|
||||||
let y = addSectionTitle(doc, 'Project Rankings', 28)
|
|
||||||
|
|
||||||
const headers = ['#', 'Project', 'Team', 'Avg Score', 'Evaluations', 'Yes %']
|
|
||||||
const rows = rankings.map((r, i) => [
|
|
||||||
i + 1,
|
|
||||||
String(r.title ?? ''),
|
|
||||||
String(r.teamName ?? ''),
|
|
||||||
r.averageScore != null ? Number(r.averageScore).toFixed(2) : '-',
|
|
||||||
String(r.evaluationCount ?? 0),
|
|
||||||
r.yesPercentage != null ? `${Number(r.yesPercentage).toFixed(0)}%` : '-',
|
|
||||||
])
|
|
||||||
|
|
||||||
y = addTable(doc, headers, rows, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Juror stats section
|
|
||||||
const jurorStats = data.jurorStats as Array<Record<string, unknown>> | undefined
|
|
||||||
if (jurorStats && jurorStats.length > 0) {
|
|
||||||
addPageBreak(doc)
|
|
||||||
await addHeader(doc, rName)
|
|
||||||
let y = addSectionTitle(doc, 'Juror Statistics', 28)
|
|
||||||
|
|
||||||
const headers = ['Juror', 'Assigned', 'Completed', 'Completion %', 'Avg Score']
|
|
||||||
const rows = jurorStats.map((j) => [
|
|
||||||
String(j.name ?? ''),
|
|
||||||
String(j.assigned ?? 0),
|
|
||||||
String(j.completed ?? 0),
|
|
||||||
`${Number(j.completionRate ?? 0).toFixed(0)}%`,
|
|
||||||
j.averageScore != null ? Number(j.averageScore).toFixed(2) : '-',
|
|
||||||
])
|
|
||||||
|
|
||||||
y = addTable(doc, headers, rows, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Criteria breakdown
|
|
||||||
const criteriaBreakdown = data.criteriaBreakdown as Array<Record<string, unknown>> | undefined
|
|
||||||
if (criteriaBreakdown && criteriaBreakdown.length > 0) {
|
|
||||||
addPageBreak(doc)
|
|
||||||
await addHeader(doc, rName)
|
|
||||||
let y = addSectionTitle(doc, 'Criteria Breakdown', 28)
|
|
||||||
|
|
||||||
const headers = ['Criterion', 'Avg Score', 'Responses']
|
|
||||||
const rows = criteriaBreakdown.map((c) => [
|
|
||||||
String(c.label ?? ''),
|
|
||||||
c.averageScore != null ? Number(c.averageScore).toFixed(2) : '-',
|
|
||||||
String(c.count ?? 0),
|
|
||||||
])
|
|
||||||
|
|
||||||
y = addTable(doc, headers, rows, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Footer on all pages
|
|
||||||
addAllPageFooters(doc)
|
|
||||||
|
|
||||||
// 8. Save
|
|
||||||
const dateStr = new Date().toISOString().split('T')[0]
|
|
||||||
savePdf(doc, `MOPC-Report-${rName.replace(/\s+/g, '-')}-${dateStr}.pdf`)
|
|
||||||
|
|
||||||
toast.success('PDF report downloaded successfully')
|
|
||||||
} catch (err) {
|
|
||||||
console.error('PDF generation error:', err)
|
|
||||||
toast.error('Failed to generate PDF report')
|
|
||||||
} finally {
|
|
||||||
setGenerating(false)
|
|
||||||
}
|
|
||||||
}, [refetch, roundName, programName, chartRefs])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button variant={variant} size={size} onClick={handleGenerate} disabled={generating}>
|
|
||||||
{generating ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<FileDown className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{generating ? 'Generating...' : 'Export PDF Report'}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
|
|
@ -140,11 +140,9 @@ type Notification = {
|
||||||
function NotificationItem({
|
function NotificationItem({
|
||||||
notification,
|
notification,
|
||||||
onRead,
|
onRead,
|
||||||
observeRef,
|
|
||||||
}: {
|
}: {
|
||||||
notification: Notification
|
notification: Notification
|
||||||
onRead: () => void
|
onRead: () => void
|
||||||
observeRef?: (el: HTMLDivElement | null) => void
|
|
||||||
}) {
|
}) {
|
||||||
const IconComponent = ICON_MAP[notification.icon || 'Bell'] || Bell
|
const IconComponent = ICON_MAP[notification.icon || 'Bell'] || Bell
|
||||||
const priorityStyle =
|
const priorityStyle =
|
||||||
|
|
@ -153,8 +151,6 @@ function NotificationItem({
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<div
|
<div
|
||||||
ref={observeRef}
|
|
||||||
data-notification-id={notification.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer',
|
'flex gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer',
|
||||||
!notification.isRead && 'bg-blue-50/50 dark:bg-blue-950/20'
|
!notification.isRead && 'bg-blue-50/50 dark:bg-blue-950/20'
|
||||||
|
|
@ -254,86 +250,17 @@ export function NotificationBell() {
|
||||||
onSuccess: () => refetch(),
|
onSuccess: () => refetch(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const markBatchAsReadMutation = trpc.notification.markBatchAsRead.useMutation({
|
// Auto-mark all notifications as read when popover opens
|
||||||
onSuccess: () => refetch(),
|
useEffect(() => {
|
||||||
})
|
if (open && (countData ?? 0) > 0) {
|
||||||
|
markAllAsReadMutation.mutate()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open])
|
||||||
|
|
||||||
const unreadCount = countData ?? 0
|
const unreadCount = countData ?? 0
|
||||||
const notifications = notificationData?.notifications ?? []
|
const notifications = notificationData?.notifications ?? []
|
||||||
|
|
||||||
// Track unread notification IDs that have become visible
|
|
||||||
const pendingReadIds = useRef<Set<string>>(new Set())
|
|
||||||
const flushTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
|
||||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
|
||||||
|
|
||||||
// Flush pending read IDs in a batch
|
|
||||||
const flushPendingReads = useCallback(() => {
|
|
||||||
if (pendingReadIds.current.size === 0) return
|
|
||||||
const ids = Array.from(pendingReadIds.current)
|
|
||||||
pendingReadIds.current.clear()
|
|
||||||
markBatchAsReadMutation.mutate({ ids })
|
|
||||||
}, [markBatchAsReadMutation])
|
|
||||||
|
|
||||||
// Set up IntersectionObserver when popover opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
// Flush any remaining on close
|
|
||||||
if (flushTimer.current) clearTimeout(flushTimer.current)
|
|
||||||
flushPendingReads()
|
|
||||||
observerRef.current?.disconnect()
|
|
||||||
observerRef.current = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
observerRef.current = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
const id = (entry.target as HTMLElement).dataset.notificationId
|
|
||||||
if (id) {
|
|
||||||
// Check if this notification is unread
|
|
||||||
const notif = notifications.find((n) => n.id === id)
|
|
||||||
if (notif && !notif.isRead) {
|
|
||||||
pendingReadIds.current.add(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Debounce the batch call
|
|
||||||
if (pendingReadIds.current.size > 0) {
|
|
||||||
if (flushTimer.current) clearTimeout(flushTimer.current)
|
|
||||||
flushTimer.current = setTimeout(flushPendingReads, 500)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.5 }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Observe all currently tracked items
|
|
||||||
itemRefs.current.forEach((el) => observerRef.current?.observe(el))
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observerRef.current?.disconnect()
|
|
||||||
if (flushTimer.current) clearTimeout(flushTimer.current)
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open, notifications])
|
|
||||||
|
|
||||||
// Ref callback for each notification item
|
|
||||||
const getItemRef = useCallback(
|
|
||||||
(id: string) => (el: HTMLDivElement | null) => {
|
|
||||||
if (el) {
|
|
||||||
itemRefs.current.set(id, el)
|
|
||||||
observerRef.current?.observe(el)
|
|
||||||
} else {
|
|
||||||
const prev = itemRefs.current.get(id)
|
|
||||||
if (prev) observerRef.current?.unobserve(prev)
|
|
||||||
itemRefs.current.delete(id)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|
@ -412,7 +339,6 @@ export function NotificationBell() {
|
||||||
<NotificationItem
|
<NotificationItem
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
notification={notification}
|
notification={notification}
|
||||||
observeRef={!notification.isRead ? getItemRef(notification.id) : undefined}
|
|
||||||
onRead={() => {
|
onRead={() => {
|
||||||
if (!notification.isRead) {
|
if (!notification.isRead) {
|
||||||
markAsReadMutation.mutate({ id: notification.id })
|
markAsReadMutation.mutate({ id: notification.id })
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ const STATUS_STYLES: Record<string, { variant: BadgeProps['variant']; className?
|
||||||
COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
|
COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
|
||||||
|
|
||||||
// User statuses
|
// User statuses
|
||||||
NONE: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-500 border-slate-200 dark:text-slate-400' },
|
|
||||||
INVITED: { variant: 'secondary', className: 'bg-sky-500/10 text-sky-700 border-sky-200 dark:text-sky-400' },
|
INVITED: { variant: 'secondary', className: 'bg-sky-500/10 text-sky-700 border-sky-200 dark:text-sky-400' },
|
||||||
INACTIVE: { variant: 'secondary' },
|
INACTIVE: { variant: 'secondary' },
|
||||||
SUSPENDED: { variant: 'destructive' },
|
SUSPENDED: { variant: 'destructive' },
|
||||||
|
|
@ -39,7 +38,7 @@ type StatusBadgeProps = {
|
||||||
|
|
||||||
export function StatusBadge({ status, className, size = 'default' }: StatusBadgeProps) {
|
export function StatusBadge({ status, className, size = 'default' }: StatusBadgeProps) {
|
||||||
const style = STATUS_STYLES[status] || { variant: 'secondary' as const }
|
const style = STATUS_STYLES[status] || { variant: 'secondary' as const }
|
||||||
const label = status === 'NONE' ? 'NOT INVITED' : status.replace(/_/g, ' ')
|
const label = status.replace(/_/g, ' ')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
|
|
|
||||||
|
|
@ -238,8 +238,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
return false // Block suspended users
|
return false // Block suspended users
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update status to ACTIVE on first login (from NONE or INVITED)
|
// Update status from INVITED to ACTIVE on first login
|
||||||
if (dbUser?.status === 'INVITED' || dbUser?.status === 'NONE') {
|
if (dbUser?.status === 'INVITED') {
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { email: user.email! },
|
where: { email: user.email! },
|
||||||
data: { status: 'ACTIVE' },
|
data: { status: 'ACTIVE' },
|
||||||
|
|
|
||||||
|
|
@ -1,422 +0,0 @@
|
||||||
import { jsPDF } from 'jspdf'
|
|
||||||
import { autoTable } from 'jspdf-autotable'
|
|
||||||
import html2canvas from 'html2canvas'
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Brand constants
|
|
||||||
// =========================================================================
|
|
||||||
const COLORS = {
|
|
||||||
darkBlue: '#053d57',
|
|
||||||
red: '#de0f1e',
|
|
||||||
teal: '#557f8c',
|
|
||||||
lightGray: '#f0f4f8',
|
|
||||||
white: '#ffffff',
|
|
||||||
textDark: '#1a1a1a',
|
|
||||||
textMuted: '#888888',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const DARK_BLUE_RGB: [number, number, number] = [5, 61, 87]
|
|
||||||
const TEAL_RGB: [number, number, number] = [85, 127, 140]
|
|
||||||
const RED_RGB: [number, number, number] = [222, 15, 30]
|
|
||||||
const LIGHT_GRAY_RGB: [number, number, number] = [240, 244, 248]
|
|
||||||
|
|
||||||
const PAGE_WIDTH = 210 // A4 mm
|
|
||||||
const PAGE_HEIGHT = 297
|
|
||||||
const MARGIN = 15
|
|
||||||
const CONTENT_WIDTH = PAGE_WIDTH - MARGIN * 2
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Font & logo caching
|
|
||||||
// =========================================================================
|
|
||||||
let cachedFonts: { regular: string; bold: string } | null = null
|
|
||||||
let cachedLogo: string | null = null
|
|
||||||
let fontLoadAttempted = false
|
|
||||||
let logoLoadAttempted = false
|
|
||||||
|
|
||||||
async function loadFonts(): Promise<{ regular: string; bold: string } | null> {
|
|
||||||
if (cachedFonts) return cachedFonts
|
|
||||||
if (fontLoadAttempted) return null
|
|
||||||
fontLoadAttempted = true
|
|
||||||
try {
|
|
||||||
const [regularRes, boldRes] = await Promise.all([
|
|
||||||
fetch('/fonts/Montserrat-Regular.ttf'),
|
|
||||||
fetch('/fonts/Montserrat-Bold.ttf'),
|
|
||||||
])
|
|
||||||
if (!regularRes.ok || !boldRes.ok) return null
|
|
||||||
const [regularBuf, boldBuf] = await Promise.all([
|
|
||||||
regularRes.arrayBuffer(),
|
|
||||||
boldRes.arrayBuffer(),
|
|
||||||
])
|
|
||||||
const toBase64 = (buf: ArrayBuffer) => {
|
|
||||||
const bytes = new Uint8Array(buf)
|
|
||||||
let binary = ''
|
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
|
||||||
binary += String.fromCharCode(bytes[i])
|
|
||||||
}
|
|
||||||
return btoa(binary)
|
|
||||||
}
|
|
||||||
cachedFonts = { regular: toBase64(regularBuf), bold: toBase64(boldBuf) }
|
|
||||||
return cachedFonts
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadLogo(): Promise<string | null> {
|
|
||||||
if (cachedLogo) return cachedLogo
|
|
||||||
if (logoLoadAttempted) return null
|
|
||||||
logoLoadAttempted = true
|
|
||||||
try {
|
|
||||||
const res = await fetch('/images/MOPC-blue-long.png')
|
|
||||||
if (!res.ok) return null
|
|
||||||
const blob = await res.blob()
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onloadend = () => {
|
|
||||||
cachedLogo = reader.result as string
|
|
||||||
resolve(cachedLogo)
|
|
||||||
}
|
|
||||||
reader.onerror = () => resolve(null)
|
|
||||||
reader.readAsDataURL(blob)
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Document creation
|
|
||||||
// =========================================================================
|
|
||||||
export interface ReportDocumentOptions {
|
|
||||||
orientation?: 'portrait' | 'landscape'
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createReportDocument(
|
|
||||||
options?: ReportDocumentOptions
|
|
||||||
): Promise<jsPDF> {
|
|
||||||
const doc = new jsPDF({
|
|
||||||
orientation: options?.orientation || 'portrait',
|
|
||||||
unit: 'mm',
|
|
||||||
format: 'a4',
|
|
||||||
})
|
|
||||||
|
|
||||||
// Load and register fonts
|
|
||||||
const fonts = await loadFonts()
|
|
||||||
if (fonts) {
|
|
||||||
doc.addFileToVFS('Montserrat-Regular.ttf', fonts.regular)
|
|
||||||
doc.addFont('Montserrat-Regular.ttf', 'Montserrat', 'normal')
|
|
||||||
doc.addFileToVFS('Montserrat-Bold.ttf', fonts.bold)
|
|
||||||
doc.addFont('Montserrat-Bold.ttf', 'Montserrat', 'bold')
|
|
||||||
doc.setFont('Montserrat', 'normal')
|
|
||||||
} else {
|
|
||||||
doc.setFont('helvetica', 'normal')
|
|
||||||
}
|
|
||||||
|
|
||||||
return doc
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Cover page
|
|
||||||
// =========================================================================
|
|
||||||
export interface CoverPageOptions {
|
|
||||||
title: string
|
|
||||||
subtitle?: string
|
|
||||||
roundName?: string
|
|
||||||
programName?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addCoverPage(
|
|
||||||
doc: jsPDF,
|
|
||||||
options: CoverPageOptions
|
|
||||||
): Promise<void> {
|
|
||||||
const logo = await loadLogo()
|
|
||||||
|
|
||||||
// Logo centered
|
|
||||||
if (logo) {
|
|
||||||
const logoWidth = 80
|
|
||||||
const logoHeight = 20
|
|
||||||
const logoX = (PAGE_WIDTH - logoWidth) / 2
|
|
||||||
doc.addImage(logo, 'PNG', logoX, 60, logoWidth, logoHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Title
|
|
||||||
const fontName = getFont(doc)
|
|
||||||
doc.setFont(fontName, 'bold')
|
|
||||||
doc.setFontSize(24)
|
|
||||||
doc.setTextColor(...DARK_BLUE_RGB)
|
|
||||||
doc.text(options.title, PAGE_WIDTH / 2, logo ? 110 : 100, { align: 'center' })
|
|
||||||
|
|
||||||
// Subtitle
|
|
||||||
if (options.subtitle) {
|
|
||||||
doc.setFont(fontName, 'normal')
|
|
||||||
doc.setFontSize(14)
|
|
||||||
doc.setTextColor(...TEAL_RGB)
|
|
||||||
doc.text(options.subtitle, PAGE_WIDTH / 2, logo ? 125 : 115, { align: 'center' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Round & program
|
|
||||||
let infoY = logo ? 145 : 135
|
|
||||||
doc.setFontSize(12)
|
|
||||||
doc.setTextColor(...DARK_BLUE_RGB)
|
|
||||||
|
|
||||||
if (options.programName) {
|
|
||||||
doc.text(options.programName, PAGE_WIDTH / 2, infoY, { align: 'center' })
|
|
||||||
infoY += 8
|
|
||||||
}
|
|
||||||
if (options.roundName) {
|
|
||||||
doc.setFont(fontName, 'bold')
|
|
||||||
doc.text(options.roundName, PAGE_WIDTH / 2, infoY, { align: 'center' })
|
|
||||||
infoY += 8
|
|
||||||
}
|
|
||||||
|
|
||||||
// Date
|
|
||||||
doc.setFont(fontName, 'normal')
|
|
||||||
doc.setFontSize(10)
|
|
||||||
doc.setTextColor(136, 136, 136)
|
|
||||||
doc.text(
|
|
||||||
`Generated on ${new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}`,
|
|
||||||
PAGE_WIDTH / 2,
|
|
||||||
infoY + 10,
|
|
||||||
{ align: 'center' }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Decorative line
|
|
||||||
doc.setDrawColor(...TEAL_RGB)
|
|
||||||
doc.setLineWidth(0.5)
|
|
||||||
doc.line(MARGIN + 30, infoY + 20, PAGE_WIDTH - MARGIN - 30, infoY + 20)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Header (on content pages)
|
|
||||||
// =========================================================================
|
|
||||||
export async function addHeader(doc: jsPDF, title: string): Promise<void> {
|
|
||||||
const logo = await loadLogo()
|
|
||||||
|
|
||||||
if (logo) {
|
|
||||||
doc.addImage(logo, 'PNG', MARGIN, 8, 30, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fontName = getFont(doc)
|
|
||||||
doc.setFont(fontName, 'bold')
|
|
||||||
doc.setFontSize(11)
|
|
||||||
doc.setTextColor(...DARK_BLUE_RGB)
|
|
||||||
doc.text(title, PAGE_WIDTH / 2, 14, { align: 'center' })
|
|
||||||
|
|
||||||
doc.setFont(fontName, 'normal')
|
|
||||||
doc.setFontSize(8)
|
|
||||||
doc.setTextColor(136, 136, 136)
|
|
||||||
doc.text(
|
|
||||||
new Date().toLocaleDateString('en-GB'),
|
|
||||||
PAGE_WIDTH - MARGIN,
|
|
||||||
14,
|
|
||||||
{ align: 'right' }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Line under header
|
|
||||||
doc.setDrawColor(...TEAL_RGB)
|
|
||||||
doc.setLineWidth(0.3)
|
|
||||||
doc.line(MARGIN, 18, PAGE_WIDTH - MARGIN, 18)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Footer
|
|
||||||
// =========================================================================
|
|
||||||
export function addFooter(
|
|
||||||
doc: jsPDF,
|
|
||||||
pageNumber: number,
|
|
||||||
totalPages: number
|
|
||||||
): void {
|
|
||||||
const fontName = getFont(doc)
|
|
||||||
const y = PAGE_HEIGHT - 10
|
|
||||||
|
|
||||||
doc.setFont(fontName, 'normal')
|
|
||||||
doc.setFontSize(7)
|
|
||||||
doc.setTextColor(136, 136, 136)
|
|
||||||
|
|
||||||
doc.text('Generated by MOPC Platform', MARGIN, y)
|
|
||||||
doc.text('Confidential', PAGE_WIDTH / 2, y, { align: 'center' })
|
|
||||||
doc.text(`Page ${pageNumber} of ${totalPages}`, PAGE_WIDTH - MARGIN, y, {
|
|
||||||
align: 'right',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addAllPageFooters(doc: jsPDF): void {
|
|
||||||
const totalPages = doc.getNumberOfPages()
|
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
|
||||||
doc.setPage(i)
|
|
||||||
addFooter(doc, i, totalPages)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Section title
|
|
||||||
// =========================================================================
|
|
||||||
export function addSectionTitle(doc: jsPDF, title: string, y: number): number {
|
|
||||||
const fontName = getFont(doc)
|
|
||||||
|
|
||||||
doc.setFont(fontName, 'bold')
|
|
||||||
doc.setFontSize(16)
|
|
||||||
doc.setTextColor(...DARK_BLUE_RGB)
|
|
||||||
doc.text(title, MARGIN, y)
|
|
||||||
|
|
||||||
// Teal underline
|
|
||||||
doc.setDrawColor(...TEAL_RGB)
|
|
||||||
doc.setLineWidth(0.5)
|
|
||||||
doc.line(MARGIN, y + 2, MARGIN + doc.getTextWidth(title), y + 2)
|
|
||||||
|
|
||||||
return y + 12
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Stat cards row
|
|
||||||
// =========================================================================
|
|
||||||
export function addStatCards(
|
|
||||||
doc: jsPDF,
|
|
||||||
stats: Array<{ label: string; value: string | number }>,
|
|
||||||
y: number
|
|
||||||
): number {
|
|
||||||
const fontName = getFont(doc)
|
|
||||||
const cardCount = Math.min(stats.length, 4)
|
|
||||||
const gap = 4
|
|
||||||
const cardWidth = (CONTENT_WIDTH - gap * (cardCount - 1)) / cardCount
|
|
||||||
const cardHeight = 22
|
|
||||||
|
|
||||||
for (let i = 0; i < cardCount; i++) {
|
|
||||||
const x = MARGIN + i * (cardWidth + gap)
|
|
||||||
|
|
||||||
// Card background
|
|
||||||
doc.setFillColor(...LIGHT_GRAY_RGB)
|
|
||||||
doc.roundedRect(x, y, cardWidth, cardHeight, 2, 2, 'F')
|
|
||||||
|
|
||||||
// Value
|
|
||||||
doc.setFont(fontName, 'bold')
|
|
||||||
doc.setFontSize(18)
|
|
||||||
doc.setTextColor(...DARK_BLUE_RGB)
|
|
||||||
doc.text(String(stats[i].value), x + cardWidth / 2, y + 10, {
|
|
||||||
align: 'center',
|
|
||||||
})
|
|
||||||
|
|
||||||
// Label
|
|
||||||
doc.setFont(fontName, 'normal')
|
|
||||||
doc.setFontSize(8)
|
|
||||||
doc.setTextColor(...TEAL_RGB)
|
|
||||||
doc.text(stats[i].label, x + cardWidth / 2, y + 18, { align: 'center' })
|
|
||||||
}
|
|
||||||
|
|
||||||
return y + cardHeight + 8
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Table via autoTable
|
|
||||||
// =========================================================================
|
|
||||||
export function addTable(
|
|
||||||
doc: jsPDF,
|
|
||||||
headers: string[],
|
|
||||||
rows: (string | number)[][],
|
|
||||||
y: number
|
|
||||||
): number {
|
|
||||||
const fontName = getFont(doc)
|
|
||||||
|
|
||||||
autoTable(doc, {
|
|
||||||
startY: y,
|
|
||||||
head: [headers],
|
|
||||||
body: rows,
|
|
||||||
margin: { left: MARGIN, right: MARGIN },
|
|
||||||
styles: {
|
|
||||||
font: fontName,
|
|
||||||
fontSize: 9,
|
|
||||||
cellPadding: 3,
|
|
||||||
textColor: [26, 26, 26],
|
|
||||||
},
|
|
||||||
headStyles: {
|
|
||||||
fillColor: DARK_BLUE_RGB,
|
|
||||||
textColor: [255, 255, 255],
|
|
||||||
fontStyle: 'bold',
|
|
||||||
fontSize: 9,
|
|
||||||
},
|
|
||||||
alternateRowStyles: {
|
|
||||||
fillColor: [248, 248, 248],
|
|
||||||
},
|
|
||||||
theme: 'grid',
|
|
||||||
tableLineColor: [220, 220, 220],
|
|
||||||
tableLineWidth: 0.1,
|
|
||||||
})
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const finalY = (doc as any).lastAutoTable?.finalY ?? y + 20
|
|
||||||
return finalY + 8
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Chart image capture
|
|
||||||
// =========================================================================
|
|
||||||
export async function addChartImage(
|
|
||||||
doc: jsPDF,
|
|
||||||
element: HTMLElement,
|
|
||||||
y: number,
|
|
||||||
options?: { maxHeight?: number }
|
|
||||||
): Promise<number> {
|
|
||||||
const canvas = await html2canvas(element, {
|
|
||||||
scale: 2,
|
|
||||||
useCORS: true,
|
|
||||||
backgroundColor: COLORS.white,
|
|
||||||
logging: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const imgData = canvas.toDataURL('image/jpeg', 0.95)
|
|
||||||
const imgWidth = CONTENT_WIDTH
|
|
||||||
const ratio = canvas.height / canvas.width
|
|
||||||
let imgHeight = imgWidth * ratio
|
|
||||||
const maxH = options?.maxHeight || 100
|
|
||||||
|
|
||||||
if (imgHeight > maxH) {
|
|
||||||
imgHeight = maxH
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check page break
|
|
||||||
y = checkPageBreak(doc, y, imgHeight + 5)
|
|
||||||
|
|
||||||
doc.addImage(imgData, 'JPEG', MARGIN, y, imgWidth, imgHeight)
|
|
||||||
return y + imgHeight + 8
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Page break helper
|
|
||||||
// =========================================================================
|
|
||||||
export function checkPageBreak(
|
|
||||||
doc: jsPDF,
|
|
||||||
y: number,
|
|
||||||
neededHeight: number
|
|
||||||
): number {
|
|
||||||
const availableHeight = PAGE_HEIGHT - 20 // leave room for footer
|
|
||||||
if (y + neededHeight > availableHeight) {
|
|
||||||
doc.addPage()
|
|
||||||
return 25 // start below header area
|
|
||||||
}
|
|
||||||
return y
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addPageBreak(doc: jsPDF): void {
|
|
||||||
doc.addPage()
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Save
|
|
||||||
// =========================================================================
|
|
||||||
export function savePdf(doc: jsPDF, filename: string): void {
|
|
||||||
doc.save(filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Helper
|
|
||||||
// =========================================================================
|
|
||||||
function getFont(doc: jsPDF): string {
|
|
||||||
// Check if Montserrat was loaded
|
|
||||||
try {
|
|
||||||
const fonts = doc.getFontList()
|
|
||||||
if (fonts['Montserrat']) return 'Montserrat'
|
|
||||||
} catch {
|
|
||||||
// Fallback
|
|
||||||
}
|
|
||||||
return 'helvetica'
|
|
||||||
}
|
|
||||||
|
|
@ -634,157 +634,4 @@ export const analyticsRouter = router({
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
|
||||||
* Get dashboard stats (optionally scoped to a round)
|
|
||||||
*/
|
|
||||||
getDashboardStats: observerProcedure
|
|
||||||
.input(z.object({ roundId: z.string().optional() }).optional())
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
const roundId = input?.roundId
|
|
||||||
|
|
||||||
const roundWhere = roundId ? { roundId } : {}
|
|
||||||
const assignmentWhere = roundId ? { roundId } : {}
|
|
||||||
const evalWhere = roundId
|
|
||||||
? { assignment: { roundId }, status: 'SUBMITTED' as const }
|
|
||||||
: { status: 'SUBMITTED' as const }
|
|
||||||
|
|
||||||
const [
|
|
||||||
programCount,
|
|
||||||
activeRoundCount,
|
|
||||||
projectCount,
|
|
||||||
jurorCount,
|
|
||||||
submittedEvaluations,
|
|
||||||
totalAssignments,
|
|
||||||
evaluationScores,
|
|
||||||
] = await Promise.all([
|
|
||||||
ctx.prisma.program.count(),
|
|
||||||
ctx.prisma.round.count({ where: { status: 'ACTIVE' } }),
|
|
||||||
ctx.prisma.project.count({ where: roundWhere }),
|
|
||||||
ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }),
|
|
||||||
ctx.prisma.evaluation.count({ where: evalWhere }),
|
|
||||||
ctx.prisma.assignment.count({ where: assignmentWhere }),
|
|
||||||
ctx.prisma.evaluation.findMany({
|
|
||||||
where: { ...evalWhere, globalScore: { not: null } },
|
|
||||||
select: { globalScore: true },
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
const completionRate = totalAssignments > 0
|
|
||||||
? Math.round((submittedEvaluations / totalAssignments) * 100)
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const scores = evaluationScores.map((e) => e.globalScore!).filter((s) => s != null)
|
|
||||||
const scoreDistribution = [
|
|
||||||
{ label: '9-10', min: 9, max: 10 },
|
|
||||||
{ label: '7-8', min: 7, max: 8.99 },
|
|
||||||
{ label: '5-6', min: 5, max: 6.99 },
|
|
||||||
{ label: '3-4', min: 3, max: 4.99 },
|
|
||||||
{ label: '1-2', min: 1, max: 2.99 },
|
|
||||||
].map((b) => ({
|
|
||||||
label: b.label,
|
|
||||||
count: scores.filter((s) => s >= b.min && s <= b.max).length,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return {
|
|
||||||
programCount,
|
|
||||||
activeRoundCount,
|
|
||||||
projectCount,
|
|
||||||
jurorCount,
|
|
||||||
submittedEvaluations,
|
|
||||||
totalEvaluations: totalAssignments,
|
|
||||||
completionRate,
|
|
||||||
scoreDistribution,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all projects with pagination, filtering, and search (for observer dashboard)
|
|
||||||
*/
|
|
||||||
getAllProjects: observerProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
roundId: z.string().optional(),
|
|
||||||
search: z.string().optional(),
|
|
||||||
status: z.string().optional(),
|
|
||||||
page: z.number().min(1).default(1),
|
|
||||||
perPage: z.number().min(1).max(100).default(20),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
const where: Record<string, unknown> = {}
|
|
||||||
|
|
||||||
if (input.roundId) {
|
|
||||||
where.roundId = input.roundId
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.status) {
|
|
||||||
where.status = input.status
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.search) {
|
|
||||||
where.OR = [
|
|
||||||
{ title: { contains: input.search, mode: 'insensitive' } },
|
|
||||||
{ teamName: { contains: input.search, mode: 'insensitive' } },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const [projects, total] = await Promise.all([
|
|
||||||
ctx.prisma.project.findMany({
|
|
||||||
where,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
teamName: true,
|
|
||||||
status: true,
|
|
||||||
country: true,
|
|
||||||
round: { select: { id: true, name: true } },
|
|
||||||
assignments: {
|
|
||||||
select: {
|
|
||||||
evaluation: {
|
|
||||||
select: { globalScore: true, status: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { title: 'asc' },
|
|
||||||
skip: (input.page - 1) * input.perPage,
|
|
||||||
take: input.perPage,
|
|
||||||
}),
|
|
||||||
ctx.prisma.project.count({ where }),
|
|
||||||
])
|
|
||||||
|
|
||||||
const mapped = projects.map((p) => {
|
|
||||||
const submitted = p.assignments
|
|
||||||
.map((a) => a.evaluation)
|
|
||||||
.filter((e) => e?.status === 'SUBMITTED')
|
|
||||||
const scores = submitted
|
|
||||||
.map((e) => e?.globalScore)
|
|
||||||
.filter((s): s is number => s !== null)
|
|
||||||
const averageScore =
|
|
||||||
scores.length > 0
|
|
||||||
? scores.reduce((a, b) => a + b, 0) / scores.length
|
|
||||||
: null
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: p.id,
|
|
||||||
title: p.title,
|
|
||||||
teamName: p.teamName,
|
|
||||||
status: p.status,
|
|
||||||
country: p.country,
|
|
||||||
roundId: p.round?.id ?? '',
|
|
||||||
roundName: p.round?.name ?? '',
|
|
||||||
averageScore,
|
|
||||||
evaluationCount: submitted.length,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
projects: mapped,
|
|
||||||
total,
|
|
||||||
page: input.page,
|
|
||||||
perPage: input.perPage,
|
|
||||||
totalPages: Math.ceil(total / input.perPage),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -826,7 +826,7 @@ export const applicantRouter = router({
|
||||||
email: input.email,
|
email: input.email,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
role: 'APPLICANT',
|
role: 'APPLICANT',
|
||||||
status: 'NONE',
|
status: 'INVITED',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -482,7 +482,7 @@ export const applicationRouter = router({
|
||||||
email: member.email,
|
email: member.email,
|
||||||
name: member.name,
|
name: member.name,
|
||||||
role: 'APPLICANT',
|
role: 'APPLICANT',
|
||||||
status: 'NONE',
|
status: 'INVITED',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,23 +94,6 @@ export const notificationRouter = router({
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark multiple notifications as read by IDs
|
|
||||||
*/
|
|
||||||
markBatchAsRead: protectedProcedure
|
|
||||||
.input(z.object({ ids: z.array(z.string()).min(1).max(50) }))
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
await ctx.prisma.inAppNotification.updateMany({
|
|
||||||
where: {
|
|
||||||
id: { in: input.ids },
|
|
||||||
userId: ctx.user.id,
|
|
||||||
isRead: false,
|
|
||||||
},
|
|
||||||
data: { isRead: true, readAt: new Date() },
|
|
||||||
})
|
|
||||||
return { success: true }
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark all notifications as read for the current user
|
* Mark all notifications as read for the current user
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import crypto from 'crypto'
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
|
|
@ -10,9 +9,6 @@ import {
|
||||||
} from '../services/in-app-notification'
|
} from '../services/in-app-notification'
|
||||||
import { normalizeCountryToCode } from '@/lib/countries'
|
import { normalizeCountryToCode } from '@/lib/countries'
|
||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
import { sendInvitationEmail } from '@/lib/email'
|
|
||||||
|
|
||||||
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
||||||
|
|
||||||
// Valid project status transitions
|
// Valid project status transitions
|
||||||
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
|
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
|
||||||
|
|
@ -85,23 +81,17 @@ export const projectRouter = router({
|
||||||
// Build where clause
|
// Build where clause
|
||||||
const where: Record<string, unknown> = {}
|
const where: Record<string, unknown> = {}
|
||||||
|
|
||||||
// Filter by program
|
// Filter by program via round
|
||||||
if (programId) where.programId = programId
|
if (programId) where.round = { programId }
|
||||||
|
|
||||||
// Filter by round
|
// Filter by round
|
||||||
if (roundId) {
|
if (roundId) {
|
||||||
where.roundId = roundId
|
where.roundId = roundId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclude projects in a specific round (include unassigned projects with roundId=null)
|
// Exclude projects in a specific round
|
||||||
if (notInRoundId) {
|
if (notInRoundId) {
|
||||||
if (!where.AND) where.AND = []
|
where.roundId = { not: notInRoundId }
|
||||||
;(where.AND as unknown[]).push({
|
|
||||||
OR: [
|
|
||||||
{ roundId: null },
|
|
||||||
{ roundId: { not: notInRoundId } },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by unassigned (no round)
|
// Filter by unassigned (no round)
|
||||||
|
|
@ -174,91 +164,6 @@ export const projectRouter = router({
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
|
||||||
* List all project IDs matching filters (no pagination).
|
|
||||||
* Used for "select all across pages" in bulk operations.
|
|
||||||
*/
|
|
||||||
listAllIds: adminProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
programId: z.string().optional(),
|
|
||||||
roundId: z.string().optional(),
|
|
||||||
notInRoundId: z.string().optional(),
|
|
||||||
unassignedOnly: z.boolean().optional(),
|
|
||||||
search: z.string().optional(),
|
|
||||||
statuses: z.array(
|
|
||||||
z.enum([
|
|
||||||
'SUBMITTED',
|
|
||||||
'ELIGIBLE',
|
|
||||||
'ASSIGNED',
|
|
||||||
'SEMIFINALIST',
|
|
||||||
'FINALIST',
|
|
||||||
'REJECTED',
|
|
||||||
])
|
|
||||||
).optional(),
|
|
||||||
tags: z.array(z.string()).optional(),
|
|
||||||
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
|
||||||
oceanIssue: z.enum([
|
|
||||||
'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
|
|
||||||
'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
|
|
||||||
'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
|
|
||||||
'OCEAN_ACIDIFICATION', 'OTHER',
|
|
||||||
]).optional(),
|
|
||||||
country: z.string().optional(),
|
|
||||||
wantsMentorship: z.boolean().optional(),
|
|
||||||
hasFiles: z.boolean().optional(),
|
|
||||||
hasAssignments: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
const {
|
|
||||||
programId, roundId, notInRoundId, unassignedOnly,
|
|
||||||
search, statuses, tags,
|
|
||||||
competitionCategory, oceanIssue, country,
|
|
||||||
wantsMentorship, hasFiles, hasAssignments,
|
|
||||||
} = input
|
|
||||||
|
|
||||||
const where: Record<string, unknown> = {}
|
|
||||||
|
|
||||||
if (programId) where.programId = programId
|
|
||||||
if (roundId) where.roundId = roundId
|
|
||||||
if (notInRoundId) {
|
|
||||||
if (!where.AND) where.AND = []
|
|
||||||
;(where.AND as unknown[]).push({
|
|
||||||
OR: [
|
|
||||||
{ roundId: null },
|
|
||||||
{ roundId: { not: notInRoundId } },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (unassignedOnly) where.roundId = null
|
|
||||||
if (statuses?.length) where.status = { in: statuses }
|
|
||||||
if (tags && tags.length > 0) where.tags = { hasSome: tags }
|
|
||||||
if (competitionCategory) where.competitionCategory = competitionCategory
|
|
||||||
if (oceanIssue) where.oceanIssue = oceanIssue
|
|
||||||
if (country) where.country = country
|
|
||||||
if (wantsMentorship !== undefined) where.wantsMentorship = wantsMentorship
|
|
||||||
if (hasFiles === true) where.files = { some: {} }
|
|
||||||
if (hasFiles === false) where.files = { none: {} }
|
|
||||||
if (hasAssignments === true) where.assignments = { some: {} }
|
|
||||||
if (hasAssignments === false) where.assignments = { none: {} }
|
|
||||||
if (search) {
|
|
||||||
where.OR = [
|
|
||||||
{ title: { contains: search, mode: 'insensitive' } },
|
|
||||||
{ teamName: { contains: search, mode: 'insensitive' } },
|
|
||||||
{ description: { contains: search, mode: 'insensitive' } },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const projects = await ctx.prisma.project.findMany({
|
|
||||||
where,
|
|
||||||
select: { id: true },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
})
|
|
||||||
|
|
||||||
return { ids: projects.map((p) => p.id) }
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get filter options for the project list (distinct values)
|
* Get filter options for the project list (distinct values)
|
||||||
*/
|
*/
|
||||||
|
|
@ -413,21 +318,12 @@ export const projectRouter = router({
|
||||||
contactName: z.string().optional(),
|
contactName: z.string().optional(),
|
||||||
city: z.string().optional(),
|
city: z.string().optional(),
|
||||||
metadataJson: z.record(z.unknown()).optional(),
|
metadataJson: z.record(z.unknown()).optional(),
|
||||||
teamMembers: z.array(z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
email: z.string().email(),
|
|
||||||
role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']),
|
|
||||||
title: z.string().optional(),
|
|
||||||
phone: z.string().optional(),
|
|
||||||
sendInvite: z.boolean().default(false),
|
|
||||||
})).max(10).optional(),
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const {
|
const {
|
||||||
metadataJson,
|
metadataJson,
|
||||||
contactPhone, contactEmail, contactName, city,
|
contactPhone, contactEmail, contactName, city,
|
||||||
teamMembers: teamMembersInput,
|
|
||||||
...rest
|
...rest
|
||||||
} = input
|
} = input
|
||||||
|
|
||||||
|
|
@ -453,7 +349,7 @@ export const projectRouter = router({
|
||||||
? normalizeCountryToCode(input.country)
|
? normalizeCountryToCode(input.country)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const { project, membersToInvite } = await ctx.prisma.$transaction(async (tx) => {
|
const project = await ctx.prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.project.create({
|
const created = await tx.project.create({
|
||||||
data: {
|
data: {
|
||||||
programId: resolvedProgramId,
|
programId: resolvedProgramId,
|
||||||
|
|
@ -473,112 +369,20 @@ export const projectRouter = router({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create team members if provided
|
|
||||||
const inviteList: { userId: string; email: string; name: string }[] = []
|
|
||||||
if (teamMembersInput && teamMembersInput.length > 0) {
|
|
||||||
for (const member of teamMembersInput) {
|
|
||||||
// Find or create user
|
|
||||||
let user = await tx.user.findUnique({
|
|
||||||
where: { email: member.email.toLowerCase() },
|
|
||||||
select: { id: true, status: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
user = await tx.user.create({
|
|
||||||
data: {
|
|
||||||
email: member.email.toLowerCase(),
|
|
||||||
name: member.name,
|
|
||||||
role: 'APPLICANT',
|
|
||||||
status: 'NONE',
|
|
||||||
phoneNumber: member.phone || null,
|
|
||||||
},
|
|
||||||
select: { id: true, status: true },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create TeamMember link (skip if already linked)
|
|
||||||
await tx.teamMember.upsert({
|
|
||||||
where: {
|
|
||||||
projectId_userId: {
|
|
||||||
projectId: created.id,
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
projectId: created.id,
|
|
||||||
userId: user.id,
|
|
||||||
role: member.role,
|
|
||||||
title: member.title || null,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
role: member.role,
|
|
||||||
title: member.title || null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (member.sendInvite) {
|
|
||||||
inviteList.push({ userId: user.id, email: member.email.toLowerCase(), name: member.name })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
prisma: tx,
|
prisma: tx,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
entityType: 'Project',
|
entityType: 'Project',
|
||||||
entityId: created.id,
|
entityId: created.id,
|
||||||
detailsJson: {
|
detailsJson: { title: input.title, roundId: input.roundId, programId: resolvedProgramId },
|
||||||
title: input.title,
|
|
||||||
roundId: input.roundId,
|
|
||||||
programId: resolvedProgramId,
|
|
||||||
teamMembersCount: teamMembersInput?.length || 0,
|
|
||||||
},
|
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { project: created, membersToInvite: inviteList }
|
return created
|
||||||
})
|
})
|
||||||
|
|
||||||
// Send invite emails outside the transaction (never fail project creation)
|
|
||||||
if (membersToInvite.length > 0) {
|
|
||||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
|
|
||||||
for (const member of membersToInvite) {
|
|
||||||
try {
|
|
||||||
const token = crypto.randomBytes(32).toString('hex')
|
|
||||||
await ctx.prisma.user.update({
|
|
||||||
where: { id: member.userId },
|
|
||||||
data: {
|
|
||||||
status: 'INVITED',
|
|
||||||
inviteToken: token,
|
|
||||||
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const inviteUrl = `${baseUrl}/auth/accept-invite?token=${token}`
|
|
||||||
await sendInvitationEmail(member.email, member.name, inviteUrl, 'APPLICANT')
|
|
||||||
|
|
||||||
// Log notification
|
|
||||||
try {
|
|
||||||
await ctx.prisma.notificationLog.create({
|
|
||||||
data: {
|
|
||||||
userId: member.userId,
|
|
||||||
channel: 'EMAIL',
|
|
||||||
type: 'JURY_INVITATION',
|
|
||||||
status: 'SENT',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// Never fail on notification logging
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Email sending failure should not break project creation
|
|
||||||
console.error(`Failed to send invite to ${member.email}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return project
|
return project
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ export const userRouter = router({
|
||||||
z.object({
|
z.object({
|
||||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
||||||
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
|
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
|
||||||
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
page: z.number().int().min(1).default(1),
|
page: z.number().int().min(1).default(1),
|
||||||
perPage: z.number().int().min(1).max(100).default(20),
|
perPage: z.number().int().min(1).max(100).default(20),
|
||||||
|
|
@ -340,7 +340,7 @@ export const userRouter = router({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string().optional().nullable(),
|
name: z.string().optional().nullable(),
|
||||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
||||||
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||||
expertiseTags: z.array(z.string()).optional(),
|
expertiseTags: z.array(z.string()).optional(),
|
||||||
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
|
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
|
||||||
availabilityJson: z.any().optional(),
|
availabilityJson: z.any().optional(),
|
||||||
|
|
@ -362,14 +362,6 @@ export const userRouter = router({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent non-super-admins from changing admin roles
|
|
||||||
if (data.role && targetUser.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'FORBIDDEN',
|
|
||||||
message: 'Only super admins can change admin roles',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent non-super-admins from assigning super admin or admin role
|
// Prevent non-super-admins from assigning super admin or admin role
|
||||||
if (data.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
|
if (data.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|
@ -716,19 +708,18 @@ export const userRouter = router({
|
||||||
where: { id: input.userId },
|
where: { id: input.userId },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (user.status !== 'NONE' && user.status !== 'INVITED') {
|
if (user.status !== 'INVITED') {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'User has already accepted their invitation',
|
message: 'User has already accepted their invitation',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate invite token, set status to INVITED, and store on user
|
// Generate invite token and store on user
|
||||||
const token = generateInviteToken()
|
const token = generateInviteToken()
|
||||||
await ctx.prisma.user.update({
|
await ctx.prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'INVITED',
|
|
||||||
inviteToken: token,
|
inviteToken: token,
|
||||||
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
||||||
},
|
},
|
||||||
|
|
@ -775,7 +766,7 @@ export const userRouter = router({
|
||||||
const users = await ctx.prisma.user.findMany({
|
const users = await ctx.prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: input.userIds },
|
id: { in: input.userIds },
|
||||||
status: { in: ['NONE', 'INVITED'] },
|
status: 'INVITED',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -789,12 +780,11 @@ export const userRouter = router({
|
||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
try {
|
try {
|
||||||
// Generate invite token for each user and set status to INVITED
|
// Generate invite token for each user
|
||||||
const token = generateInviteToken()
|
const token = generateInviteToken()
|
||||||
await ctx.prisma.user.update({
|
await ctx.prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'INVITED',
|
|
||||||
inviteToken: token,
|
inviteToken: token,
|
||||||
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ export type DocumentCheckConfig = {
|
||||||
|
|
||||||
export type AIScreeningConfig = {
|
export type AIScreeningConfig = {
|
||||||
criteriaText: string
|
criteriaText: string
|
||||||
action: 'PASS' | 'REJECT' | 'FLAG' // REJECT = auto-filter-out, FLAG = flag for human review
|
action: 'FLAG' // AI screening always flags for human review
|
||||||
// Performance settings
|
// Performance settings
|
||||||
batchSize?: number // Projects per API call (1-50, default 20)
|
batchSize?: number // Projects per API call (1-50, default 20)
|
||||||
parallelBatches?: number // Concurrent API calls (1-10, default 1)
|
parallelBatches?: number // Concurrent API calls (1-10, default 1)
|
||||||
|
|
@ -607,21 +607,16 @@ export async function executeFilteringRules(
|
||||||
|
|
||||||
if (screening) {
|
if (screening) {
|
||||||
const passed = screening.meetsCriteria && !screening.spamRisk
|
const passed = screening.meetsCriteria && !screening.spamRisk
|
||||||
const aiConfig = aiRule.configJson as unknown as AIScreeningConfig
|
|
||||||
const aiAction = aiConfig?.action || 'FLAG'
|
|
||||||
ruleResults.push({
|
ruleResults.push({
|
||||||
ruleId: aiRule.id,
|
ruleId: aiRule.id,
|
||||||
ruleName: aiRule.name,
|
ruleName: aiRule.name,
|
||||||
ruleType: 'AI_SCREENING',
|
ruleType: 'AI_SCREENING',
|
||||||
passed,
|
passed,
|
||||||
action: aiAction,
|
action: 'FLAG',
|
||||||
reasoning: screening.reasoning,
|
reasoning: screening.reasoning,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!passed) {
|
if (!passed) hasFlagged = true
|
||||||
if (aiAction === 'REJECT') hasFailed = true
|
|
||||||
else hasFlagged = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export type RoundSettings =
|
||||||
export const defaultFilteringSettings: FilteringRoundSettings = {
|
export const defaultFilteringSettings: FilteringRoundSettings = {
|
||||||
autoEliminationEnabled: false,
|
autoEliminationEnabled: false,
|
||||||
autoEliminationThreshold: 4,
|
autoEliminationThreshold: 4,
|
||||||
autoEliminationMinReviews: 0,
|
autoEliminationMinReviews: 3,
|
||||||
targetAdvancing: 60,
|
targetAdvancing: 60,
|
||||||
showAverageScore: true,
|
showAverageScore: true,
|
||||||
showRanking: true,
|
showRanking: true,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue