Observer dashboard extraction, PDF reports, jury UX overhaul, and miscellaneous improvements
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:
Matt 2026-02-10 23:08:00 +01:00
parent 5c8d22ac11
commit d787a24921
31 changed files with 2565 additions and 930 deletions

223
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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');

View File

@ -28,6 +28,7 @@ enum UserRole {
} }
enum UserStatus { enum UserStatus {
NONE
INVITED INVITED
ACTIVE ACTIVE
SUSPENDED SUSPENDED

View File

@ -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,

View File

@ -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>

View File

@ -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>

View File

@ -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">

View File

@ -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

View File

@ -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()

View File

@ -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>

View File

@ -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>

View File

@ -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} &middot; {round.program.year} {round.program.name} &middot; {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&apos;ll see your project assignments here once they&apos;re assigned to you by an administrator. You&apos;ll see your project assignments here once they&apos;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&apos;s an overview of your evaluation progress Here&apos;s an overview of your evaluation progress
</p> </p>
</div> </div>
</div>
{/* Content */} {/* Content */}
<Suspense fallback={<DashboardSkeleton />}> <Suspense fallback={<DashboardSkeleton />}>

View File

@ -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>
)
} }

View File

@ -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">

View File

@ -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%;
} }
} }

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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)
} }

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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)
} }

View File

@ -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>
)
}

View File

@ -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 })

View File

@ -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

View File

@ -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' },

422
src/lib/pdf-generator.ts Normal file
View File

@ -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'
}

View File

@ -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),
}
}),
}) })

View File

@ -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',
}, },
}) })
} }

View File

@ -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',
}, },
}) })
} }

View File

@ -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
*/ */

View File

@ -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
}
} }
} }

View File

@ -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,