From d787a2492111580aa8cc7614f8a553d0acbd5aa0 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 10 Feb 2026 23:08:00 +0100 Subject: [PATCH] Observer dashboard extraction, PDF reports, jury UX overhaul, and miscellaneous improvements - 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 --- package-lock.json | 223 ++++++ package.json | 3 + .../20260130000000_init/migration.sql | 2 +- prisma/schema.prisma | 1 + prisma/seed-candidatures.ts | 4 +- src/app/(admin)/admin/page.tsx | 4 +- src/app/(admin)/admin/projects/new/page.tsx | 348 +++++++-- src/app/(admin)/admin/reports/page.tsx | 47 +- .../(admin)/admin/rounds/[id]/edit/page.tsx | 54 +- .../rounds/[id]/filtering/results/page.tsx | 2 + .../rounds/[id]/filtering/rules/page.tsx | 21 +- src/app/(admin)/admin/rounds/new/page.tsx | 50 +- src/app/(jury)/jury/page.tsx | 732 ++++++++++-------- src/app/(observer)/observer/page.tsx | 338 +------- src/app/(observer)/observer/reports/page.tsx | 18 +- src/app/globals.css | 34 +- src/components/admin/pdf-report.tsx | 228 +++--- src/components/forms/round-type-settings.tsx | 6 +- .../observer/observer-dashboard-content.tsx | 476 ++++++++++++ src/components/shared/csv-export-dialog.tsx | 7 +- src/components/shared/export-pdf-button.tsx | 191 +++++ src/components/shared/notification-bell.tsx | 90 ++- src/components/shared/status-badge.tsx | 3 +- src/lib/auth.ts | 4 +- src/lib/pdf-generator.ts | 422 ++++++++++ src/server/routers/analytics.ts | 153 ++++ src/server/routers/applicant.ts | 2 +- src/server/routers/application.ts | 2 +- src/server/routers/notification.ts | 17 + src/server/services/ai-filtering.ts | 11 +- src/types/round-settings.ts | 2 +- 31 files changed, 2565 insertions(+), 930 deletions(-) create mode 100644 src/components/observer/observer-dashboard-content.tsx create mode 100644 src/components/shared/export-pdf-button.tsx create mode 100644 src/lib/pdf-generator.ts diff --git a/package-lock.json b/package-lock.json index 239362a..460a477 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,9 @@ "clsx": "^2.1.1", "cmdk": "^1.0.4", "date-fns": "^4.1.0", + "html2canvas": "^1.4.1", + "jspdf": "^4.1.0", + "jspdf-autotable": "^5.0.7", "leaflet": "^1.9.4", "lucide-react": "^0.563.0", "minio": "^8.0.2", @@ -5107,6 +5110,12 @@ "@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": { "version": "5.5.2", "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", @@ -5117,6 +5126,13 @@ "@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": { "version": "19.2.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", @@ -5135,6 +5151,13 @@ "@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": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -6131,6 +6154,15 @@ "dev": true, "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": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", @@ -6294,6 +6326,26 @@ ], "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -6517,6 +6569,18 @@ "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": { "version": "1.6.12", "resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.6.12.tgz", @@ -6544,6 +6608,15 @@ "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": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -6924,6 +6997,16 @@ "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": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -7800,6 +7883,17 @@ "dev": true, "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": { "version": "4.5.3", "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": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -8574,6 +8674,19 @@ "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": { "version": "4.8.2", "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.2.tgz", @@ -8699,6 +8812,12 @@ "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": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -9257,6 +9376,32 @@ "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": { "version": "3.3.5", "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" } }, + "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": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", @@ -11346,6 +11497,13 @@ "devOptional": true, "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11945,6 +12103,16 @@ ], "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": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", @@ -12289,6 +12457,13 @@ "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": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -12509,6 +12684,16 @@ "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": { "version": "4.57.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", @@ -12937,6 +13122,16 @@ "dev": true, "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": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -13214,6 +13409,16 @@ "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": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -13259,6 +13464,15 @@ "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", @@ -13876,6 +14090,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "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": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/package.json b/package.json index 4503f90..4632519 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,9 @@ "clsx": "^2.1.1", "cmdk": "^1.0.4", "date-fns": "^4.1.0", + "html2canvas": "^1.4.1", + "jspdf": "^4.1.0", + "jspdf-autotable": "^5.0.7", "leaflet": "^1.9.4", "lucide-react": "^0.563.0", "minio": "^8.0.2", diff --git a/prisma/migrations/20260130000000_init/migration.sql b/prisma/migrations/20260130000000_init/migration.sql index 272572e..8fc05f3 100644 --- a/prisma/migrations/20260130000000_init/migration.sql +++ b/prisma/migrations/20260130000000_init/migration.sql @@ -5,7 +5,7 @@ CREATE SCHEMA IF NOT EXISTS "public"; CREATE TYPE "UserRole" AS ENUM ('SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT'); -- CreateEnum -CREATE TYPE "UserStatus" AS ENUM ('INVITED', 'ACTIVE', 'SUSPENDED'); +CREATE TYPE "UserStatus" AS ENUM ('NONE', 'INVITED', 'ACTIVE', 'SUSPENDED'); -- CreateEnum CREATE TYPE "ProgramStatus" AS ENUM ('DRAFT', 'ACTIVE', 'ARCHIVED'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ac6f847..f238fd7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,6 +28,7 @@ enum UserRole { } enum UserStatus { + NONE INVITED ACTIVE SUSPENDED diff --git a/prisma/seed-candidatures.ts b/prisma/seed-candidatures.ts index e0a22bc..1bbfb43 100644 --- a/prisma/seed-candidatures.ts +++ b/prisma/seed-candidatures.ts @@ -346,7 +346,7 @@ async function main() { email, name: row['Full name']?.trim() || 'Unknown', role: 'APPLICANT', - status: 'INVITED', + status: 'NONE', phoneNumber: row['Téléphone']?.trim() || null, }, }) @@ -424,7 +424,7 @@ async function main() { email: memberEmail, name: member.name, role: 'APPLICANT', - status: 'INVITED', + status: 'NONE', metadataJson: { isPendingEmailVerification: true, originalName: member.name, diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx index 521780d..37ae121 100644 --- a/src/app/(admin)/admin/page.tsx +++ b/src/app/(admin)/admin/page.tsx @@ -142,7 +142,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) { prisma.user.count({ where: { role: 'JURY_MEMBER', - status: { in: ['ACTIVE', 'INVITED'] }, + status: { in: ['ACTIVE', 'INVITED', 'NONE'] }, assignments: { some: { round: { programId: editionId } } }, }, }), @@ -751,7 +751,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
diff --git a/src/app/(admin)/admin/projects/new/page.tsx b/src/app/(admin)/admin/projects/new/page.tsx index bd45e73..3dcb279 100644 --- a/src/app/(admin)/admin/projects/new/page.tsx +++ b/src/app/(admin)/admin/projects/new/page.tsx @@ -25,7 +25,10 @@ import { import { Skeleton } from '@/components/ui/skeleton' import { TagInput } from '@/components/shared/tag-input' 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 { ArrowLeft, @@ -33,8 +36,52 @@ import { Loader2, AlertCircle, FolderPlus, + Users, + UserPlus, + Trash2, + Mail, } 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 = { + 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 = { + 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 = { + 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 = { LEAD: 0, MEMBER: 1, ADVISOR: 2 } + function NewProjectPageContent() { const router = useRouter() const searchParams = useSearchParams() @@ -49,15 +96,20 @@ function NewProjectPageContent() { const [teamName, setTeamName] = useState('') const [description, setDescription] = useState('') const [tags, setTags] = useState([]) - const [contactEmail, setContactEmail] = useState('') - const [contactName, setContactName] = useState('') - const [contactPhone, setContactPhone] = useState('') const [country, setCountry] = useState('') - const [city, setCity] = useState('') const [institution, setInstitution] = useState('') const [competitionCategory, setCompetitionCategory] = useState('') const [oceanIssue, setOceanIssue] = useState('') + // Team members state + const [teamMembers, setTeamMembers] = useState([]) + 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 const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({ status: 'ACTIVE', @@ -92,6 +144,66 @@ function NewProjectPageContent() { const categoryOptions = wizardConfig?.competitionCategories || [] 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 = () => { if (!title.trim()) { toast.error('Please enter a project title') @@ -113,10 +225,16 @@ function NewProjectPageContent() { 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, institution: institution.trim() || undefined, - contactPhone: contactPhone.trim() || undefined, - contactEmail: contactEmail.trim() || undefined, - contactName: contactName.trim() || undefined, - city: city.trim() || undefined, + teamMembers: teamMembers.length > 0 + ? teamMembers.map(({ name, email, role, title: t, phone, sendInvite }) => ({ + name, + email, + role, + title: t || undefined, + phone: phone || undefined, + sendInvite, + })) + : undefined, }) } @@ -304,47 +422,6 @@ function NewProjectPageContent() { placeholder="e.g., University of Monaco" />
- - - - {/* Contact Info */} - - - Contact Information - - Contact details for the project team - - - -
- - setContactName(e.target.value)} - placeholder="e.g., John Smith" - /> -
- -
- - setContactEmail(e.target.value)} - placeholder="e.g., john@example.com" - /> -
- -
- - -
@@ -353,16 +430,171 @@ function NewProjectPageContent() { onChange={setCountry} />
+
+
-
- - setCity(e.target.value)} - placeholder="e.g., Monaco" - /> + {/* Team Members */} + + +
+ Team Members + {teamMembers.length} / 10
+ + Add team members and optionally invite them to the platform + +
+ + {teamMembers.length === 0 ? ( +
+ +

No team members yet

+

+ Add members below to link them to this project +

+
+ ) : ( +
+ {sortedMembers.map((member) => ( +
+ + + {getInitials(member.name)} + + +
+
+ {member.name} + + {ROLE_LABELS[member.role]} + + {member.title && ( + + {member.title} + + )} +
+

{member.email}

+
+
+ + +
+
+ ))} +
+ )} + + {teamMembers.length > 0 && teamMembers.length < 10 && } + + {/* Add member form */} + {teamMembers.length < 10 && ( +
+

+ + Add Member +

+
+
+ + setMemberName(e.target.value)} + placeholder="Full name" + className="h-9" + /> +
+
+ + setMemberEmail(e.target.value)} + placeholder="email@example.com" + className="h-9" + /> +
+
+
+
+ + +
+
+ + setMemberTitle(e.target.value)} + placeholder="e.g., CEO, CTO" + className="h-9" + /> +
+
+
+
+ setMemberSendInvite(checked === true)} + /> + +
+ +
+
+ )} + + {teamMembers.length >= 10 && ( +

+ Maximum of 10 team members reached +

+ )}
diff --git a/src/app/(admin)/admin/reports/page.tsx b/src/app/(admin)/admin/reports/page.tsx index 65dfc6d..83b976f 100644 --- a/src/app/(admin)/admin/reports/page.tsx +++ b/src/app/(admin)/admin/reports/page.tsx @@ -37,12 +37,10 @@ import { Users, ClipboardList, CheckCircle2, - PieChart, TrendingUp, GitCompare, UserCheck, Globe, - Printer, } from 'lucide-react' import { formatDateOnly } from '@/lib/utils' import { @@ -57,6 +55,7 @@ import { JurorConsistencyChart, DiversityMetricsChart, } from '@/components/charts' +import { ExportPdfButton } from '@/components/shared/export-pdf-button' function ReportsOverview() { const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true }) @@ -631,6 +630,19 @@ function DiversityTab() { } export default function ReportsPage() { + const [pdfRoundId, setPdfRoundId] = useState(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 (
{/* Header */} @@ -666,16 +678,27 @@ export default function ReportsPage() { Diversity - +
+ + {pdfRoundId && ( + + )} +
diff --git a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx index 864e242..9b2cd0b 100644 --- a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx @@ -72,7 +72,7 @@ interface PageProps { const updateRoundSchema = z .object({ 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), maxAssignmentsPerJuror: z.number().int().min(1).max(100), votingStartAt: z.date().nullable().optional(), @@ -206,7 +206,7 @@ function EditRoundContent({ roundId }: { roundId: string }) { await updateRound.mutateAsync({ id: roundId, name: data.name, - requiredReviews: data.requiredReviews, + requiredReviews: roundType === 'FILTERING' ? 0 : data.requiredReviews, minAssignmentsPerJuror: data.minAssignmentsPerJuror, maxAssignmentsPerJuror: data.maxAssignmentsPerJuror, roundType, @@ -301,30 +301,32 @@ function EditRoundContent({ roundId }: { roundId: string }) { )} /> - ( - - Required Reviews per Project - - - field.onChange(parseInt(e.target.value) || 1) - } - /> - - - Minimum number of evaluations each project should receive - - - - )} - /> + {roundType !== 'FILTERING' && ( + ( + + Required Reviews per Project + + + field.onChange(parseInt(e.target.value) || 1) + } + /> + + + Minimum number of evaluations each project should receive + + + + )} + /> + )}
('REJECT') const [aiBatchSize, setAiBatchSize] = useState('20') const [aiParallelBatches, setAiParallelBatches] = useState('1') @@ -144,7 +145,7 @@ export default function FilteringRulesPage({ } else if (newRuleType === 'AI_SCREENING') { configJson = { criteriaText, - action: 'FLAG', + action: aiAction, batchSize: parseInt(aiBatchSize) || 20, parallelBatches: parseInt(aiParallelBatches) || 1, } @@ -418,9 +419,23 @@ export default function FilteringRulesPage({ placeholder="Describe the criteria for AI to evaluate projects against..." rows={4} /> +
+ +
+ +

- AI screening always flags projects for human review, never - auto-rejects. + {aiAction === 'REJECT' + ? 'Projects that don\'t meet criteria will be automatically filtered out.' + : 'Projects that don\'t meet criteria will be flagged for human review.'}

diff --git a/src/app/(admin)/admin/rounds/new/page.tsx b/src/app/(admin)/admin/rounds/new/page.tsx index 6f73536..0246940 100644 --- a/src/app/(admin)/admin/rounds/new/page.tsx +++ b/src/app/(admin)/admin/rounds/new/page.tsx @@ -50,7 +50,7 @@ const TEAM_NOTIFICATION_OPTIONS = [ const createRoundSchema = z.object({ programId: z.string().min(1, 'Please select a program'), 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(), votingEndAt: z.date().nullable().optional(), }).refine((data) => { @@ -128,7 +128,7 @@ function CreateRoundContent() { programId: data.programId, name: data.name, roundType, - requiredReviews: data.requiredReviews, + requiredReviews: roundType === 'FILTERING' ? 0 : data.requiredReviews, settingsJson: roundSettings, votingStartAt: data.votingStartAt ?? undefined, votingEndAt: data.votingEndAt ?? undefined, @@ -291,28 +291,30 @@ function CreateRoundContent() { )} /> - ( - - Required Reviews per Project - - field.onChange(parseInt(e.target.value) || 1)} - /> - - - Minimum number of evaluations each project should receive - - - - )} - /> + {roundType !== 'FILTERING' && ( + ( + + Required Reviews per Project + + field.onChange(parseInt(e.target.value) || 1)} + /> + + + Minimum number of evaluations each project should receive + + + + )} + /> + )} diff --git a/src/app/(jury)/jury/page.tsx b/src/app/(jury)/jury/page.tsx index 7774205..dea7581 100644 --- a/src/app/(jury)/jury/page.tsx +++ b/src/app/(jury)/jury/page.tsx @@ -15,22 +15,21 @@ import { } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { Progress } from '@/components/ui/progress' import { Skeleton } from '@/components/ui/skeleton' -import { Separator } from '@/components/ui/separator' import { ClipboardList, CheckCircle2, Clock, - AlertCircle, ArrowRight, GitCompare, Zap, BarChart3, Target, + Waves, } from 'lucide-react' import { formatDateOnly } from '@/lib/utils' import { CountdownTimer } from '@/components/shared/countdown-timer' +import { AnimatedCard } from '@/components/shared/animated-container' import { cn } from '@/lib/utils' function getGreeting(): string { @@ -186,29 +185,33 @@ async function JuryDashboardContent() { label: 'Total Assignments', value: totalAssignments, 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', }, { label: 'Completed', value: completedAssignments, icon: CheckCircle2, - iconBg: 'bg-green-100 dark:bg-green-900/30', - iconColor: 'text-green-600 dark:text-green-400', + accentColor: 'border-l-emerald-500', + iconBg: 'bg-emerald-50 dark:bg-emerald-950/40', + iconColor: 'text-emerald-600 dark:text-emerald-400', }, { label: 'In Progress', value: inProgressAssignments, 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', }, { label: 'Pending', value: pendingAssignments, icon: Target, - iconBg: 'bg-slate-100 dark:bg-slate-800', - iconColor: 'text-slate-500', + accentColor: 'border-l-slate-400', + iconBg: 'bg-slate-50 dark:bg-slate-800/50', + iconColor: 'text-slate-500 dark:text-slate-400', }, ] @@ -216,334 +219,422 @@ async function JuryDashboardContent() { <> {/* Hero CTA - Jump to next evaluation */} {nextUnevaluated && activeRemaining > 0 && ( - - -
-
- -
-
-

- {activeRemaining} evaluation{activeRemaining > 1 ? 's' : ''} remaining -

-

- Continue with "{nextUnevaluated.project.title}" -

-
+ + +
+ +
+
+ +
+
+

+ {activeRemaining} evaluation{activeRemaining > 1 ? 's' : ''} remaining +

+

+ Continue with "{nextUnevaluated.project.title}" +

+
+
+ +
- - -
+ +
)} {/* Stats */}
- {stats.map((stat) => ( - - -
- -
-
-

{stat.value}

-

{stat.label}

-
-
-
+ {stats.map((stat, i) => ( + + + +
+ +
+
+

{stat.value}

+

{stat.label}

+
+
+
+
))}
{/* Overall Progress */} - - -
-
- - Overall Completion + + +
+ +
+
+
+ +
+ Overall Completion +
+
+ + {completionRate.toFixed(0)}% + + + ({completedAssignments}/{totalAssignments}) + +
- - {completedAssignments}/{totalAssignments} ({completionRate.toFixed(0)}%) - -
- - -
+
+
+
+ + + - {/* Main content — two column layout */} + {/* Main content -- two column layout */}
{/* Left column */}
{/* Recent Assignments */} - - -
- My Assignments - -
-
- - {recentAssignments.length > 0 ? ( -
- {recentAssignments.map((assignment) => { - const evaluation = assignment.evaluation - const isCompleted = evaluation?.status === 'SUBMITTED' - const isDraft = evaluation?.status === 'DRAFT' - const isVotingOpen = - assignment.round.status === 'ACTIVE' && - assignment.round.votingStartAt && - assignment.round.votingEndAt && - new Date(assignment.round.votingStartAt) <= now && - new Date(assignment.round.votingEndAt) >= now + + + +
+
+
+ +
+ My Assignments +
+ +
+
+ + {recentAssignments.length > 0 ? ( +
+ {recentAssignments.map((assignment, idx) => { + const evaluation = assignment.evaluation + const isCompleted = evaluation?.status === 'SUBMITTED' + const isDraft = evaluation?.status === 'DRAFT' + const isVotingOpen = + assignment.round.status === 'ACTIVE' && + assignment.round.votingStartAt && + assignment.round.votingEndAt && + new Date(assignment.round.votingStartAt) <= now && + new Date(assignment.round.votingEndAt) >= now - return ( -
- -

- {assignment.project.title} -

-
- - {assignment.project.teamName} - - - {assignment.round.name} - + +

+ {assignment.project.title} +

+
+ + {assignment.project.teamName} + + + {assignment.round.name} + +
+ +
+ {isCompleted ? ( + + + Done + + ) : isDraft ? ( + + + Draft + + ) : ( + Pending + )} + {isCompleted ? ( + + ) : isVotingOpen ? ( + + ) : ( + + )}
- -
- {isCompleted ? ( - - - Done - - ) : isDraft ? ( - - - Draft - - ) : ( - Pending - )} - {isCompleted ? ( - - ) : isVotingOpen ? ( - - ) : ( - - )}
-
- ) - })} -
- ) : ( -
- -

- No assignments yet -

-
- )} - - + ) + })} +
+ ) : ( +
+
+ +
+

+ No assignments yet +

+

+ Assignments will appear here once an administrator assigns projects to you. +

+
+ )} +
+
+
{/* Quick Actions */} - - - Quick Actions - - -
- - -
-
-
+
+
+
+
{/* Right column */}
{/* Active Rounds */} {activeRounds.length > 0 && ( - - - Active Voting Rounds - - Rounds currently open for evaluation - - - - {activeRounds.map(({ round, assignments: roundAssignments }) => { - const roundCompleted = roundAssignments.filter( - (a) => a.evaluation?.status === 'SUBMITTED' - ).length - const roundTotal = roundAssignments.length - const roundProgress = - roundTotal > 0 ? (roundCompleted / roundTotal) * 100 : 0 - const isAlmostDone = roundProgress >= 80 - const deadline = graceByRound.get(round.id) ?? (round.votingEndAt ? new Date(round.votingEndAt) : null) - const isUrgent = deadline && (deadline.getTime() - now.getTime()) < 24 * 60 * 60 * 1000 + + +
+ +
+
+ +
+
+ Active Voting Rounds + + Rounds currently open for evaluation + +
+
+
+ + {activeRounds.map(({ round, assignments: roundAssignments }) => { + const roundCompleted = roundAssignments.filter( + (a) => a.evaluation?.status === 'SUBMITTED' + ).length + const roundTotal = roundAssignments.length + const roundProgress = + roundTotal > 0 ? (roundCompleted / roundTotal) * 100 : 0 + const isAlmostDone = roundProgress >= 80 + const deadline = graceByRound.get(round.id) ?? (round.votingEndAt ? new Date(round.votingEndAt) : null) + const isUrgent = deadline && (deadline.getTime() - now.getTime()) < 24 * 60 * 60 * 1000 - return ( -
-
-
-

{round.name}

-

- {round.program.name} · {round.program.year} -

-
- {isAlmostDone ? ( - Almost done - ) : ( - Active + return ( +
- -
-
- Progress - - {roundCompleted}/{roundTotal} - -
- -
- - {deadline && ( -
- - {round.votingEndAt && ( - - ({formatDateOnly(round.votingEndAt)}) - + > +
+
+

{round.name}

+

+ {round.program.name} · {round.program.year} +

+
+ {isAlmostDone ? ( + Almost done + ) : ( + Active )}
- )} - -
- ) - })} - - +
+
+ Progress + + {roundCompleted}/{roundTotal} + +
+
+
+
+
+ + {deadline && ( +
+ + {round.votingEndAt && ( + + ({formatDateOnly(round.votingEndAt)}) + + )} +
+ )} + + +
+ ) + })} + + + )} {/* No active rounds */} {activeRounds.length === 0 && totalAssignments > 0 && ( - - -
- -
-

No active voting rounds

-

- Check back later when a voting window opens -

-
-
+ + + +
+ +
+

No active voting rounds

+

+ Check back later when a voting window opens +

+
+
+
)} {/* Completion Summary by Round */} {Object.keys(assignmentsByRound).length > 0 && ( - - - Round Summary - - - {Object.values(assignmentsByRound).map(({ round, assignments: roundAssignments }) => { - const done = roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length - const total = roundAssignments.length - const pct = total > 0 ? Math.round((done / total) * 100) : 0 - return ( -
-
- {round.name} - - {done}/{total} ({pct}%) - -
- + + + +
+
+
- ) - })} - - + Round Summary +
+
+ + {Object.values(assignmentsByRound).map(({ round, assignments: roundAssignments }) => { + const done = roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length + const total = roundAssignments.length + const pct = total > 0 ? Math.round((done / total) * 100) : 0 + return ( +
+
+ {round.name} +
+ {pct}% + ({done}/{total}) +
+
+
+
+
+
+ ) + })} + + + )}
{/* No assignments at all */} {totalAssignments === 0 && ( - - -
- -
-

No assignments yet

-

- You'll see your project assignments here once they're assigned to you by an administrator. -

-
-
+ + +
+ +
+ +
+

No assignments yet

+

+ You'll see your project assignments here once they're assigned to you by an administrator. +

+
+ + )} ) @@ -552,34 +643,42 @@ async function JuryDashboardContent() { function DashboardSkeleton() { return ( <> + {/* Stats skeleton */}
{[...Array(4)].map((_, i) => ( - - - + + +
- - + +
))}
- - - + {/* Progress bar skeleton */} + +
+ +
+ + +
+
+ {/* Two-column skeleton */}
- - + + {[...Array(4)].map((_, i) => ( -
-
+
+
@@ -589,13 +688,27 @@ function DashboardSkeleton() {
-
- - - +
+ +
+ + - + + + + + + + + + {[...Array(2)].map((_, i) => ( +
+ + +
+ ))}
@@ -610,13 +723,16 @@ export default async function JuryDashboardPage() { return (
{/* Header */} -
-

- {getGreeting()}, {session?.user?.name || 'Juror'} -

-

- Here's an overview of your evaluation progress -

+
+
+
+

+ {getGreeting()}, {session?.user?.name || 'Juror'} +

+

+ Here's an overview of your evaluation progress +

+
{/* Content */} diff --git a/src/app/(observer)/observer/page.tsx b/src/app/(observer)/observer/page.tsx index eb64891..6b598af 100644 --- a/src/app/(observer)/observer/page.tsx +++ b/src/app/(observer)/observer/page.tsx @@ -1,346 +1,12 @@ import type { Metadata } from 'next' -import { Suspense } from 'react' 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 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 */} -
-
-
- -
-
-
-

Observer Mode

- - Read-Only - -
-

- You have read-only access to view platform statistics and reports. -

-
-
-
- - {/* Stats Grid */} -
- - - Programs - - - -
{programCount}
-

- {activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''} -

-
-
- - - - Projects - - - -
{projectCount}
-

Across all rounds

-
-
- - - - Jury Members - - - -
{jurorCount}
-

Active members

-
-
- - - - Evaluations - - - -
{submittedCount}
-
- -

- {completionRate.toFixed(0)}% completion rate -

-
-
-
-
- - {/* Recent Rounds */} - - - Recent Rounds - Overview of the latest voting rounds - - - {recentRounds.length === 0 ? ( -
- -

- No rounds created yet -

-
- ) : ( -
- {recentRounds.map((round) => ( -
-
-
-

{round.name}

- - {round.status} - -
-

- {round.program.year} Edition -

-
-
-

{round._count.projects} projects

-

- {round._count.assignments} assignments -

-
-
- ))} -
- )} -
-
- - {/* Score Distribution */} - - - Score Distribution - Distribution of global scores across all evaluations - - - {scoreDistribution.length === 0 ? ( -
- -

No completed evaluations yet

-
- ) : ( -
- {scoreDistribution.map((bucket) => ( -
- {bucket.label} -
-
-
- {bucket.count} -
- ))} -
- )} - - - - {/* Jury Completion by Round */} - - - Jury Completion by Round - Evaluation completion rate per round - - - {recentRounds.length === 0 ? ( -
- -

No rounds available

-
- ) : ( -
- {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 ( -
-
-
- {round.name} - {round.status} -
- {percent}% -
- -

{submittedInRound} of {totalAssignments} evaluations submitted

-
- ) - })} -
- )} -
-
- - ) -} - -function DashboardSkeleton() { - return ( - <> - -
- {[...Array(4)].map((_, i) => ( - - - - - - - - - - ))} -
- - - - - - -
- {[...Array(3)].map((_, i) => ( - - ))} -
-
-
- - ) -} export default async function ObserverDashboardPage() { const session = await auth() - return ( -
- {/* Header */} -
-

Dashboard

-

- Welcome, {session?.user?.name || 'Observer'} -

-
- - {/* Content */} - }> - - -
- ) + return } diff --git a/src/app/(observer)/observer/reports/page.tsx b/src/app/(observer)/observer/reports/page.tsx index e92ddd1..028ff0a 100644 --- a/src/app/(observer)/observer/reports/page.tsx +++ b/src/app/(observer)/observer/reports/page.tsx @@ -38,9 +38,7 @@ import { GitCompare, UserCheck, Globe, - Printer, } from 'lucide-react' -import { Button } from '@/components/ui/button' import { formatDateOnly } from '@/lib/utils' import { ScoreDistributionChart, @@ -53,6 +51,7 @@ import { JurorConsistencyChart, DiversityMetricsChart, } from '@/components/charts' +import { ExportPdfButton } from '@/components/shared/export-pdf-button' function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) { const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true }) @@ -608,14 +607,13 @@ export default function ObserverReportsPage() { Diversity - + {selectedRoundId && ( + r.id === selectedRoundId)?.name} + programName={rounds.find((r) => r.id === selectedRoundId)?.programName} + /> + )}
diff --git a/src/app/globals.css b/src/app/globals.css index 9142f49..098db23 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -142,13 +142,13 @@ :root { /* MOPC Brand Colors - mapped to shadcn/ui variables */ --background: 0 0% 99.5%; - --foreground: 198 85% 18%; + --foreground: 220 13% 18%; --card: 0 0% 100%; - --card-foreground: 198 85% 18%; + --card-foreground: 220 13% 18%; --popover: 0 0% 100%; - --popover-foreground: 198 85% 18%; + --popover-foreground: 220 13% 18%; /* Primary - MOPC Red */ --primary: 354 90% 47%; @@ -156,14 +156,14 @@ /* Secondary - Warm gray */ --secondary: 30 6% 96%; - --secondary-foreground: 198 85% 18%; + --secondary-foreground: 220 13% 18%; --muted: 30 6% 96%; - --muted-foreground: 30 8% 38%; + --muted-foreground: 220 8% 46%; - /* Accent - MOPC Teal */ - --accent: 194 25% 44%; - --accent-foreground: 0 0% 100%; + /* Accent - Light teal tint for hover states */ + --accent: 194 30% 94%; + --accent-foreground: 220 13% 18%; --destructive: 0 84% 60%; --destructive-foreground: 0 0% 100%; @@ -181,32 +181,32 @@ } .dark { - --background: 198 85% 8%; + --background: 220 15% 8%; --foreground: 0 0% 98%; - --card: 198 85% 10%; + --card: 220 15% 10%; --card-foreground: 0 0% 98%; - --popover: 198 85% 10%; + --popover: 220 15% 10%; --popover-foreground: 0 0% 98%; --primary: 354 90% 50%; --primary-foreground: 0 0% 100%; - --secondary: 198 30% 18%; + --secondary: 220 15% 18%; --secondary-foreground: 0 0% 98%; - --muted: 198 30% 18%; + --muted: 220 15% 18%; --muted-foreground: 0 0% 64%; - --accent: 194 25% 50%; - --accent-foreground: 0 0% 100%; + --accent: 194 20% 18%; + --accent-foreground: 0 0% 98%; --destructive: 0 84% 55%; --destructive-foreground: 0 0% 100%; - --border: 198 30% 22%; - --input: 198 30% 22%; + --border: 220 15% 22%; + --input: 220 15% 22%; --ring: 354 90% 50%; } } diff --git a/src/components/admin/pdf-report.tsx b/src/components/admin/pdf-report.tsx index a9fa4f0..e842195 100644 --- a/src/components/admin/pdf-report.tsx +++ b/src/components/admin/pdf-report.tsx @@ -5,108 +5,23 @@ 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, + addAllPageFooters, + savePdf, +} from '@/lib/pdf-generator' interface PdfReportProps { roundId: string sections: string[] } -function buildReportHtml(reportData: Record): string { - const parts: string[] = [] - - parts.push(` - Round Report - ${String(reportData.roundName || 'Report')} - - `) - - parts.push(`
- -
`) - - parts.push(`

${escapeHtml(String(reportData.roundName || 'Round Report'))}

`) - parts.push(`

${escapeHtml(String(reportData.programName || ''))}

`) - parts.push(`

Generated on ${new Date().toLocaleString()}

`) - - const summary = reportData.summary as Record | undefined - if (summary) { - parts.push(`

Summary

`) - 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(`
`) - } - - const rankings = reportData.rankings as Array> | undefined - if (rankings && rankings.length > 0) { - parts.push(`

Project Rankings

- - `) - for (const p of rankings) { - parts.push(` - - - - - - `) - } - parts.push(`
#ProjectTeamAvg ScoreEvaluations
${escapeHtml(String(p.rank ?? ''))}${escapeHtml(String(p.title ?? ''))}${escapeHtml(String(p.team ?? ''))}${Number(p.avgScore ?? 0).toFixed(2)}${String(p.evalCount ?? 0)}
`) - } - - const jurorStats = reportData.jurorStats as Array> | undefined - if (jurorStats && jurorStats.length > 0) { - parts.push(`

Juror Statistics

- - `) - for (const j of jurorStats) { - parts.push(` - - - - - - `) - } - parts.push(`
JurorAssignedCompletedCompletion %Avg Score Given
${escapeHtml(String(j.name ?? ''))}${String(j.assigned ?? 0)}${String(j.completed ?? 0)}${Number(j.completionRate ?? 0).toFixed(0)}%${Number(j.avgScore ?? 0).toFixed(2)}
`) - } - - parts.push(``) - return parts.join('') -} - -function escapeHtml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') -} - -function statCard(value: unknown, label: string): string { - return `
${escapeHtml(String(value ?? 0))}
${escapeHtml(label)}
` -} - export function PdfReportGenerator({ roundId, sections }: PdfReportProps) { const [generating, setGenerating] = useState(false) @@ -117,6 +32,8 @@ export function PdfReportGenerator({ roundId, sections }: PdfReportProps) { const handleGenerate = useCallback(async () => { setGenerating(true) + toast.info('Generating PDF report...') + try { const result = await refetch() if (!result.data) { @@ -124,20 +41,113 @@ export function PdfReportGenerator({ roundId, sections }: PdfReportProps) { return } - const html = buildReportHtml(result.data as Record) - const blob = new Blob([html], { type: 'text/html;charset=utf-8' }) - const url = URL.createObjectURL(blob) - const newWindow = window.open(url, '_blank') - if (!newWindow) { - toast.error('Pop-up blocked. Please allow pop-ups and try again.') - URL.revokeObjectURL(url) - return + const data = result.data as Record + const rName = String(data.roundName || 'Report') + const pName = 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 + const summary = data.summary as Record | 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) - toast.success('Report generated. Use the Print button or Ctrl+P to save as PDF.') - } catch { - toast.error('Failed to generate report') + + // 4. Rankings + const rankings = data.rankings as Array> | 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 + const jurorStats = data.jurorStats as Array> | 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> | 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 { setGenerating(false) } diff --git a/src/components/forms/round-type-settings.tsx b/src/components/forms/round-type-settings.tsx index d4fbeb1..1af51ed 100644 --- a/src/components/forms/round-type-settings.tsx +++ b/src/components/forms/round-type-settings.tsx @@ -213,17 +213,17 @@ function FilteringSettings({ onChange({ ...settings, - autoEliminationMinReviews: parseInt(e.target.value) || 1, + autoEliminationMinReviews: parseInt(e.target.value) || 0, }) } />

- Min reviews before auto-elimination applies + Min reviews before auto-elimination applies (0 for AI-only filtering)

diff --git a/src/components/observer/observer-dashboard-content.tsx b/src/components/observer/observer-dashboard-content.tsx new file mode 100644 index 0000000..b6760ab --- /dev/null +++ b/src/components/observer/observer-dashboard-content.tsx @@ -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('all') + const [search, setSearch] = useState('') + const [debouncedSearch, setDebouncedSearch] = useState('') + const [statusFilter, setStatusFilter] = useState('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 ( +
+ {/* Header */} +
+

Dashboard

+

+ Welcome, {userName || 'Observer'} +

+
+ + {/* Observer Notice */} +
+
+
+ +
+
+
+

Observer Mode

+ + Read-Only + +
+

+ You have read-only access to view platform statistics and reports. +

+
+
+
+ + {/* Round Filter */} +
+ + +
+ + {/* Stats Grid */} + {statsLoading ? ( +
+ {[...Array(4)].map((_, i) => ( + + + + + + + + + + ))} +
+ ) : stats ? ( +
+ + + Programs + + + +
{stats.programCount}
+

+ {stats.activeRoundCount} active round{stats.activeRoundCount !== 1 ? 's' : ''} +

+
+
+ + + + Projects + + + +
{stats.projectCount}
+

+ {selectedRoundId !== 'all' ? 'In selected round' : 'Across all rounds'} +

+
+
+ + + + Jury Members + + + +
{stats.jurorCount}
+

Active members

+
+
+ + + + Evaluations + + + +
{stats.submittedEvaluations}
+
+ +

+ {stats.completionRate}% completion rate +

+
+
+
+
+ ) : null} + + {/* Projects Table */} + + + All Projects + + {projectsData ? `${projectsData.total} project${projectsData.total !== 1 ? 's' : ''} found` : 'Loading projects...'} + + + + {/* Search & Filter Bar */} +
+
+ + handleSearchChange(e.target.value)} + className="pl-10" + /> +
+ + +
+ + {projectsLoading ? ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ ) : projectsData && projectsData.projects.length > 0 ? ( + <> + {/* Desktop Table */} +
+ + + + Title + Team + Round + Status + Avg Score + Evaluations + + + + {projectsData.projects.map((project) => ( + + + {project.title} + + {project.teamName || '-'} + + + {project.roundName} + + + + + + + {project.averageScore !== null + ? project.averageScore.toFixed(2) + : '-'} + + + {project.evaluationCount} + + + ))} + +
+
+ + {/* Mobile Cards */} +
+ {projectsData.projects.map((project) => ( + + +
+

{project.title}

+ +
+ {project.teamName && ( +

{project.teamName}

+ )} +
+ + {project.roundName} + +
+ Score: {project.averageScore !== null ? project.averageScore.toFixed(2) : '-'} + {project.evaluationCount} eval{project.evaluationCount !== 1 ? 's' : ''} +
+
+
+
+ ))} +
+ + {/* Pagination */} + {projectsData.totalPages > 1 && ( +
+

+ Page {projectsData.page} of {projectsData.totalPages} +

+
+ + +
+
+ )} + + ) : ( +
+ +

+ {debouncedSearch || statusFilter !== 'all' + ? 'No projects match your filters' + : 'No projects found'} +

+
+ )} +
+
+ + {/* Score Distribution */} + {stats && stats.scoreDistribution.some((b) => b.count > 0) && ( + + + Score Distribution + Distribution of global scores across evaluations + + +
+ {(() => { + 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) => ( +
+ {bucket.label} +
+
0 ? (bucket.count / maxCount) * 100 : 0}%` }} + /> +
+ {bucket.count} +
+ )) + })()} +
+ + + )} + + {/* Recent Rounds */} + {recentRounds.length > 0 && ( + + + Recent Rounds + Overview of the latest voting rounds + + +
+ {recentRounds.map((round) => ( +
+
+
+

{round.name}

+ + {round.status} + +
+

+ {round.programName} +

+
+
+

{round._count?.projects || 0} projects

+

+ {round._count?.assignments || 0} assignments +

+
+
+ ))} +
+
+
+ )} +
+ ) +} diff --git a/src/components/shared/csv-export-dialog.tsx b/src/components/shared/csv-export-dialog.tsx index a22f384..36868dc 100644 --- a/src/components/shared/csv-export-dialog.tsx +++ b/src/components/shared/csv-export-dialog.tsx @@ -132,13 +132,16 @@ export function CsvExportDialog({ ), ].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 link = document.createElement('a') link.href = url link.download = `${filename}-${new Date().toISOString().split('T')[0]}.csv` + document.body.appendChild(link) 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) } diff --git a/src/components/shared/export-pdf-button.tsx b/src/components/shared/export-pdf-button.tsx new file mode 100644 index 0000000..1dc19b6 --- /dev/null +++ b/src/components/shared/export-pdf-button.tsx @@ -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> + 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 + 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 | 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> | 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> | 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> | 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 ( + + ) +} diff --git a/src/components/shared/notification-bell.tsx b/src/components/shared/notification-bell.tsx index e380f5c..ecd7016 100644 --- a/src/components/shared/notification-bell.tsx +++ b/src/components/shared/notification-bell.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import Link from 'next/link' import type { Route } from 'next' import { usePathname } from 'next/navigation' @@ -140,9 +140,11 @@ type Notification = { function NotificationItem({ notification, onRead, + observeRef, }: { notification: Notification onRead: () => void + observeRef?: (el: HTMLDivElement | null) => void }) { const IconComponent = ICON_MAP[notification.icon || 'Bell'] || Bell const priorityStyle = @@ -151,6 +153,8 @@ function NotificationItem({ const content = (
refetch(), }) - // Auto-mark all notifications as read when popover opens - useEffect(() => { - if (open && (countData ?? 0) > 0) { - markAllAsReadMutation.mutate() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]) + const markBatchAsReadMutation = trpc.notification.markBatchAsRead.useMutation({ + onSuccess: () => refetch(), + }) const unreadCount = countData ?? 0 const notifications = notificationData?.notifications ?? [] + // Track unread notification IDs that have become visible + const pendingReadIds = useRef>(new Set()) + const flushTimer = useRef | null>(null) + const observerRef = useRef(null) + const itemRefs = useRef>(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 ( @@ -339,6 +412,7 @@ export function NotificationBell() { { if (!notification.isRead) { markAsReadMutation.mutate({ id: notification.id }) diff --git a/src/components/shared/status-badge.tsx b/src/components/shared/status-badge.tsx index b2f4b71..78401b0 100644 --- a/src/components/shared/status-badge.tsx +++ b/src/components/shared/status-badge.tsx @@ -25,6 +25,7 @@ const STATUS_STYLES: Record { + 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 { + 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 { + 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 { + 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 { + 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 { + 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' +} diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts index 47f2f2b..bda385d 100644 --- a/src/server/routers/analytics.ts +++ b/src/server/routers/analytics.ts @@ -634,4 +634,157 @@ export const analyticsRouter = router({ 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 = {} + + 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), + } + }), }) diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index ce6a93d..0fa7031 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -826,7 +826,7 @@ export const applicantRouter = router({ email: input.email, name: input.name, role: 'APPLICANT', - status: 'INVITED', + status: 'NONE', }, }) } diff --git a/src/server/routers/application.ts b/src/server/routers/application.ts index 3822329..dabadb5 100644 --- a/src/server/routers/application.ts +++ b/src/server/routers/application.ts @@ -482,7 +482,7 @@ export const applicationRouter = router({ email: member.email, name: member.name, role: 'APPLICANT', - status: 'INVITED', + status: 'NONE', }, }) } diff --git a/src/server/routers/notification.ts b/src/server/routers/notification.ts index 516a9cc..ac097a1 100644 --- a/src/server/routers/notification.ts +++ b/src/server/routers/notification.ts @@ -94,6 +94,23 @@ export const notificationRouter = router({ 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 */ diff --git a/src/server/services/ai-filtering.ts b/src/server/services/ai-filtering.ts index 3763893..36cfdca 100644 --- a/src/server/services/ai-filtering.ts +++ b/src/server/services/ai-filtering.ts @@ -71,7 +71,7 @@ export type DocumentCheckConfig = { export type AIScreeningConfig = { 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 batchSize?: number // Projects per API call (1-50, default 20) parallelBatches?: number // Concurrent API calls (1-10, default 1) @@ -607,16 +607,21 @@ export async function executeFilteringRules( if (screening) { const passed = screening.meetsCriteria && !screening.spamRisk + const aiConfig = aiRule.configJson as unknown as AIScreeningConfig + const aiAction = aiConfig?.action || 'FLAG' ruleResults.push({ ruleId: aiRule.id, ruleName: aiRule.name, ruleType: 'AI_SCREENING', passed, - action: 'FLAG', + action: aiAction, reasoning: screening.reasoning, }) - if (!passed) hasFlagged = true + if (!passed) { + if (aiAction === 'REJECT') hasFailed = true + else hasFlagged = true + } } } diff --git a/src/types/round-settings.ts b/src/types/round-settings.ts index ce0a419..8d788a7 100644 --- a/src/types/round-settings.ts +++ b/src/types/round-settings.ts @@ -53,7 +53,7 @@ export type RoundSettings = export const defaultFilteringSettings: FilteringRoundSettings = { autoEliminationEnabled: false, autoEliminationThreshold: 4, - autoEliminationMinReviews: 3, + autoEliminationMinReviews: 0, targetAdvancing: 60, showAverageScore: true, showRanking: true,