Observer dashboard extraction, PDF reports, jury UX overhaul, and miscellaneous improvements
Build and Push Docker Image / build (push) Successful in 15m32s
Details
Build and Push Docker Image / build (push) Successful in 15m32s
Details
- Extract observer dashboard to client component, add PDF export button - Add PDF report generator with jsPDF for analytics reports - Overhaul jury evaluation page with improved layout and UX - Add new analytics endpoints for observer/admin reports - Improve round creation/edit forms with better settings - Fix filtering rules page, CSV export dialog, notification bell - Update auth, prisma schema, and various type fixes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5c8d22ac11
commit
d787a24921
|
|
@ -48,6 +48,9 @@
|
||||||
"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",
|
||||||
|
|
@ -5107,6 +5110,12 @@
|
||||||
"@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",
|
||||||
|
|
@ -5117,6 +5126,13 @@
|
||||||
"@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",
|
||||||
|
|
@ -5135,6 +5151,13 @@
|
||||||
"@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",
|
||||||
|
|
@ -6131,6 +6154,15 @@
|
||||||
"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",
|
||||||
|
|
@ -6294,6 +6326,26 @@
|
||||||
],
|
],
|
||||||
"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",
|
||||||
|
|
@ -6517,6 +6569,18 @@
|
||||||
"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",
|
||||||
|
|
@ -6544,6 +6608,15 @@
|
||||||
"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",
|
||||||
|
|
@ -6924,6 +6997,16 @@
|
||||||
"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",
|
||||||
|
|
@ -7800,6 +7883,17 @@
|
||||||
"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",
|
||||||
|
|
@ -7846,6 +7940,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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",
|
||||||
|
|
@ -8574,6 +8674,19 @@
|
||||||
"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",
|
||||||
|
|
@ -8699,6 +8812,12 @@
|
||||||
"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",
|
||||||
|
|
@ -9257,6 +9376,32 @@
|
||||||
"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",
|
||||||
|
|
@ -11262,6 +11407,12 @@
|
||||||
"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",
|
||||||
|
|
@ -11346,6 +11497,13 @@
|
||||||
"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",
|
||||||
|
|
@ -11945,6 +12103,16 @@
|
||||||
],
|
],
|
||||||
"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",
|
||||||
|
|
@ -12289,6 +12457,13 @@
|
||||||
"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",
|
||||||
|
|
@ -12509,6 +12684,16 @@
|
||||||
"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",
|
||||||
|
|
@ -12937,6 +13122,16 @@
|
||||||
"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",
|
||||||
|
|
@ -13214,6 +13409,16 @@
|
||||||
"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",
|
||||||
|
|
@ -13259,6 +13464,15 @@
|
||||||
"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",
|
||||||
|
|
@ -13876,6 +14090,15 @@
|
||||||
"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,6 +61,9 @@
|
||||||
"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 ('INVITED', 'ACTIVE', 'SUSPENDED');
|
CREATE TYPE "UserStatus" AS ENUM ('NONE', 'INVITED', 'ACTIVE', 'SUSPENDED');
|
||||||
|
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
CREATE TYPE "ProgramStatus" AS ENUM ('DRAFT', 'ACTIVE', 'ARCHIVED');
|
CREATE TYPE "ProgramStatus" AS ENUM ('DRAFT', 'ACTIVE', 'ARCHIVED');
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ 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: 'INVITED',
|
status: 'NONE',
|
||||||
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: 'INVITED',
|
status: 'NONE',
|
||||||
metadataJson: {
|
metadataJson: {
|
||||||
isPendingEmailVerification: true,
|
isPendingEmailVerification: true,
|
||||||
originalName: member.name,
|
originalName: member.name,
|
||||||
|
|
|
||||||
|
|
@ -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'] },
|
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
|
||||||
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-accent transition-all"
|
className="h-full rounded-full bg-brand-teal transition-all"
|
||||||
style={{ width: `${(issue.count / maxIssueCount) * 100}%` }}
|
style={{ width: `${(issue.count / maxIssueCount) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,10 @@ 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 { PhoneInput } from '@/components/ui/phone-input'
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||||
|
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,
|
||||||
|
|
@ -33,8 +36,52 @@ 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()
|
||||||
|
|
@ -49,15 +96,20 @@ 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',
|
||||||
|
|
@ -92,6 +144,66 @@ 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')
|
||||||
|
|
@ -113,10 +225,16 @@ 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,
|
||||||
contactPhone: contactPhone.trim() || undefined,
|
teamMembers: teamMembers.length > 0
|
||||||
contactEmail: contactEmail.trim() || undefined,
|
? teamMembers.map(({ name, email, role, title: t, phone, sendInvite }) => ({
|
||||||
contactName: contactName.trim() || undefined,
|
name,
|
||||||
city: city.trim() || undefined,
|
email,
|
||||||
|
role,
|
||||||
|
title: t || undefined,
|
||||||
|
phone: phone || undefined,
|
||||||
|
sendInvite,
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,47 +422,6 @@ 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>
|
||||||
|
|
@ -353,16 +430,171 @@ 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">
|
||||||
<Label htmlFor="city">City</Label>
|
{sortedMembers.map((member) => (
|
||||||
|
<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="city"
|
id="memberName"
|
||||||
value={city}
|
value={memberName}
|
||||||
onChange={(e) => setCity(e.target.value)}
|
onChange={(e) => setMemberName(e.target.value)}
|
||||||
placeholder="e.g., Monaco"
|
placeholder="Full name"
|
||||||
|
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>
|
||||||
|
|
|
||||||
|
|
@ -37,12 +37,10 @@ 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 {
|
||||||
|
|
@ -57,6 +55,7 @@ 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 })
|
||||||
|
|
@ -631,6 +630,19 @@ 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 */}
|
||||||
|
|
@ -666,16 +678,27 @@ export default function ReportsPage() {
|
||||||
Diversity
|
Diversity
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
variant="outline"
|
<Select value={pdfRoundId || ''} onValueChange={setPdfRoundId}>
|
||||||
size="sm"
|
<SelectTrigger className="w-[220px]">
|
||||||
onClick={() => {
|
<SelectValue placeholder="Select round for PDF" />
|
||||||
window.print()
|
</SelectTrigger>
|
||||||
}}
|
<SelectContent>
|
||||||
>
|
{pdfRounds.map((round) => (
|
||||||
<Printer className="mr-2 h-4 w-4" />
|
<SelectItem key={round.id} value={round.id}>
|
||||||
Export PDF
|
{round.programName} - {round.name}
|
||||||
</Button>
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</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(1).max(10),
|
requiredReviews: z.number().int().min(0).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: data.requiredReviews,
|
requiredReviews: roundType === 'FILTERING' ? 0 : data.requiredReviews,
|
||||||
minAssignmentsPerJuror: data.minAssignmentsPerJuror,
|
minAssignmentsPerJuror: data.minAssignmentsPerJuror,
|
||||||
maxAssignmentsPerJuror: data.maxAssignmentsPerJuror,
|
maxAssignmentsPerJuror: data.maxAssignmentsPerJuror,
|
||||||
roundType,
|
roundType,
|
||||||
|
|
@ -301,6 +301,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{roundType !== 'FILTERING' && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="requiredReviews"
|
name="requiredReviews"
|
||||||
|
|
@ -325,6 +326,7 @@ 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,6 +105,8 @@ 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,6 +109,7 @@ 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')
|
||||||
|
|
||||||
|
|
@ -144,7 +145,7 @@ export default function FilteringRulesPage({
|
||||||
} else if (newRuleType === 'AI_SCREENING') {
|
} else if (newRuleType === 'AI_SCREENING') {
|
||||||
configJson = {
|
configJson = {
|
||||||
criteriaText,
|
criteriaText,
|
||||||
action: 'FLAG',
|
action: aiAction,
|
||||||
batchSize: parseInt(aiBatchSize) || 20,
|
batchSize: parseInt(aiBatchSize) || 20,
|
||||||
parallelBatches: parseInt(aiParallelBatches) || 1,
|
parallelBatches: parseInt(aiParallelBatches) || 1,
|
||||||
}
|
}
|
||||||
|
|
@ -418,9 +419,23 @@ 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">
|
||||||
AI screening always flags projects for human review, never
|
{aiAction === 'REJECT'
|
||||||
auto-rejects.
|
? 'Projects that don\'t meet criteria will be automatically filtered out.'
|
||||||
|
: 'Projects that don\'t meet criteria will be flagged for human review.'}
|
||||||
</p>
|
</p>
|
||||||
</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(1).max(10),
|
requiredReviews: z.number().int().min(0).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: data.requiredReviews,
|
requiredReviews: roundType === 'FILTERING' ? 0 : data.requiredReviews,
|
||||||
settingsJson: roundSettings,
|
settingsJson: roundSettings,
|
||||||
votingStartAt: data.votingStartAt ?? undefined,
|
votingStartAt: data.votingStartAt ?? undefined,
|
||||||
votingEndAt: data.votingEndAt ?? undefined,
|
votingEndAt: data.votingEndAt ?? undefined,
|
||||||
|
|
@ -291,6 +291,7 @@ function CreateRoundContent() {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{roundType !== 'FILTERING' && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="requiredReviews"
|
name="requiredReviews"
|
||||||
|
|
@ -313,6 +314,7 @@ function CreateRoundContent() {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,22 +15,21 @@ 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 {
|
||||||
|
|
@ -186,29 +185,33 @@ async function JuryDashboardContent() {
|
||||||
label: 'Total Assignments',
|
label: 'Total Assignments',
|
||||||
value: totalAssignments,
|
value: totalAssignments,
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
|
accentColor: 'border-l-blue-500',
|
||||||
|
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,
|
||||||
iconBg: 'bg-green-100 dark:bg-green-900/30',
|
accentColor: 'border-l-emerald-500',
|
||||||
iconColor: 'text-green-600 dark:text-green-400',
|
iconBg: 'bg-emerald-50 dark:bg-emerald-950/40',
|
||||||
|
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'In Progress',
|
label: 'In Progress',
|
||||||
value: inProgressAssignments,
|
value: inProgressAssignments,
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
iconBg: 'bg-amber-100 dark:bg-amber-900/30',
|
accentColor: 'border-l-amber-500',
|
||||||
|
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,
|
||||||
iconBg: 'bg-slate-100 dark:bg-slate-800',
|
accentColor: 'border-l-slate-400',
|
||||||
iconColor: 'text-slate-500',
|
iconBg: 'bg-slate-50 dark:bg-slate-800/50',
|
||||||
|
iconColor: 'text-slate-500 dark:text-slate-400',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -216,14 +219,16 @@ async function JuryDashboardContent() {
|
||||||
<>
|
<>
|
||||||
{/* Hero CTA - Jump to next evaluation */}
|
{/* Hero CTA - Jump to next evaluation */}
|
||||||
{nextUnevaluated && activeRemaining > 0 && (
|
{nextUnevaluated && activeRemaining > 0 && (
|
||||||
<Card className="border-primary/20 bg-gradient-to-r from-primary/5 to-accent/5">
|
<AnimatedCard index={0}>
|
||||||
<CardContent className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 py-5">
|
<Card className="overflow-hidden border-0 shadow-lg">
|
||||||
<div className="flex items-center gap-3">
|
<div className="bg-gradient-to-r from-brand-blue to-brand-teal p-[1px] rounded-lg">
|
||||||
<div className="rounded-full bg-primary/10 p-2.5">
|
<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">
|
||||||
<Zap className="h-5 w-5 text-primary" />
|
<div className="flex items-center gap-4">
|
||||||
|
<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">
|
<p className="font-semibold text-base">
|
||||||
{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">
|
||||||
|
|
@ -231,59 +236,87 @@ async function JuryDashboardContent() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<Button asChild size="lg" className="bg-brand-blue hover:bg-brand-blue-light shadow-md">
|
||||||
<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) => (
|
{stats.map((stat, i) => (
|
||||||
<Card key={stat.label} className="transition-all hover:shadow-md">
|
<AnimatedCard key={stat.label} index={i + 1}>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<Card className={cn(
|
||||||
<div className={cn('rounded-full p-2.5', stat.iconBg)}>
|
'border-l-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||||
|
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">{stat.value}</p>
|
<p className="text-2xl font-bold tabular-nums tracking-tight">{stat.value}</p>
|
||||||
<p className="text-sm text-muted-foreground">{stat.label}</p>
|
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Overall Progress */}
|
{/* Overall Progress */}
|
||||||
<Card>
|
<AnimatedCard index={5}>
|
||||||
<CardContent className="py-4">
|
<Card className="overflow-hidden">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="h-1 w-full bg-gradient-to-r from-brand-teal via-brand-blue to-brand-teal" />
|
||||||
<div className="flex items-center gap-2">
|
<CardContent className="py-5 px-6">
|
||||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-sm font-medium">Overall Completion</span>
|
<div className="flex items-center gap-2.5">
|
||||||
|
<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 tabular-nums">
|
<span className="text-sm font-semibold">Overall Completion</span>
|
||||||
{completedAssignments}/{totalAssignments} ({completionRate.toFixed(0)}%)
|
</div>
|
||||||
|
<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>
|
||||||
<Progress value={completionRate} className="h-2.5" />
|
</div>
|
||||||
|
<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>
|
||||||
<Button variant="ghost" size="sm" asChild>
|
</div>
|
||||||
|
<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" />
|
||||||
|
|
@ -293,8 +326,8 @@ async function JuryDashboardContent() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{recentAssignments.length > 0 ? (
|
{recentAssignments.length > 0 ? (
|
||||||
<div className="divide-y">
|
<div className="space-y-1">
|
||||||
{recentAssignments.map((assignment) => {
|
{recentAssignments.map((assignment, idx) => {
|
||||||
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'
|
||||||
|
|
@ -308,20 +341,24 @@ async function JuryDashboardContent() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={assignment.id}
|
key={assignment.id}
|
||||||
className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"
|
className={cn(
|
||||||
|
'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-primary group-hover:underline transition-colors">
|
<p className="text-sm font-medium truncate group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">
|
||||||
{assignment.project.title}
|
{assignment.project.title}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<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">
|
<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">
|
||||||
{assignment.round.name}
|
{assignment.round.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -347,7 +384,7 @@ async function JuryDashboardContent() {
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : isVotingOpen ? (
|
) : isVotingOpen ? (
|
||||||
<Button size="sm" asChild className="h-7 px-2">
|
<Button size="sm" asChild className="h-7 px-3 bg-brand-blue hover:bg-brand-blue-light shadow-sm">
|
||||||
<Link href={`/jury/projects/${assignment.project.id}/evaluate`}>
|
<Link href={`/jury/projects/${assignment.project.id}/evaluate`}>
|
||||||
{isDraft ? 'Continue' : 'Evaluate'}
|
{isDraft ? 'Continue' : 'Evaluate'}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -365,56 +402,84 @@ async function JuryDashboardContent() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||||
<ClipboardList className="h-10 w-10 text-muted-foreground/30" />
|
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-3">
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<ClipboardList className="h-8 w-8 text-brand-teal/60" />
|
||||||
|
</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">
|
||||||
<Button variant="outline" className="justify-start h-auto py-3" asChild>
|
<Link
|
||||||
<Link href="/jury/assignments">
|
href="/jury/assignments"
|
||||||
<ClipboardList className="mr-2 h-4 w-4 text-muted-foreground" />
|
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"
|
||||||
|
>
|
||||||
|
<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-medium">All Assignments</p>
|
<p className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">All Assignments</p>
|
||||||
<p className="text-xs text-muted-foreground">View and manage evaluations</p>
|
<p className="text-xs text-muted-foreground mt-0.5">View and manage evaluations</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
<Link
|
||||||
<Button variant="outline" className="justify-start h-auto py-3" asChild>
|
href="/jury/compare"
|
||||||
<Link href="/jury/compare">
|
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"
|
||||||
<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-medium">Compare Projects</p>
|
<p className="font-semibold text-sm group-hover:text-brand-teal transition-colors">Compare Projects</p>
|
||||||
<p className="text-xs text-muted-foreground">Side-by-side comparison</p>
|
<p className="text-xs text-muted-foreground mt-0.5">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 && (
|
||||||
<Card>
|
<AnimatedCard index={8}>
|
||||||
|
<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>
|
<CardDescription className="mt-0.5">
|
||||||
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 }) => {
|
||||||
|
|
@ -432,32 +497,39 @@ async function JuryDashboardContent() {
|
||||||
<div
|
<div
|
||||||
key={round.id}
|
key={round.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg border p-4 space-y-3 transition-all hover:-translate-y-0.5 hover:shadow-md',
|
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||||
isUrgent && 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20'
|
isUrgent
|
||||||
|
? '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-medium">{round.name}</h3>
|
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{round.name}</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{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="default">Active</Badge>
|
<Badge variant="info">Active</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5">
|
||||||
<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-medium tabular-nums">
|
<span className="font-semibold tabular-nums">
|
||||||
{roundCompleted}/{roundTotal}
|
{roundCompleted}/{roundTotal}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={roundProgress} className="h-2" />
|
<div className="relative h-2.5 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: `${roundProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{deadline && (
|
{deadline && (
|
||||||
|
|
@ -474,7 +546,7 @@ async function JuryDashboardContent() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button asChild size="sm" className="w-full">
|
<Button asChild size="sm" className="w-full bg-brand-blue hover:bg-brand-blue-light shadow-sm">
|
||||||
<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" />
|
||||||
|
|
@ -485,65 +557,84 @@ 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-8 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-10 text-center">
|
||||||
<div className="rounded-full bg-muted p-3 mb-3">
|
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-3 dark:bg-brand-teal/20">
|
||||||
<Clock className="h-6 w-6 text-muted-foreground" />
|
<Clock className="h-7 w-7 text-brand-teal/70" />
|
||||||
</div>
|
</div>
|
||||||
<p className="font-medium">No active voting rounds</p>
|
<p className="font-semibold text-sm">No active voting rounds</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1 max-w-[220px]">
|
||||||
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-3">
|
<CardContent className="space-y-4">
|
||||||
{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-1.5">
|
<div key={round.id} className="space-y-2">
|
||||||
<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>
|
||||||
<span className="text-muted-foreground tabular-nums shrink-0 ml-2">
|
<div className="flex items-baseline gap-1 shrink-0 ml-2">
|
||||||
{done}/{total} ({pct}%)
|
<span className="font-bold tabular-nums text-brand-blue dark:text-brand-teal">{pct}%</span>
|
||||||
</span>
|
<span className="text-xs text-muted-foreground">({done}/{total})</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 && (
|
||||||
<Card>
|
<AnimatedCard index={1}>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
<Card className="overflow-hidden">
|
||||||
<div className="rounded-full bg-muted p-4 mb-4">
|
<div className="h-1 w-full bg-gradient-to-r from-brand-teal/40 via-brand-blue/40 to-brand-teal/40" />
|
||||||
<ClipboardList className="h-8 w-8 text-muted-foreground" />
|
<CardContent className="flex flex-col items-center justify-center py-14 text-center">
|
||||||
|
<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-medium">No assignments yet</p>
|
<p className="text-lg font-semibold">No assignments yet</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
|
<p className="text-sm text-muted-foreground mt-1.5 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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
@ -552,34 +643,42 @@ 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}>
|
<Card key={i} className="border-l-4 border-l-muted">
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||||
<Skeleton className="h-10 w-10 rounded-full" />
|
<Skeleton className="h-11 w-11 rounded-xl" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-6 w-12" />
|
<Skeleton className="h-7 w-12" />
|
||||||
<Skeleton className="h-4 w-20" />
|
<Skeleton className="h-4 w-24" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
{/* Progress bar skeleton */}
|
||||||
<CardContent className="py-4">
|
<Card className="overflow-hidden">
|
||||||
<Skeleton className="h-2.5 w-full" />
|
<div className="h-1 w-full bg-muted" />
|
||||||
|
<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>
|
<CardHeader className="pb-3">
|
||||||
<Skeleton className="h-5 w-32" />
|
<Skeleton className="h-5 w-40" />
|
||||||
</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">
|
<div key={i} className="flex items-center justify-between py-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<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>
|
||||||
|
|
@ -589,13 +688,27 @@ function DashboardSkeleton() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-5">
|
<div className="lg:col-span-5 space-y-6">
|
||||||
<Card>
|
<Card className="overflow-hidden">
|
||||||
<CardHeader>
|
<div className="h-1 w-full bg-muted" />
|
||||||
<Skeleton className="h-5 w-40" />
|
<CardHeader className="pb-3">
|
||||||
|
<Skeleton className="h-5 w-44" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<Skeleton className="h-24 w-full rounded-lg" />
|
<Skeleton className="h-28 w-full rounded-xl" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<Skeleton className="h-5 w-36" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{[...Array(2)].map((_, i) => (
|
||||||
|
<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>
|
||||||
|
|
@ -610,14 +723,17 @@ export default async function JuryDashboardPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div className="relative">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<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" />
|
||||||
|
<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">
|
<p className="text-muted-foreground mt-0.5">
|
||||||
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,346 +1,12 @@
|
||||||
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 { prisma } from '@/lib/prisma'
|
import { ObserverDashboardContent } from '@/components/observer/observer-dashboard-content'
|
||||||
|
|
||||||
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 (
|
return <ObserverDashboardContent userName={session?.user?.name || undefined} />
|
||||||
<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,9 +38,7 @@ 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,
|
||||||
|
|
@ -53,6 +51,7 @@ 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 })
|
||||||
|
|
@ -608,14 +607,13 @@ export default function ObserverReportsPage() {
|
||||||
Diversity
|
Diversity
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<Button
|
{selectedRoundId && (
|
||||||
variant="outline"
|
<ExportPdfButton
|
||||||
size="sm"
|
roundId={selectedRoundId}
|
||||||
onClick={() => window.print()}
|
roundName={rounds.find((r) => r.id === selectedRoundId)?.name}
|
||||||
>
|
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: 198 85% 18%;
|
--foreground: 220 13% 18%;
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 198 85% 18%;
|
--card-foreground: 220 13% 18%;
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 198 85% 18%;
|
--popover-foreground: 220 13% 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: 198 85% 18%;
|
--secondary-foreground: 220 13% 18%;
|
||||||
|
|
||||||
--muted: 30 6% 96%;
|
--muted: 30 6% 96%;
|
||||||
--muted-foreground: 30 8% 38%;
|
--muted-foreground: 220 8% 46%;
|
||||||
|
|
||||||
/* Accent - MOPC Teal */
|
/* Accent - Light teal tint for hover states */
|
||||||
--accent: 194 25% 44%;
|
--accent: 194 30% 94%;
|
||||||
--accent-foreground: 0 0% 100%;
|
--accent-foreground: 220 13% 18%;
|
||||||
|
|
||||||
--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: 198 85% 8%;
|
--background: 220 15% 8%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
|
|
||||||
--card: 198 85% 10%;
|
--card: 220 15% 10%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--popover: 198 85% 10%;
|
--popover: 220 15% 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: 198 30% 18%;
|
--secondary: 220 15% 18%;
|
||||||
--secondary-foreground: 0 0% 98%;
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--muted: 198 30% 18%;
|
--muted: 220 15% 18%;
|
||||||
--muted-foreground: 0 0% 64%;
|
--muted-foreground: 0 0% 64%;
|
||||||
|
|
||||||
--accent: 194 25% 50%;
|
--accent: 194 20% 18%;
|
||||||
--accent-foreground: 0 0% 100%;
|
--accent-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--destructive: 0 84% 55%;
|
--destructive: 0 84% 55%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--border: 198 30% 22%;
|
--border: 220 15% 22%;
|
||||||
--input: 198 30% 22%;
|
--input: 220 15% 22%;
|
||||||
--ring: 354 90% 50%;
|
--ring: 354 90% 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,108 +5,23 @@ 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)
|
||||||
|
|
||||||
|
|
@ -117,6 +32,8 @@ 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) {
|
||||||
|
|
@ -124,20 +41,113 @@ export function PdfReportGenerator({ roundId, sections }: PdfReportProps) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = buildReportHtml(result.data as Record<string, unknown>)
|
const data = result.data as Record<string, unknown>
|
||||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
const rName = String(data.roundName || 'Report')
|
||||||
const url = URL.createObjectURL(blob)
|
const pName = String(data.programName || '')
|
||||||
const newWindow = window.open(url, '_blank')
|
|
||||||
if (!newWindow) {
|
// 1. Create document
|
||||||
toast.error('Pop-up blocked. Please allow pop-ups and try again.')
|
const doc = await createReportDocument()
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
return
|
// 2. Cover page
|
||||||
|
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
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 5000)
|
// 4. Rankings
|
||||||
toast.success('Report generated. Use the Print button or Ctrl+P to save as PDF.')
|
const rankings = data.rankings as Array<Record<string, unknown>> | undefined
|
||||||
} catch {
|
if (rankings && rankings.length > 0) {
|
||||||
toast.error('Failed to generate report')
|
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
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -213,17 +213,17 @@ function FilteringSettings({
|
||||||
<Input
|
<Input
|
||||||
id="minReviews"
|
id="minReviews"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="0"
|
||||||
value={settings.autoEliminationMinReviews}
|
value={settings.autoEliminationMinReviews}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange({
|
onChange({
|
||||||
...settings,
|
...settings,
|
||||||
autoEliminationMinReviews: parseInt(e.target.value) || 1,
|
autoEliminationMinReviews: parseInt(e.target.value) || 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Min reviews before auto-elimination applies
|
Min reviews before auto-elimination applies (0 for AI-only filtering)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,476 @@
|
||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -132,13 +132,16 @@ export function CsvExportDialog({
|
||||||
),
|
),
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
const blob = new Blob(['\ufeff' + 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()
|
||||||
URL.revokeObjectURL(url)
|
document.body.removeChild(link)
|
||||||
|
// Delay revoking to ensure download starts before URL is invalidated
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
'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 { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, 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,9 +140,11 @@ 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 =
|
||||||
|
|
@ -151,6 +153,8 @@ 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'
|
||||||
|
|
@ -250,17 +254,86 @@ export function NotificationBell() {
|
||||||
onSuccess: () => refetch(),
|
onSuccess: () => refetch(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-mark all notifications as read when popover opens
|
const markBatchAsReadMutation = trpc.notification.markBatchAsRead.useMutation({
|
||||||
useEffect(() => {
|
onSuccess: () => refetch(),
|
||||||
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>
|
||||||
|
|
@ -339,6 +412,7 @@ 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,6 +25,7 @@ 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' },
|
||||||
|
|
@ -38,7 +39,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.replace(/_/g, ' ')
|
const label = status === 'NONE' ? 'NOT INVITED' : 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 from INVITED to ACTIVE on first login
|
// Update status to ACTIVE on first login (from NONE or INVITED)
|
||||||
if (dbUser?.status === 'INVITED') {
|
if (dbUser?.status === 'INVITED' || dbUser?.status === 'NONE') {
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { email: user.email! },
|
where: { email: user.email! },
|
||||||
data: { status: 'ACTIVE' },
|
data: { status: 'ACTIVE' },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,422 @@
|
||||||
|
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,4 +634,157 @@ 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: 'INVITED',
|
status: 'NONE',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: 'INVITED',
|
status: 'NONE',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,23 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ export type DocumentCheckConfig = {
|
||||||
|
|
||||||
export type AIScreeningConfig = {
|
export type AIScreeningConfig = {
|
||||||
criteriaText: string
|
criteriaText: string
|
||||||
action: 'FLAG' // AI screening always flags for human review
|
action: 'PASS' | 'REJECT' | 'FLAG' // REJECT = auto-filter-out, FLAG = flag 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,16 +607,21 @@ 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: 'FLAG',
|
action: aiAction,
|
||||||
reasoning: screening.reasoning,
|
reasoning: screening.reasoning,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!passed) hasFlagged = true
|
if (!passed) {
|
||||||
|
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: 3,
|
autoEliminationMinReviews: 0,
|
||||||
targetAdvancing: 60,
|
targetAdvancing: 60,
|
||||||
showAverageScore: true,
|
showAverageScore: true,
|
||||||
showRanking: true,
|
showRanking: true,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue