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
-
-
-
-
- Contact Name
- setContactName(e.target.value)}
- placeholder="e.g., John Smith"
- />
-
-
-
- Contact Email
- setContactEmail(e.target.value)}
- placeholder="e.g., john@example.com"
- />
-
-
-
Country
@@ -353,16 +430,171 @@ function NewProjectPageContent() {
onChange={setCountry}
/>
+
+
-
-
City
-
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}
+
+
+ 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'}
+ >
+
+
+ {member.sendInvite ? 'Invite' : 'No invite'}
+
+
+ handleRemoveMember(member.id)}
+ >
+
+
+
+
+ ))}
+
+ )}
+
+ {teamMembers.length > 0 && teamMembers.length < 10 && }
+
+ {/* Add member form */}
+ {teamMembers.length < 10 && (
+
+
+
+ Add Member
+
+
+
+
+ Role
+ setMemberRole(v as 'LEAD' | 'MEMBER' | 'ADVISOR')}>
+
+
+
+
+ Lead
+ Member
+ Advisor
+
+
+
+
+ Title (optional)
+ setMemberTitle(e.target.value)}
+ placeholder="e.g., CEO, CTO"
+ className="h-9"
+ />
+
+
+
+
+ setMemberSendInvite(checked === true)}
+ />
+
+ Send platform invitation
+
+
+
+
+ Add
+
+
+
+ )}
+
+ {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
-
{
- window.print()
- }}
- >
-
- Export PDF
-
+
+
+
+
+
+
+ {pdfRounds.map((round) => (
+
+ {round.programName} - {round.name}
+
+ ))}
+
+
+ {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}
/>
+
+
+
+
Action for Non-Matching Projects
+
setAiAction(v as 'REJECT' | 'FLAG')}>
+
+
+
+
+ Auto Filter Out
+ Flag for Review
+
+
- 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}"
+
+
+
+
+
+ {nextUnevaluated.evaluation?.status === 'DRAFT' ? 'Continue Evaluation' : 'Start Evaluation'}
+
+
+
+
-
-
- {nextUnevaluated.evaluation?.status === 'DRAFT' ? 'Continue Evaluation' : 'Start Evaluation'}
-
-
-
-
-
+
+
)}
{/* 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
-
-
- View all
-
-
-
-
-
-
- {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
+
+
+
+
+
+
+ {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 ? (
+
+
+ View
+
+
+ ) : isVotingOpen ? (
+
+
+ {isDraft ? 'Continue' : 'Evaluate'}
+
+
+ ) : (
+
+
+ View
+
+
+ )}
-
-
- {isCompleted ? (
-
-
- Done
-
- ) : isDraft ? (
-
-
- Draft
-
- ) : (
- Pending
- )}
- {isCompleted ? (
-
-
- View
-
-
- ) : isVotingOpen ? (
-
-
- {isDraft ? 'Continue' : 'Evaluate'}
-
-
- ) : (
-
-
- View
-
-
- )}
-
- )
- })}
-
- ) : (
-
-
-
- No assignments yet
-
-
- )}
-
-
+ )
+ })}
+
+ ) : (
+
+
+
+
+
+ No assignments yet
+
+
+ Assignments will appear here once an administrator assigns projects to you.
+
+
+ )}
+
+
+
{/* Quick Actions */}
-
-
- Quick Actions
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
All Assignments
-
View and manage evaluations
+
All Assignments
+
View and manage evaluations
-
-
-
-
+
+
+
+
-
Compare Projects
-
Side-by-side comparison
+
Compare Projects
+
Side-by-side comparison
-
-
-
-
+
+
+
+
{/* 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
)}
- )}
-
-
- View Assignments
-
-
-
-
- )
- })}
-
-
+
+
+ Progress
+
+ {roundCompleted}/{roundTotal}
+
+
+
+
+
+ {deadline && (
+
+
+ {round.votingEndAt && (
+
+ ({formatDateOnly(round.votingEndAt)})
+
+ )}
+
+ )}
+
+
+
+ View Assignments
+
+
+
+
+ )
+ })}
+
+
+
)}
{/* 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
-
window.print()}
- >
-
- Export PDF
-
+ {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(`
-
- Print / Save as PDF
-
-
`)
-
- 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
- # Project Team Avg Score Evaluations
- `)
- for (const p of rankings) {
- parts.push(`
- ${escapeHtml(String(p.rank ?? ''))}
- ${escapeHtml(String(p.title ?? ''))}
- ${escapeHtml(String(p.team ?? ''))}
- ${Number(p.avgScore ?? 0).toFixed(2)}
- ${String(p.evalCount ?? 0)}
- `)
- }
- parts.push(`
`)
- }
-
- const jurorStats = reportData.jurorStats as Array> | undefined
- if (jurorStats && jurorStats.length > 0) {
- parts.push(`Juror Statistics
- Juror Assigned Completed Completion % Avg Score Given
- `)
- for (const j of jurorStats) {
- parts.push(`
- ${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(`
`)
- }
-
- 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 */}
+
+ Filter by Round:
+
+
+
+
+
+ All Rounds
+ {rounds.map((round) => (
+
+ {round.programName} - {round.name}
+
+ ))}
+
+
+
+
+ {/* 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"
+ />
+
+
+
+
+
+
+ All Statuses
+ Submitted
+ Eligible
+ Assigned
+ Under Review
+ Shortlisted
+ Semi-finalist
+ Finalist
+ Winner
+ Rejected
+ Withdrawn
+
+
+
{ setPerPage(Number(v)); setPage(1) }}>
+
+
+
+
+ {PER_PAGE_OPTIONS.map((n) => (
+ {n} / page
+ ))}
+
+
+
+
+ {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.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}
+
+
+ setPage((p) => Math.max(1, p - 1))}
+ disabled={page <= 1}
+ >
+
+
+ setPage((p) => Math.min(projectsData.totalPages, p + 1))}
+ disabled={page >= 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 (
+
+ {generating ? (
+
+ ) : (
+
+ )}
+ {generating ? 'Generating...' : 'Export PDF Report'}
+
+ )
+}
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,