From ce4069bf928621d80b292afa27e7936d5d7298dc Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 11 Feb 2026 13:20:52 +0100 Subject: [PATCH] Platform-wide visual overhaul, team invites, analytics improvements, and deployment hardening UI overhaul applying jury dashboard design patterns across all pages: - Stat cards with border-l-4 accent + icon pills on admin, observer, mentor, applicant dashboards and reports - Card section headers with color-coded icon pills throughout - Hover lift effects (translate-y + shadow) on cards and list items - Gradient progress bars (brand-teal to brand-blue) platform-wide - AnimatedCard stagger animations on all dashboard sections - Auth pages with gradient accent strip and polished icon containers - EmptyState component upgraded with rounded icon pill containers - Replaced AI-looking icons (Brain/Sparkles/Bot/Wand2/Cpu) with descriptive alternatives across 12 files - Removed gradient overlay from jury dashboard header - Quick actions restyled as card links with group hover effects Backend improvements: - Team member invite emails with account setup flow and notification logging - Analytics routers accept edition-wide queries (programId) in addition to roundId - Round detail endpoint returns inline progress data (eliminates extra getProgress call) - Award voting endpoints parallelized with Promise.all - Bulk invite supports optional sendInvitation flag - AwardVote composite index migration for query performance Infrastructure: - Docker entrypoint with migration retry loop (configurable retries/delay) - docker-compose pull_policy: always for automatic image refresh - Simplified deploy/update scripts using docker compose up -d --pull always - Updated deployment documentation Co-Authored-By: Claude Opus 4.6 --- DEPLOYMENT.md | 11 +- docker/docker-compose.yml | 5 +- docker/docker-entrypoint.sh | 18 +- messages/en.json | 8 +- messages/fr.json | 8 +- .../migration.sql | 2 + prisma/schema.prisma | 1 + scripts/deploy.sh | 7 +- scripts/update.sh | 12 +- src/app/(admin)/admin/awards/[id]/page.tsx | 81 +++- src/app/(admin)/admin/awards/page.tsx | 9 +- src/app/(admin)/admin/dashboard-content.tsx | 437 ++++++++++++++---- src/app/(admin)/admin/members/invite/page.tsx | 62 ++- src/app/(admin)/admin/programs/page.tsx | 6 +- .../admin/projects/[id]/mentor/page.tsx | 8 +- src/app/(admin)/admin/projects/[id]/page.tsx | 58 ++- src/app/(admin)/admin/projects/page.tsx | 12 +- src/app/(admin)/admin/reports/page.tsx | 237 ++++++---- .../admin/rounds/[id]/assignments/page.tsx | 16 +- .../rounds/[id]/filtering/rules/page.tsx | 4 +- src/app/(admin)/admin/rounds/[id]/page.tsx | 115 +++-- src/app/(admin)/admin/rounds/page.tsx | 9 +- src/app/(applicant)/applicant/page.tsx | 123 ++--- src/app/(applicant)/applicant/team/page.tsx | 8 +- src/app/(auth)/accept-invite/page.tsx | 25 +- src/app/(auth)/error/page.tsx | 8 +- src/app/(auth)/login/page.tsx | 13 +- src/app/(auth)/onboarding/page.tsx | 13 +- src/app/(auth)/set-password/page.tsx | 20 +- src/app/(auth)/verify-email/page.tsx | 8 +- src/app/(auth)/verify/page.tsx | 8 +- src/app/(jury)/jury/assignments/page.tsx | 6 +- src/app/(jury)/jury/page.tsx | 17 +- .../jury/projects/[id]/evaluation/page.tsx | 9 + src/app/(jury)/jury/projects/[id]/page.tsx | 21 +- src/app/(mentor)/mentor/page.tsx | 130 +++--- .../(mentor)/mentor/projects/[id]/page.tsx | 54 ++- src/app/(observer)/observer/reports/page.tsx | 231 +++++---- .../(public)/my-submission/[id]/team/page.tsx | 8 +- .../admin/assign-projects-dialog.tsx | 104 +++-- .../admin/evaluation-summary-card.tsx | 10 +- src/components/admin/members-content.tsx | 60 ++- src/components/layouts/admin-sidebar.tsx | 13 +- src/components/layouts/applicant-nav.tsx | 48 +- src/components/layouts/jury-nav.tsx | 55 ++- src/components/layouts/mentor-nav.tsx | 38 +- src/components/layouts/observer-nav.tsx | 28 +- src/components/layouts/role-nav.tsx | 11 +- .../observer/observer-dashboard-content.tsx | 154 +++--- src/components/settings/ai-settings-form.tsx | 8 +- src/components/settings/ai-usage-card.tsx | 6 +- src/components/settings/settings-content.tsx | 35 +- src/components/shared/empty-state.tsx | 4 +- src/components/ui/progress.tsx | 13 +- src/server/routers/analytics.ts | 119 +++-- src/server/routers/applicant.ts | 124 ++++- src/server/routers/round.ts | 40 +- src/server/routers/specialAward.ts | 83 ++-- src/server/routers/user.ts | 81 ++-- 59 files changed, 1949 insertions(+), 913 deletions(-) create mode 100644 prisma/migrations/20260211120000_add_award_vote_composite_index/migration.sql diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 9c40024..944a911 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -12,7 +12,7 @@ The app is built automatically by a Gitea runner on every push to `main`: 1. Gitea Actions workflow builds the Docker image on Ubuntu 2. Image is pushed to the Gitea container registry -3. On the server, you pull the latest image and restart +3. On the server, `docker compose up -d` refreshes the image and restarts the app ### Gitea Setup @@ -165,6 +165,15 @@ cd /opt/mopc This pulls the latest image from the registry, restarts only the app container (PostgreSQL stays running), runs migrations via the entrypoint, and waits for the health check. +Manual equivalent: + +```bash +cd /opt/mopc/docker +docker compose up -d --pull always --force-recreate app +``` + +`prisma migrate deploy` runs automatically in the container entrypoint before the app starts. + ## Manual Operations ### View logs diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 46b4b00..0a90e7d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -5,12 +5,13 @@ # MinIO and Poste.io are external services connected via environment variables. # # The app image is built by Gitea CI and pushed to the container registry. -# To pull the latest image: docker compose pull app -# To deploy: docker compose up -d +# `pull_policy: always` ensures `docker compose up -d` checks for newer app images. +# The app entrypoint runs `prisma migrate deploy` before starting Next.js. services: app: image: ${REGISTRY_URL}/mopc-app:latest + pull_policy: always container_name: mopc-app restart: unless-stopped dns: diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 4a753f8..b27f616 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -1,8 +1,20 @@ #!/bin/sh -set -e +set -eu -echo "==> Running database migrations..." -npx prisma migrate deploy +MAX_MIGRATION_RETRIES="${MIGRATION_MAX_RETRIES:-30}" +MIGRATION_RETRY_DELAY_SECONDS="${MIGRATION_RETRY_DELAY_SECONDS:-2}" +ATTEMPT=1 + +echo "==> Running database migrations (with retry)..." +until npx prisma migrate deploy; do + if [ "$ATTEMPT" -ge "$MAX_MIGRATION_RETRIES" ]; then + echo "ERROR: Migration failed after ${MAX_MIGRATION_RETRIES} attempts." + exit 1 + fi + echo "Migration attempt ${ATTEMPT} failed. Retrying in ${MIGRATION_RETRY_DELAY_SECONDS}s..." + ATTEMPT=$((ATTEMPT + 1)) + sleep "$MIGRATION_RETRY_DELAY_SECONDS" +done echo "==> Generating Prisma client..." npx prisma generate diff --git a/messages/en.json b/messages/en.json index addf53d..1e49e9e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -86,7 +86,13 @@ "mentoring": "Mentoring", "liveVoting": "Live Voting", "applications": "Applications", - "messages": "Messages" + "messages": "Messages", + "team": "Team", + "documents": "Documents", + "awards": "Awards", + "compare": "Compare", + "learningHub": "Learning Hub", + "reports": "Reports" }, "dashboard": { "title": "Dashboard", diff --git a/messages/fr.json b/messages/fr.json index fd7600e..d22e1aa 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -86,7 +86,13 @@ "mentoring": "Mentorat", "liveVoting": "Vote en direct", "applications": "Candidatures", - "messages": "Messages" + "messages": "Messages", + "team": "\u00c9quipe", + "documents": "Documents", + "awards": "Prix", + "compare": "Comparer", + "learningHub": "Centre de ressources", + "reports": "Rapports" }, "dashboard": { "title": "Tableau de bord", diff --git a/prisma/migrations/20260211120000_add_award_vote_composite_index/migration.sql b/prisma/migrations/20260211120000_add_award_vote_composite_index/migration.sql new file mode 100644 index 0000000..51717bd --- /dev/null +++ b/prisma/migrations/20260211120000_add_award_vote_composite_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "AwardVote_awardId_userId_idx" ON "AwardVote"("awardId", "userId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f238fd7..cdfedce 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1449,6 +1449,7 @@ model AwardVote { @@index([awardId]) @@index([userId]) @@index([projectId]) + @@index([awardId, userId]) } // ============================================================================= diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 774be76..b4d2c53 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -58,12 +58,9 @@ sudo mkdir -p /data/mopc/postgres sudo chown -R 1000:1000 /data/mopc # 6. Pull and start -echo "==> Pulling latest images..." +echo "==> Pulling latest images and starting services..." cd "$DOCKER_DIR" -docker compose pull app - -echo "==> Starting services..." -docker compose up -d +docker compose up -d --pull always # 7. Wait for health check echo "==> Waiting for application to start..." diff --git a/scripts/update.sh b/scripts/update.sh index e054176..09bc751 100644 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -17,16 +17,12 @@ echo " MOPC Platform - Update" echo "============================================" echo "" -# 1. Pull latest image from registry -echo "==> Pulling latest image..." +# 1. Pull and recreate app only (postgres stays running) +echo "==> Pulling latest image and recreating app..." cd "$DOCKER_DIR" -docker compose pull app +docker compose up -d --pull always --force-recreate app -# 2. Restart app only (postgres stays running) -echo "==> Restarting app..." -docker compose up -d app - -# 3. Wait for health check +# 2. Wait for health check echo "==> Waiting for application to start..." MAX_WAIT=120 WAITED=0 diff --git a/src/app/(admin)/admin/awards/[id]/page.tsx b/src/app/(admin)/admin/awards/[id]/page.tsx index 889a0bc..d943dd5 100644 --- a/src/app/(admin)/admin/awards/[id]/page.tsx +++ b/src/app/(admin)/admin/awards/[id]/page.tsx @@ -55,6 +55,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Input } from '@/components/ui/input' import { Progress } from '@/components/ui/progress' import { UserAvatar } from '@/components/shared/user-avatar' +import { AnimatedCard } from '@/components/shared/animated-container' import { Pagination } from '@/components/shared/pagination' import { toast } from 'sonner' import { @@ -66,14 +67,13 @@ import { import { Collapsible, CollapsibleContent, - CollapsibleTrigger, } from '@/components/ui/collapsible' import { ArrowLeft, Trophy, Users, CheckCircle2, - Brain, + ListChecks, BarChart3, Loader2, Crown, @@ -151,19 +151,29 @@ export default function AwardDetailPage({ const [expandedRows, setExpandedRows] = useState>(new Set()) const [activeTab, setActiveTab] = useState('eligibility') - // Core queries + // Pagination for eligibility list + const [eligibilityPage, setEligibilityPage] = useState(1) + const eligibilityPerPage = 25 + + // Core queries — lazy-load tab-specific data based on activeTab const { data: award, isLoading, refetch } = trpc.specialAward.get.useQuery({ id: awardId }) const { data: eligibilityData, refetch: refetchEligibility } = trpc.specialAward.listEligible.useQuery({ awardId, - page: 1, - perPage: 100, + page: eligibilityPage, + perPage: eligibilityPerPage, + }, { + enabled: activeTab === 'eligibility', }) const { data: jurors, refetch: refetchJurors } = - trpc.specialAward.listJurors.useQuery({ awardId }) + trpc.specialAward.listJurors.useQuery({ awardId }, { + enabled: activeTab === 'jurors', + }) const { data: voteResults } = - trpc.specialAward.getVoteResults.useQuery({ awardId }) + trpc.specialAward.getVoteResults.useQuery({ awardId }, { + enabled: activeTab === 'results', + }) // Deferred queries - only load when needed const { data: allUsers } = trpc.user.list.useQuery( @@ -539,8 +549,9 @@ export default function AwardDetailPage({ {/* Stats Cards */} +
- +
@@ -553,7 +564,7 @@ export default function AwardDetailPage({
- +
@@ -561,12 +572,12 @@ export default function AwardDetailPage({

{award._count.eligibilities}

- +
- +
@@ -579,7 +590,7 @@ export default function AwardDetailPage({
- +
@@ -593,8 +604,10 @@ export default function AwardDetailPage({
+ {/* Tabs */} + @@ -637,7 +650,7 @@ export default function AwardDetailPage({ {runEligibility.isPending || isPollingJob ? ( ) : ( - + )} {isPollingJob ? 'Processing...' : 'Run AI Eligibility'} @@ -779,6 +792,7 @@ export default function AwardDetailPage({ ? ((jobStatus.eligibilityJobDone ?? 0) / jobStatus.eligibilityJobTotal) * 100 : 0 } + gradient />
@@ -841,15 +855,22 @@ export default function AwardDetailPage({ }) }} asChild> <> - + { + if (!hasReasoning) return + setExpandedRows((prev) => { + const next = new Set(prev) + if (next.has(e.id)) next.delete(e.id) + else next.add(e.id) + return next + }) + }} + >
{hasReasoning && ( - - - + )}

{e.project.title}

@@ -892,7 +913,7 @@ export default function AwardDetailPage({ )} )} - + ev.stopPropagation()}> @@ -900,7 +921,7 @@ export default function AwardDetailPage({ } /> - + ev.stopPropagation()}>
) } diff --git a/src/app/(admin)/admin/awards/page.tsx b/src/app/(admin)/admin/awards/page.tsx index 1ac190a..030c306 100644 --- a/src/app/(admin)/admin/awards/page.tsx +++ b/src/app/(admin)/admin/awards/page.tsx @@ -23,6 +23,7 @@ import { SelectValue, } from '@/components/ui/select' import { Plus, Trophy, Users, CheckCircle2, Search } from 'lucide-react' +import { AnimatedCard } from '@/components/shared/animated-container' const STATUS_COLORS: Record = { DRAFT: 'secondary', @@ -156,9 +157,10 @@ export default function AwardsListPage() { {/* Awards Grid */} {filteredAwards.length > 0 ? (
- {filteredAwards.map((award) => ( - - + {filteredAwards.map((award, index) => ( + + +
@@ -202,6 +204,7 @@ export default function AwardsListPage() { + ))}
) : awards && awards.length > 0 ? ( diff --git a/src/app/(admin)/admin/dashboard-content.tsx b/src/app/(admin)/admin/dashboard-content.tsx index 1fd9955..d9e84ec 100644 --- a/src/app/(admin)/admin/dashboard-content.tsx +++ b/src/app/(admin)/admin/dashboard-content.tsx @@ -52,48 +52,228 @@ type DashboardContentProps = { sessionName: string } +function formatEntity(entityType: string | null): string { + if (!entityType) return 'record' + // Insert space before uppercase letters (PascalCase → words), then lowercase + return entityType + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/_/g, ' ') + .toLowerCase() +} + function formatAction(action: string, entityType: string | null): string { - const entity = entityType?.toLowerCase() || 'record' + const entity = formatEntity(entityType) const actionMap: Record = { + // Generic CRUD CREATE: `created a ${entity}`, UPDATE: `updated a ${entity}`, DELETE: `deleted a ${entity}`, - LOGIN: 'logged in', - EXPORT: `exported ${entity} data`, - SUBMIT: `submitted an ${entity}`, - ASSIGN: `assigned a ${entity}`, - INVITE: `invited a user`, - STATUS_CHANGE: `changed ${entity} status`, - BULK_UPDATE: `bulk updated ${entity}s`, IMPORT: `imported ${entity}s`, + EXPORT: `exported ${entity} data`, + REORDER: `reordered ${entity}s`, + + // Auth + LOGIN: 'logged in', + LOGIN_SUCCESS: 'logged in', + LOGIN_FAILED: 'failed to log in', + PASSWORD_SET: 'set their password', + PASSWORD_CHANGED: 'changed their password', + REQUEST_PASSWORD_RESET: 'requested a password reset', + COMPLETE_ONBOARDING: 'completed onboarding', + DELETE_OWN_ACCOUNT: 'deleted their account', + + // Evaluations + EVALUATION_SUBMITTED: 'submitted an evaluation', + COI_DECLARED: 'declared a conflict of interest', + COI_REVIEWED: 'reviewed a COI declaration', + REMINDERS_TRIGGERED: 'triggered evaluation reminders', + DISCUSSION_COMMENT_ADDED: 'added a discussion comment', + DISCUSSION_CLOSED: 'closed a discussion', + + // Assignments + ASSIGN: `assigned a ${entity}`, + BULK_CREATE: `bulk created ${entity}s`, + BULK_ASSIGN: 'bulk assigned users', + BULK_DELETE: `bulk deleted ${entity}s`, + BULK_UPDATE: `bulk updated ${entity}s`, + BULK_UPDATE_STATUS: 'bulk updated statuses', + APPLY_SUGGESTIONS: 'applied assignment suggestions', + ASSIGN_PROJECTS_TO_ROUND: 'assigned projects to round', + REMOVE_PROJECTS_FROM_ROUND: 'removed projects from round', + ADVANCE_PROJECTS: 'advanced projects to next round', + BULK_ASSIGN_TO_ROUND: 'bulk assigned to round', + REORDER_ROUNDS: 'reordered rounds', + + // Status + STATUS_CHANGE: `changed ${entity} status`, + UPDATE_STATUS: `updated ${entity} status`, + ROLE_CHANGED: 'changed a user role', + + // Invitations + INVITE: 'invited a user', + SEND_INVITATION: 'sent an invitation', + BULK_SEND_INVITATIONS: 'sent bulk invitations', + + // Files + UPLOAD_FILE: 'uploaded a file', + DELETE_FILE: 'deleted a file', + REPLACE_FILE: 'replaced a file', + FILE_DOWNLOADED: 'downloaded a file', + + // Filtering + EXECUTE_FILTERING: 'ran project filtering', + FINALIZE_FILTERING: 'finalized filtering results', + OVERRIDE: `overrode a ${entity} result`, + BULK_OVERRIDE: 'bulk overrode filtering results', + REINSTATE: 'reinstated a project', + BULK_REINSTATE: 'bulk reinstated projects', + + // AI + AI_TAG: 'ran AI tagging', + START_AI_TAG_JOB: 'started AI tagging job', + EVALUATION_SUMMARY: 'generated an AI summary', + AWARD_ELIGIBILITY: 'ran award eligibility check', + PROJECT_TAGGING: 'ran project tagging', + FILTERING: 'ran AI filtering', + MENTOR_MATCHING: 'ran mentor matching', + + // Tags + ADD_TAG: 'added a tag', + REMOVE_TAG: 'removed a tag', + BULK_CREATE_TAGS: 'bulk created tags', + + // Mentor + MENTOR_ASSIGN: 'assigned a mentor', + MENTOR_UNASSIGN: 'unassigned a mentor', + MENTOR_AUTO_ASSIGN: 'auto-assigned mentors', + MENTOR_BULK_ASSIGN: 'bulk assigned mentors', + CREATE_MENTOR_NOTE: 'created a mentor note', + COMPLETE_MILESTONE: 'completed a milestone', + + // Messages & Webhooks + SEND_MESSAGE: 'sent a message', + CREATE_MESSAGE_TEMPLATE: 'created a message template', + UPDATE_MESSAGE_TEMPLATE: 'updated a message template', + DELETE_MESSAGE_TEMPLATE: 'deleted a message template', + CREATE_WEBHOOK: 'created a webhook', + UPDATE_WEBHOOK: 'updated a webhook', + DELETE_WEBHOOK: 'deleted a webhook', + TEST_WEBHOOK: 'tested a webhook', + REGENERATE_WEBHOOK_SECRET: 'regenerated a webhook secret', + + // Settings + UPDATE_SETTING: 'updated a setting', + UPDATE_SETTINGS_BATCH: 'updated settings', + UPDATE_NOTIFICATION_PREFERENCES: 'updated notification preferences', + UPDATE_DIGEST_SETTINGS: 'updated digest settings', + UPDATE_ANALYTICS_SETTINGS: 'updated analytics settings', + UPDATE_AUDIT_SETTINGS: 'updated audit settings', + UPDATE_LOCALIZATION_SETTINGS: 'updated localization settings', + UPDATE_RETENTION_CONFIG: 'updated retention config', + + // Live Voting + START_VOTING: 'started live voting', + END_SESSION: 'ended a live voting session', + UPDATE_SESSION_CONFIG: 'updated session config', + + // Round Templates + CREATE_ROUND_TEMPLATE: 'created a round template', + CREATE_ROUND_TEMPLATE_FROM_ROUND: 'saved round as template', + UPDATE_ROUND_TEMPLATE: 'updated a round template', + DELETE_ROUND_TEMPLATE: 'deleted a round template', + UPDATE_EVALUATION_FORM: 'updated the evaluation form', + + // Grace Period + GRANT_GRACE_PERIOD: 'granted a grace period', + UPDATE_GRACE_PERIOD: 'updated a grace period', + REVOKE_GRACE_PERIOD: 'revoked a grace period', + BULK_GRANT_GRACE_PERIOD: 'bulk granted grace periods', + + // Awards + SET_AWARD_WINNER: 'set an award winner', + + // Reports & Applications + REPORT_GENERATED: 'generated a report', + DRAFT_SUBMITTED: 'submitted a draft application', + SUBMIT: `submitted a ${entity}`, } - return actionMap[action] || `${action.toLowerCase()} ${entity}` + if (actionMap[action]) return actionMap[action] + + // Fallback: convert ACTION_NAME to readable text + return action.toLowerCase().replace(/_/g, ' ') } function getActionIcon(action: string) { switch (action) { - case 'CREATE': return - case 'UPDATE': return - case 'DELETE': return - case 'LOGIN': return - case 'EXPORT': return - case 'SUBMIT': return - case 'ASSIGN': return - case 'INVITE': return - default: return + case 'CREATE': + case 'BULK_CREATE': + return + case 'UPDATE': + case 'UPDATE_STATUS': + case 'BULK_UPDATE': + case 'BULK_UPDATE_STATUS': + case 'STATUS_CHANGE': + case 'ROLE_CHANGED': + return + case 'DELETE': + case 'BULK_DELETE': + return + case 'LOGIN': + case 'LOGIN_SUCCESS': + case 'LOGIN_FAILED': + case 'PASSWORD_SET': + case 'PASSWORD_CHANGED': + case 'COMPLETE_ONBOARDING': + return + case 'EXPORT': + case 'REPORT_GENERATED': + return + case 'SUBMIT': + case 'EVALUATION_SUBMITTED': + case 'DRAFT_SUBMITTED': + return + case 'ASSIGN': + case 'BULK_ASSIGN': + case 'APPLY_SUGGESTIONS': + case 'ASSIGN_PROJECTS_TO_ROUND': + case 'MENTOR_ASSIGN': + case 'MENTOR_BULK_ASSIGN': + return + case 'INVITE': + case 'SEND_INVITATION': + case 'BULK_SEND_INVITATIONS': + return + case 'IMPORT': + return + default: + return } } export function DashboardContent({ editionId, sessionName }: DashboardContentProps) { - const { data, isLoading } = trpc.dashboard.getStats.useQuery( + const { data, isLoading, error } = trpc.dashboard.getStats.useQuery( { editionId }, - { enabled: !!editionId } + { enabled: !!editionId, retry: 1 } ) if (isLoading) { return } + if (error) { + return ( + + + +

Failed to load dashboard

+

+ {error.message || 'An unexpected error occurred. Please try refreshing the page.'} +

+
+
+ ) + } + if (!data) { return ( @@ -204,69 +384,85 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro {/* Stats Grid */}
- - - Rounds - - - -
{totalRoundCount}
-

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

+ + +
+
+

Rounds

+

{totalRoundCount}

+

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

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

- {newProjectsThisWeek > 0 - ? `${newProjectsThisWeek} new this week` - : 'In this edition'} -

+ + +
+
+

Projects

+

{projectCount}

+

+ {newProjectsThisWeek > 0 + ? `${newProjectsThisWeek} new this week` + : 'In this edition'} +

+
+
+ +
+
- - - Jury Members - - - -
{totalJurors}
-

- {activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`} -

+ + +
+
+

Jury Members

+

{totalJurors}

+

+ {activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`} +

+
+
+ +
+
- - - Evaluations - - - -
- {submittedCount} - {totalAssignments > 0 && ( - - {' '}/ {totalAssignments} - - )} + + +
+
+

Evaluations

+

+ {submittedCount} + {totalAssignments > 0 && ( + + {' '}/ {totalAssignments} + + )} +

+
+
+ +
- +

{completionRate.toFixed(0)}% completion rate

@@ -277,25 +473,34 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{/* Quick Actions */} -
- - - +
+ +
+ +
+
+

New Round

+

Create a voting round

+
+ + +
+ +
+
+

Import Projects

+

Upload a CSV file

+
+ + +
+ +
+
+

Invite Jury

+

Add jury members

+
+
{/* Two-Column Content */} @@ -303,11 +508,17 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro {/* Left Column */}
{/* Rounds Card (enhanced) */} +
- Rounds + +
+ +
+ Rounds +
Voting rounds in {edition.name} @@ -363,7 +574,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{round.totalEvals > 0 && ( - + )}
@@ -372,13 +583,20 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro )} + {/* Latest Projects Card */} +
- Latest Projects + +
+ +
+ Latest Projects +
Recently submitted projects
+
{/* Right Column */}
{/* Pending Actions Card */} + - - + +
+ +
Pending Actions
@@ -503,12 +725,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
+
{/* Evaluation Progress Card */} + - - + +
+ +
Evaluation Progress
@@ -532,7 +758,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro {round.evalPercent}%
- +

{round.submittedEvals} of {round.totalEvals} evaluations submitted

@@ -542,12 +768,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro )}
+ {/* Category Breakdown Card */} + - - + +
+ +
Project Categories
@@ -607,12 +837,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro )}
+
{/* Recent Activity Card */} + - - + +
+ +
Recent Activity
@@ -646,12 +880,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro )}
+
{/* Upcoming Deadlines Card */} + - - + +
+ +
Upcoming Deadlines
@@ -688,6 +926,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro )}
+
diff --git a/src/app/(admin)/admin/members/invite/page.tsx b/src/app/(admin)/admin/members/invite/page.tsx index 5a42f3d..2918016 100644 --- a/src/app/(admin)/admin/members/invite/page.tsx +++ b/src/app/(admin)/admin/members/invite/page.tsx @@ -50,6 +50,7 @@ import { CommandItem, CommandList, } from '@/components/ui/command' +import { Switch } from '@/components/ui/switch' import { ArrowLeft, ArrowRight, @@ -65,6 +66,8 @@ import { ChevronDown, Check, Tags, + Mail, + MailX, } from 'lucide-react' import { cn } from '@/lib/utils' @@ -257,10 +260,12 @@ export default function MemberInvitePage() { const [rows, setRows] = useState([createEmptyRow()]) const [parsedUsers, setParsedUsers] = useState([]) const [sendProgress, setSendProgress] = useState(0) + const [sendInvitation, setSendInvitation] = useState(true) const [result, setResult] = useState<{ created: number skipped: number assignmentsCreated?: number + invitationSent?: boolean } | null>(null) // Pre-assignment state @@ -505,6 +510,7 @@ export default function MemberInvitePage() { expertiseTags: u.expertiseTags, assignments: u.assignments, })), + sendInvitation, }) setSendProgress(100) setResult(result) @@ -520,6 +526,7 @@ export default function MemberInvitePage() { setParsedUsers([]) setResult(null) setSendProgress(0) + setSendInvitation(true) } const hasManualData = rows.some((r) => r.email.trim() || r.name.trim()) @@ -793,6 +800,32 @@ export default function MemberInvitePage() {
)} + {/* Invitation toggle */} +
+
+ {sendInvitation ? ( + + ) : ( + + )} +
+ +

+ {sendInvitation + ? 'Members will receive an email invitation to create their account' + : 'Members will be created without notification — you can send invitations later from the Members page'} +

+
+ +
+
+ {/* Actions */}
+ {!sendInvitation && ( +
+ +
+

No invitations will be sent

+

+ Members will be created with “Not Invited” status. You can send invitations later from the Members page. +

+
+
+ )} + {summary.invalid > 0 && (
@@ -924,10 +969,12 @@ export default function MemberInvitePage() { > {bulkCreate.isPending ? ( + ) : sendInvitation ? ( + ) : ( )} - Create & Invite {summary.valid} Member + {sendInvitation ? 'Create & Invite' : 'Create'} {summary.valid} Member {summary.valid !== 1 ? 's' : ''}
@@ -948,7 +995,7 @@ export default function MemberInvitePage() {

- Creating members and sending invitations... + {sendInvitation ? 'Creating members and sending invitations...' : 'Creating members...'}

@@ -963,23 +1010,28 @@ export default function MemberInvitePage() {

- Invitations Sent! + {result?.invitationSent ? 'Members Created & Invited!' : 'Members Created!'}

{result?.created} member{result?.created !== 1 ? 's' : ''}{' '} - created and invited. + {result?.invitationSent ? 'created and invited' : 'created'}. {result?.skipped ? ` ${result.skipped} skipped (already exist).` : ''} {result?.assignmentsCreated && result.assignmentsCreated > 0 ? ` ${result.assignmentsCreated} project assignment${result.assignmentsCreated !== 1 ? 's' : ''} pre-assigned.` : ''} + {!result?.invitationSent && ( + + You can send invitations from the Members page when ready. + + )}

- +
diff --git a/src/app/(admin)/admin/programs/page.tsx b/src/app/(admin)/admin/programs/page.tsx index bef0168..466511b 100644 --- a/src/app/(admin)/admin/programs/page.tsx +++ b/src/app/(admin)/admin/programs/page.tsx @@ -34,7 +34,7 @@ import { FolderKanban, Eye, Pencil, - Wand2, + Copy, } from 'lucide-react' import { formatDateOnly } from '@/lib/utils' @@ -150,7 +150,7 @@ async function ProgramsContent() { - + Apply Settings @@ -204,7 +204,7 @@ async function ProgramsContent() { diff --git a/src/app/(admin)/admin/projects/[id]/mentor/page.tsx b/src/app/(admin)/admin/projects/[id]/mentor/page.tsx index 489855e..abba363 100644 --- a/src/app/(admin)/admin/projects/[id]/mentor/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/mentor/page.tsx @@ -19,10 +19,10 @@ import { Progress } from '@/components/ui/progress' import { ArrowLeft, Loader2, - Sparkles, + Users, User, Check, - Wand2, + RefreshCw, } from 'lucide-react' import { getInitials } from '@/lib/utils' @@ -199,7 +199,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
- + AI-Suggested Mentors @@ -225,7 +225,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) { {autoAssignMutation.isPending ? ( ) : ( - + )} Auto-Assign Best Match diff --git a/src/app/(admin)/admin/projects/[id]/page.tsx b/src/app/(admin)/admin/projects/[id]/page.tsx index 2b3ddd4..8febcba 100644 --- a/src/app/(admin)/admin/projects/[id]/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/page.tsx @@ -28,6 +28,7 @@ import { FileUpload } from '@/components/shared/file-upload' import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url' import { UserAvatar } from '@/components/shared/user-avatar' import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card' +import { AnimatedCard } from '@/components/shared/animated-container' import { ArrowLeft, Edit, @@ -184,13 +185,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { {/* Stats Grid */} {stats && ( +
- + Average Score - +
+ +
@@ -202,12 +206,14 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { - + Recommendations - +
+ +
@@ -219,12 +225,19 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
+ )} {/* Project Info */} + - Project Information + +
+ +
+ Project Information +
{/* Category & Ocean Issue badges */} @@ -393,14 +406,18 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
+ {/* Team Members Section */} {project.teamMembers && project.teamMembers.length > 0 && ( +
- - + +
+ +
Team Members ({project.teamMembers.length})
@@ -437,15 +454,19 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
+
)} {/* Mentor Assignment Section */} {project.wantsMentorship && ( +
- - + +
+ +
Mentor Assignment
{!project.mentorAssignment && ( @@ -487,12 +508,19 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { )} + )} {/* Files Section */} + - Files + +
+ +
+ Files +
Project documents and materials @@ -535,14 +563,21 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
+
{/* Assignments Section */} {assignments && assignments.length > 0 && ( +
- Jury Assignments + +
+ +
+ Jury Assignments +
{assignments.filter((a) => a.evaluation?.status === 'SUBMITTED') .length}{' '} @@ -649,6 +684,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { + )} {/* AI Evaluation Summary */} diff --git a/src/app/(admin)/admin/projects/page.tsx b/src/app/(admin)/admin/projects/page.tsx index 9209216..11f148e 100644 --- a/src/app/(admin)/admin/projects/page.tsx +++ b/src/app/(admin)/admin/projects/page.tsx @@ -60,7 +60,6 @@ import { Search, Trash2, Loader2, - Sparkles, Tags, Clock, CheckCircle2, @@ -98,6 +97,7 @@ import { ProjectFiltersBar, type ProjectFilters, } from './project-filters' +import { AnimatedCard } from '@/components/shared/animated-container' const statusColors: Record< string, @@ -584,7 +584,7 @@ export default function ProjectsPage() {
- +
@@ -1051,7 +1051,7 @@ export default function ProjectsPage() { />
- +
@@ -1483,7 +1483,7 @@ export default function ProjectsPage() {
- +
AI Tag Generator @@ -1723,7 +1723,7 @@ export default function ProjectsPage() { {taggingInProgress ? ( ) : ( - + )} {taggingInProgress ? 'Processing...' : 'Generate Tags'} diff --git a/src/app/(admin)/admin/reports/page.tsx b/src/app/(admin)/admin/reports/page.tsx index adaedb9..fff6eb4 100644 --- a/src/app/(admin)/admin/reports/page.tsx +++ b/src/app/(admin)/admin/reports/page.tsx @@ -56,6 +56,7 @@ import { DiversityMetricsChart, } from '@/components/charts' import { ExportPdfButton } from '@/components/shared/export-pdf-button' +import { AnimatedCard } from '@/components/shared/animated-container' function ReportsOverview() { const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true }) @@ -96,62 +97,91 @@ function ReportsOverview() {
{/* Quick Stats */}
- - - Programs - - - -
{totalPrograms}
-

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

-
-
+ + + +
+
+

Programs

+

{totalPrograms}

+

+ {activeRounds} active round{activeRounds !== 1 ? 's' : ''} +

+
+
+ +
+
+
+
+
- - - Total Projects - - - -
{totalProjects}
-

Across all programs

-
-
+ + + +
+
+

Total Projects

+

{totalProjects}

+

Across all programs

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

Active jurors

-
-
+ + + +
+
+

Jury Members

+

{jurorCount}

+

Active jurors

+
+
+ +
+
+
+
+
- - - Evaluations - - - -
{submittedEvaluations}
-

- {totalEvaluations > 0 - ? `${completionRate}% completion rate` - : 'No assignments yet'} -

-
-
+ + + +
+
+

Evaluations

+

{submittedEvaluations}

+

+ {totalEvaluations > 0 + ? `${completionRate}% completion rate` + : 'No assignments yet'} +

+
+
+ +
+
+
+
+
{/* Score Distribution (if any evaluations exist) */} {dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && ( - Score Distribution + +
+ +
+ Score Distribution +
Overall score distribution across all evaluations
@@ -162,7 +192,7 @@ function ReportsOverview() {
{bucket.label}
- +
{bucket.count}
@@ -176,7 +206,12 @@ function ReportsOverview() { {/* Rounds Table */} - Round Reports + +
+ +
+ Round Reports +
View progress and export data for each round @@ -263,60 +298,73 @@ function ReportsOverview() { ) } +// Parse selection value: "all:programId" for edition-wide, or roundId +function parseSelection(value: string | null): { roundId?: string; programId?: string } { + if (!value) return {} + if (value.startsWith('all:')) return { programId: value.slice(4) } + return { roundId: value } +} + function RoundAnalytics() { - const [selectedRoundId, setSelectedRoundId] = useState(null) + const [selectedValue, setSelectedValue] = useState(null) const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true }) // Flatten rounds from all programs with program name - const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programName: `${p.year} Edition` }))) || [] + const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programId: p.id, programName: `${p.year} Edition` }))) || [] // Set default selected round - if (rounds.length && !selectedRoundId) { - setSelectedRoundId(rounds[0].id) + if (rounds.length && !selectedValue) { + setSelectedValue(rounds[0].id) } + const queryInput = parseSelection(selectedValue) + const hasSelection = !!queryInput.roundId || !!queryInput.programId + const { data: scoreDistribution, isLoading: scoreLoading } = trpc.analytics.getScoreDistribution.useQuery( - { roundId: selectedRoundId! }, - { enabled: !!selectedRoundId } + queryInput, + { enabled: hasSelection } ) const { data: timeline, isLoading: timelineLoading } = trpc.analytics.getEvaluationTimeline.useQuery( - { roundId: selectedRoundId! }, - { enabled: !!selectedRoundId } + queryInput, + { enabled: hasSelection } ) const { data: statusBreakdown, isLoading: statusLoading } = trpc.analytics.getStatusBreakdown.useQuery( - { roundId: selectedRoundId! }, - { enabled: !!selectedRoundId } + queryInput, + { enabled: hasSelection } ) const { data: jurorWorkload, isLoading: workloadLoading } = trpc.analytics.getJurorWorkload.useQuery( - { roundId: selectedRoundId! }, - { enabled: !!selectedRoundId } + queryInput, + { enabled: hasSelection } ) const { data: projectRankings, isLoading: rankingsLoading } = trpc.analytics.getProjectRankings.useQuery( - { roundId: selectedRoundId!, limit: 15 }, - { enabled: !!selectedRoundId } + { ...queryInput, limit: 15 }, + { enabled: hasSelection } ) const { data: criteriaScores, isLoading: criteriaLoading } = trpc.analytics.getCriteriaScores.useQuery( - { roundId: selectedRoundId! }, - { enabled: !!selectedRoundId } + queryInput, + { enabled: hasSelection } ) - const selectedRound = rounds.find((r) => r.id === selectedRoundId) + const selectedRound = rounds.find((r) => r.id === selectedValue) + const geoInput = queryInput.programId + ? { programId: queryInput.programId } + : { programId: selectedRound?.programId || '', roundId: queryInput.roundId } const { data: geoData, isLoading: geoLoading } = trpc.analytics.getGeographicDistribution.useQuery( - { programId: selectedRound?.programId || '', roundId: selectedRoundId! }, - { enabled: !!selectedRoundId && !!selectedRound?.programId } + geoInput, + { enabled: hasSelection && !!(geoInput.programId || geoInput.roundId) } ) if (roundsLoading) { @@ -350,11 +398,16 @@ function RoundAnalytics() { {/* Round Selector */}
- + {programs?.map((p) => ( + + {p.year} Edition — All Rounds + + ))} {rounds.map((round) => ( {round.programName} - {round.name} @@ -364,7 +417,7 @@ function RoundAnalytics() {
- {selectedRoundId && ( + {hasSelection && (
{/* Row 1: Score Distribution & Status Breakdown */}
@@ -537,22 +590,25 @@ function CrossRoundTab() { } function JurorConsistencyTab() { - const [selectedRoundId, setSelectedRoundId] = useState(null) + const [selectedValue, setSelectedValue] = useState(null) const { data: programs, isLoading: programsLoading } = 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` })) + p.rounds.map(r => ({ id: r.id, name: r.name, programId: p.id, programName: `${p.year} Edition` })) ) || [] - if (rounds.length && !selectedRoundId) { - setSelectedRoundId(rounds[0].id) + if (rounds.length && !selectedValue) { + setSelectedValue(rounds[0].id) } + const queryInput = parseSelection(selectedValue) + const hasSelection = !!queryInput.roundId || !!queryInput.programId + const { data: consistency, isLoading: consistencyLoading } = trpc.analytics.getJurorConsistency.useQuery( - { roundId: selectedRoundId! }, - { enabled: !!selectedRoundId } + queryInput, + { enabled: hasSelection } ) if (programsLoading) { @@ -563,11 +619,16 @@ function JurorConsistencyTab() {
- + {programs?.map((p) => ( + + {p.year} Edition — All Rounds + + ))} {rounds.map((round) => ( {round.programName} - {round.name} @@ -601,22 +662,25 @@ function JurorConsistencyTab() { } function DiversityTab() { - const [selectedRoundId, setSelectedRoundId] = useState(null) + const [selectedValue, setSelectedValue] = useState(null) const { data: programs, isLoading: programsLoading } = 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` })) + p.rounds.map(r => ({ id: r.id, name: r.name, programId: p.id, programName: `${p.year} Edition` })) ) || [] - if (rounds.length && !selectedRoundId) { - setSelectedRoundId(rounds[0].id) + if (rounds.length && !selectedValue) { + setSelectedValue(rounds[0].id) } + const queryInput = parseSelection(selectedValue) + const hasSelection = !!queryInput.roundId || !!queryInput.programId + const { data: diversity, isLoading: diversityLoading } = trpc.analytics.getDiversityMetrics.useQuery( - { roundId: selectedRoundId! }, - { enabled: !!selectedRoundId } + queryInput, + { enabled: hasSelection } ) if (programsLoading) { @@ -627,11 +691,16 @@ function DiversityTab() {
- + {programs?.map((p) => ( + + {p.year} Edition — All Rounds + + ))} {rounds.map((round) => ( {round.programName} - {round.name} diff --git a/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx b/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx index 2c2595a..4d97221 100644 --- a/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx @@ -64,14 +64,14 @@ import { CheckCircle2, Clock, AlertCircle, - Sparkles, + Shuffle, Loader2, Plus, Trash2, RefreshCw, UserPlus, - Cpu, - Brain, + Calculator, + Workflow, Search, ChevronsUpDown, Check, @@ -829,7 +829,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { - + Smart Assignment Suggestions @@ -844,7 +844,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
- + Algorithm {algorithmicSuggestions && algorithmicSuggestions.length > 0 && ( @@ -853,7 +853,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { )} - + AI Powered {aiSuggestions.length > 0 && ( @@ -983,7 +983,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { /> ) : !hasStoredAISuggestions ? (
- +

No AI analysis yet

Click "Start Analysis" to generate AI-powered suggestions @@ -995,7 +995,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { {startAIJob.isPending ? ( ) : ( - + )} Start AI Analysis diff --git a/src/app/(admin)/admin/rounds/[id]/filtering/rules/page.tsx b/src/app/(admin)/admin/rounds/[id]/filtering/rules/page.tsx index 3e0886e..1b7fc16 100644 --- a/src/app/(admin)/admin/rounds/[id]/filtering/rules/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/filtering/rules/page.tsx @@ -41,7 +41,7 @@ import { GripVertical, Loader2, FileCheck, - Brain, + SlidersHorizontal, Filter, } from 'lucide-react' @@ -56,7 +56,7 @@ const RULE_TYPE_LABELS: Record = { const RULE_TYPE_ICONS: Record = { FIELD_BASED: , DOCUMENT_CHECK: , - AI_SCREENING: , + AI_SCREENING: , } const FIELD_OPTIONS = [ diff --git a/src/app/(admin)/admin/rounds/[id]/page.tsx b/src/app/(admin)/admin/rounds/[id]/page.tsx index 2981942..24cf0b6 100644 --- a/src/app/(admin)/admin/rounds/[id]/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/page.tsx @@ -81,13 +81,17 @@ import { AlertTriangle, ListChecks, ClipboardCheck, - Sparkles, + FileSearch, LayoutTemplate, ShieldCheck, Download, RotateCcw, + Zap, + QrCode, + ExternalLink, } from 'lucide-react' import { toast } from 'sonner' +import { AnimatedCard } from '@/components/shared/animated-container' import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog' import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog' import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog' @@ -126,6 +130,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) { const [advanceOpen, setAdvanceOpen] = useState(false) const [removeOpen, setRemoveOpen] = useState(false) const [activeJobId, setActiveJobId] = useState(null) + const [jobPollInterval, setJobPollInterval] = useState(2000) // Inline filtering results state const [outcomeFilter, setOutcomeFilter] = useState('') @@ -140,7 +145,8 @@ function RoundDetailContent({ roundId }: { roundId: string }) { const [showExportDialog, setShowExportDialog] = useState(false) const { data: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId }) - const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId }) + // Progress data is now included in round.get response (eliminates duplicate evaluation.groupBy) + const progress = round?.progress // Check if this is a filtering round - roundType is stored directly on the round const isFilteringRound = round?.roundType === 'FILTERING' @@ -149,7 +155,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) { const { data: filteringStats, isLoading: isLoadingFilteringStats, refetch: refetchFilteringStats } = trpc.filtering.getResultStats.useQuery( { roundId }, - { enabled: isFilteringRound, staleTime: 0 } + { enabled: isFilteringRound, staleTime: 30_000 } ) const { data: filteringRules } = trpc.filtering.getRules.useQuery( { roundId }, @@ -162,31 +168,41 @@ function RoundDetailContent({ roundId }: { roundId: string }) { const { data: latestJob, refetch: refetchLatestJob } = trpc.filtering.getLatestJob.useQuery( { roundId }, - { enabled: isFilteringRound, staleTime: 0 } + { enabled: isFilteringRound, staleTime: 30_000 } ) - // Poll for job status when there's an active job + // Poll for job status with exponential backoff (2s → 4s → 8s → 15s cap) const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery( { jobId: activeJobId! }, { enabled: !!activeJobId, - refetchInterval: activeJobId ? 2000 : false, + refetchInterval: activeJobId ? jobPollInterval : false, + refetchIntervalInBackground: false, staleTime: 0, } ) + // Increase polling interval over time (exponential backoff) + useEffect(() => { + if (!activeJobId) { + setJobPollInterval(2000) + return + } + const timer = setTimeout(() => { + setJobPollInterval((prev) => Math.min(prev * 2, 15000)) + }, jobPollInterval) + return () => clearTimeout(timer) + }, [activeJobId, jobPollInterval]) + const utils = trpc.useUtils() const updateStatus = trpc.round.updateStatus.useMutation({ onSuccess: () => { utils.round.get.invalidate({ id: roundId }) - utils.round.list.invalidate() - utils.program.list.invalidate({ includeRounds: true }) }, }) const deleteRound = trpc.round.delete.useMutation({ onSuccess: () => { toast.success('Round deleted') - utils.program.list.invalidate() utils.round.list.invalidate() router.push('/admin/rounds') }, @@ -200,7 +216,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) { const finalizeResults = trpc.filtering.finalizeResults.useMutation({ onSuccess: () => { utils.round.get.invalidate({ id: roundId }) - utils.project.list.invalidate() }, }) @@ -218,7 +233,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) { }, { enabled: isFilteringRound && (filteringStats?.total ?? 0) > 0, - staleTime: 0, + staleTime: 30_000, } ) const overrideResult = trpc.filtering.overrideResult.useMutation() @@ -286,6 +301,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) { const handleStartFiltering = async () => { try { const result = await startJob.mutateAsync({ roundId }) + setJobPollInterval(2000) setActiveJobId(result.jobId) toast.info('Filtering job started. Progress will update automatically.') } catch (error) { @@ -309,8 +325,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) { } refetchFilteringStats() refetchRound() - utils.project.list.invalidate() - utils.program.list.invalidate({ includeRounds: true }) utils.round.get.invalidate({ id: roundId }) } catch (error) { toast.error( @@ -340,7 +354,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) { setOverrideReason('') refetchResults() refetchFilteringStats() - utils.project.list.invalidate() } catch { toast.error('Failed to override result') } @@ -352,7 +365,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) { toast.success('Project reinstated') refetchResults() refetchFilteringStats() - utils.project.list.invalidate() } catch { toast.error('Failed to reinstate project') } @@ -548,11 +560,14 @@ function RoundDetailContent({ roundId }: { roundId: string }) { {/* Stats Grid */} +

- + Projects - +
+ +
{round._count.projects}
@@ -562,10 +577,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
- + Judge Assignments - +
+ +
{round._count.assignments}
@@ -577,10 +594,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
- + Required Reviews - +
+ +
{round.requiredReviews}
@@ -588,10 +607,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
- + Completion - +
+ +
@@ -603,12 +624,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
+ {/* Progress */} {progress && progress.totalAssignments > 0 && ( + - Evaluation Progress + +
+ +
+ Evaluation Progress +
@@ -616,7 +644,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) { Overall Completion {progress.completionPercentage}%
- +
@@ -631,12 +659,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
+ )} {/* Voting Window */} + - Voting Window + +
+ +
+ Voting Window +
@@ -723,15 +758,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) { )} + {/* Filtering Section (for FILTERING rounds) */} {isFilteringRound && ( +
- - + +
+ +
Project Filtering
@@ -782,7 +821,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) { {progressPercent}%
- +
@@ -1226,12 +1265,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
+ )} {/* Quick Actions */} + - Quick Actions + +
+ +
+ Quick Actions +
{/* Project Management */} @@ -1275,6 +1321,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) { Jury Assignments + @@ -1287,7 +1339,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) { {bulkSummaries.isPending ? ( ) : ( - + )} {bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'} @@ -1319,6 +1371,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
+ {/* Dialogs */} - {programs.map((program) => ( - + {programs.map((program, index) => ( + + + ))}
) @@ -485,7 +488,7 @@ function SortableRoundRow({ ref={setNodeRef} style={style} className={cn( - 'rounded-lg border bg-card transition-all', + 'rounded-lg border bg-card transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md', isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90', isReordering && !isDragging && 'opacity-50' )} diff --git a/src/app/(applicant)/applicant/page.tsx b/src/app/(applicant)/applicant/page.tsx index 1774b21..cc0ff3b 100644 --- a/src/app/(applicant)/applicant/page.tsx +++ b/src/app/(applicant)/applicant/page.tsx @@ -16,6 +16,7 @@ import { import { Skeleton } from '@/components/ui/skeleton' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { StatusTracker } from '@/components/shared/status-tracker' +import { AnimatedCard } from '@/components/shared/animated-container' import { FileText, Calendar, @@ -79,16 +80,20 @@ export default function ApplicantDashboardPage() { Your applicant dashboard

- - - -

No Project Yet

-

- You haven't submitted a project yet. Check for open application rounds - on the MOPC website. -

-
-
+ + + +
+ +
+

No Project Yet

+

+ You haven't submitted a project yet. Check for open application rounds + on the MOPC website. +

+
+
+
) } @@ -132,6 +137,7 @@ export default function ApplicantDashboardPage() { {/* Main content */}
{/* Project details */} + Project Details @@ -203,65 +209,57 @@ export default function ApplicantDashboardPage() {
+ {/* Quick actions */} -
- - - -
- -
-
-

Documents

-

- {openRounds.length > 0 ? `${openRounds.length} round(s) open` : 'View uploads'} -

-
- - -
-
+ +
+ +
+ +
+
+

Documents

+

+ {openRounds.length > 0 ? `${openRounds.length} round(s) open` : 'View uploads'} +

+
+ + - - - -
- -
-
-

Team

-

- {project.teamMembers.length} member(s) -

-
- - -
-
+ +
+ +
+
+

Team

+

+ {project.teamMembers.length} member(s) +

+
+ + - - - -
- -
-
-

Mentor

-

- {project.mentorAssignment?.mentor?.name || 'Not assigned'} -

-
- - -
-
-
+ +
+ +
+
+

Mentor

+

+ {project.mentorAssignment?.mentor?.name || 'Not assigned'} +

+
+ + +
+
{/* Sidebar */}
{/* Status timeline */} + Status Timeline @@ -273,8 +271,10 @@ export default function ApplicantDashboardPage() { /> + {/* Team overview */} +
@@ -324,8 +324,10 @@ export default function ApplicantDashboardPage() { )} + {/* Key dates */} + Key Dates @@ -353,6 +355,7 @@ export default function ApplicantDashboardPage() { )} +
diff --git a/src/app/(applicant)/applicant/team/page.tsx b/src/app/(applicant)/applicant/team/page.tsx index 5fd83e7..ab29ab6 100644 --- a/src/app/(applicant)/applicant/team/page.tsx +++ b/src/app/(applicant)/applicant/team/page.tsx @@ -99,8 +99,12 @@ export default function ApplicantTeamPage() { ) const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({ - onSuccess: () => { - toast.success('Team member invited!') + onSuccess: (result) => { + if (result.requiresAccountSetup) { + toast.success('Invitation email sent to team member') + } else { + toast.success('Team member added and notified by email') + } setIsInviteOpen(false) refetch() }, diff --git a/src/app/(auth)/accept-invite/page.tsx b/src/app/(auth)/accept-invite/page.tsx index 5087942..953df99 100644 --- a/src/app/(auth)/accept-invite/page.tsx +++ b/src/app/(auth)/accept-invite/page.tsx @@ -13,6 +13,7 @@ import { } from '@/components/ui/card' import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock } from 'lucide-react' import { trpc } from '@/lib/trpc/client' +import { AnimatedCard } from '@/components/shared/animated-container' type InviteState = 'loading' | 'valid' | 'accepting' | 'error' @@ -134,12 +135,15 @@ function AcceptInviteContent() { // Loading state if (state === 'loading') { return ( - + + +

Verifying your invitation...

+ ) } @@ -147,9 +151,11 @@ function AcceptInviteContent() { if (state === 'error') { const errorContent = getErrorContent() return ( - + + +
-
+
{errorContent.icon}
{errorContent.title} @@ -167,15 +173,18 @@ function AcceptInviteContent() { + ) } // Valid invitation - show welcome const user = data?.user return ( - + + +
-
+
@@ -213,18 +222,22 @@ function AcceptInviteContent() {

+ ) } // Loading fallback for Suspense function LoadingCard() { return ( - + + +

Loading...

+ ) } diff --git a/src/app/(auth)/error/page.tsx b/src/app/(auth)/error/page.tsx index 1caa5b7..8d96391 100644 --- a/src/app/(auth)/error/page.tsx +++ b/src/app/(auth)/error/page.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Logo } from '@/components/shared/logo' import { AlertCircle } from 'lucide-react' +import { AnimatedCard } from '@/components/shared/animated-container' const errorMessages: Record = { Configuration: 'There is a problem with the server configuration.', @@ -20,12 +21,14 @@ export default function AuthErrorPage() { const message = errorMessages[error] || errorMessages.Default return ( - + + +
-
+
Authentication Error @@ -42,5 +45,6 @@ export default function AuthErrorPage() {
+ ) } diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index eac5ab6..8a5cca3 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -14,6 +14,7 @@ import { CardTitle, } from '@/components/ui/card' import { Mail, Loader2, CheckCircle2, AlertCircle, Lock, KeyRound } from 'lucide-react' +import { AnimatedCard } from '@/components/shared/animated-container' type LoginMode = 'password' | 'magic-link' @@ -102,9 +103,11 @@ export default function LoginPage() { // Success state after sending magic link if (isSent) { return ( - + + +
-
+
Check your email @@ -137,11 +140,14 @@ export default function LoginPage() {
+ ) } return ( - + + +
Welcome back @@ -299,5 +305,6 @@ export default function LoginPage() {
+
) } diff --git a/src/app/(auth)/onboarding/page.tsx b/src/app/(auth)/onboarding/page.tsx index 8d9040a..df0d5ca 100644 --- a/src/app/(auth)/onboarding/page.tsx +++ b/src/app/(auth)/onboarding/page.tsx @@ -41,6 +41,7 @@ import { Globe, FileText, } from 'lucide-react' +import { AnimatedCard } from '@/components/shared/animated-container' type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'preferences' | 'complete' @@ -181,19 +182,24 @@ export default function OnboardingPage() { if (sessionStatus === 'loading' || userLoading || !initialized) { return (
- + + +

Loading your profile...

+
) } return (
- + + +
{/* Progress indicator */}
@@ -570,7 +576,7 @@ export default function OnboardingPage() { {/* Step 7: Complete */} {step === 'complete' && ( -
+

@@ -584,6 +590,7 @@ export default function OnboardingPage() { )} +

) } diff --git a/src/app/(auth)/set-password/page.tsx b/src/app/(auth)/set-password/page.tsx index 0a04ff9..9239c3c 100644 --- a/src/app/(auth)/set-password/page.tsx +++ b/src/app/(auth)/set-password/page.tsx @@ -17,6 +17,7 @@ import { Progress } from '@/components/ui/progress' import { Loader2, Lock, CheckCircle2, AlertCircle, Eye, EyeOff } from 'lucide-react' import Image from 'next/image' import { trpc } from '@/lib/trpc/client' +import { AnimatedCard } from '@/components/shared/animated-container' export default function SetPasswordPage() { const [password, setPassword] = useState('') @@ -116,20 +117,25 @@ export default function SetPasswordPage() { // Loading state while checking session if (session === undefined) { return ( - + + +
+ ) } // Success state if (isSuccess) { return ( - + + +
-
+
Password Set Successfully @@ -144,13 +150,16 @@ export default function SetPasswordPage() {

+ ) } return ( - + + +
-
+
MOPC
Set Your Password @@ -294,5 +303,6 @@ export default function SetPasswordPage() { + ) } diff --git a/src/app/(auth)/verify-email/page.tsx b/src/app/(auth)/verify-email/page.tsx index 3f088b2..fe0609f 100644 --- a/src/app/(auth)/verify-email/page.tsx +++ b/src/app/(auth)/verify-email/page.tsx @@ -1,11 +1,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Mail } from 'lucide-react' +import { AnimatedCard } from '@/components/shared/animated-container' export default function VerifyEmailPage() { return ( - + + +
-
+
Check your email @@ -23,5 +26,6 @@ export default function VerifyEmailPage() {

+ ) } diff --git a/src/app/(auth)/verify/page.tsx b/src/app/(auth)/verify/page.tsx index 3d98b90..d2fceb9 100644 --- a/src/app/(auth)/verify/page.tsx +++ b/src/app/(auth)/verify/page.tsx @@ -2,12 +2,15 @@ import Link from 'next/link' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { CheckCircle2 } from 'lucide-react' +import { AnimatedCard } from '@/components/shared/animated-container' export default function VerifyPage() { return ( - + + +
-
+
Check your email @@ -24,5 +27,6 @@ export default function VerifyPage() {
+ ) } diff --git a/src/app/(jury)/jury/assignments/page.tsx b/src/app/(jury)/jury/assignments/page.tsx index 3d0c0e0..a9c245d 100644 --- a/src/app/(jury)/jury/assignments/page.tsx +++ b/src/app/(jury)/jury/assignments/page.tsx @@ -178,7 +178,7 @@ async function AssignmentsContent({
- +

{overallProgress}% complete

@@ -210,7 +210,7 @@ async function AssignmentsContent({ new Date(assignment.round.votingEndAt) >= now return ( - + = now 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/(jury)/jury/projects/[id]/evaluation/page.tsx b/src/app/(jury)/jury/projects/[id]/evaluation/page.tsx index c1fc000..e3d7c13 100644 --- a/src/app/(jury)/jury/projects/[id]/evaluation/page.tsx +++ b/src/app/(jury)/jury/projects/[id]/evaluation/page.tsx @@ -27,6 +27,7 @@ import { Star, AlertCircle, } from 'lucide-react' +import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section' import { format } from 'date-fns' interface PageProps { @@ -83,6 +84,7 @@ async function EvaluationContent({ projectId }: { projectId: string }) { id: true, title: true, teamName: true, + _count: { select: { files: true } }, }, }) @@ -223,6 +225,13 @@ async function EvaluationContent({ projectId }: { projectId: string }) { + {/* Project Documents */} + + {/* Criteria scores */} {criteria.length > 0 && ( diff --git a/src/app/(jury)/jury/projects/[id]/page.tsx b/src/app/(jury)/jury/projects/[id]/page.tsx index fa045dc..4365a67 100644 --- a/src/app/(jury)/jury/projects/[id]/page.tsx +++ b/src/app/(jury)/jury/projects/[id]/page.tsx @@ -240,7 +240,12 @@ async function ProjectContent({ projectId }: { projectId: string }) { {/* Description */} - Project Description + +
+ +
+ Project Description +
{project.description ? ( @@ -266,7 +271,12 @@ async function ProjectContent({ projectId }: { projectId: string }) { {/* Round Info */} - Round Details + +
+ +
+ Round Details +
@@ -310,7 +320,12 @@ async function ProjectContent({ projectId }: { projectId: string }) { {evaluation && ( - Your Evaluation + +
+ +
+ Your Evaluation +
diff --git a/src/app/(mentor)/mentor/page.tsx b/src/app/(mentor)/mentor/page.tsx index a5e22bf..935b3df 100644 --- a/src/app/(mentor)/mentor/page.tsx +++ b/src/app/(mentor)/mentor/page.tsx @@ -39,6 +39,7 @@ import { Search, } from 'lucide-react' import { formatDateOnly } from '@/lib/utils' +import { AnimatedCard } from '@/components/shared/animated-container' // Status badge colors const statusColors: Record = { @@ -117,63 +118,72 @@ export default function MentorDashboard() { {/* Stats */}
- - - - Assigned Projects - - - - -
{projects.length}
-

- Projects you are mentoring -

-
-
+ + + +
+
+

Assigned Projects

+

{projects.length}

+

Projects you are mentoring

+
+
+ +
+
+
+
+
- - - - Completed - - - - -
{completedCount}
-
- {projects.length > 0 && ( - - )} - - {projects.length > 0 ? Math.round((completedCount / projects.length) * 100) : 0}% - -
-
-
+ + + +
+
+

Completed

+

{completedCount}

+
+ {projects.length > 0 && ( + + )} + + {projects.length > 0 ? Math.round((completedCount / projects.length) * 100) : 0}% + +
+
+
+ +
+
+
+
+
- - - - Total Team Members - - - - -
- {projects.reduce( - (acc, a) => acc + (a.project.teamMembers?.length || 0), - 0 - )} -
-

- Across all assigned projects -

-
-
+ + + +
+
+

Total Team Members

+

+ {projects.reduce( + (acc, a) => acc + (a.project.teamMembers?.length || 0), + 0 + )} +

+

Across all assigned projects

+
+
+ +
+
+
+
+
{/* Quick Actions */} @@ -219,8 +229,8 @@ export default function MentorDashboard() { {projects.length === 0 ? ( -
- +
+

No assigned projects yet

@@ -248,7 +258,7 @@ export default function MentorDashboard() { ) : (

- {filteredProjects.map((assignment) => { + {filteredProjects.map((assignment, index) => { const project = assignment.project const teamLead = project.teamMembers?.find( (m) => m.role === 'LEAD' @@ -256,7 +266,8 @@ export default function MentorDashboard() { const badge = completionBadge[assignment.completionStatus] || completionBadge.in_progress return ( - + +
@@ -376,6 +387,7 @@ export default function MentorDashboard() {
+ ) })}
diff --git a/src/app/(mentor)/mentor/projects/[id]/page.tsx b/src/app/(mentor)/mentor/projects/[id]/page.tsx index da849bc..31e212b 100644 --- a/src/app/(mentor)/mentor/projects/[id]/page.tsx +++ b/src/app/(mentor)/mentor/projects/[id]/page.tsx @@ -28,6 +28,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' +import { AnimatedCard } from '@/components/shared/animated-container' import { FileViewer } from '@/components/shared/file-viewer' import { MentorChat } from '@/components/shared/mentor-chat' import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url' @@ -194,21 +195,31 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { {/* Milestones Section */} {programId && mentorAssignmentId && ( + + )} {/* Private Notes Section */} {mentorAssignmentId && ( - + + + )} {/* Project Info */} + - Project Information + +
+ +
+ Project Information +
{/* Category & Ocean Issue badges */} @@ -299,12 +310,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { )}
+
{/* Team Members Section */} + - - + +
+ +
Team Members ({project.teamMembers?.length || 0})
@@ -392,12 +407,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { )}
+
{/* Files Section */} + - - + +
+ +
Project Files
@@ -426,12 +445,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { )}
+
{/* Messaging Section */} + - - + +
+ +
Messages
@@ -450,6 +473,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { />
+
) } @@ -529,8 +553,10 @@ function MilestonesSection({
- - + +
+ +
Milestones
@@ -552,7 +578,7 @@ function MilestonesSection({ return (
@@ -676,8 +702,10 @@ function NotesSection({ mentorAssignmentId }: { mentorAssignmentId: string }) {
- - + +
+ +
Private Notes
{!isAdding && !editingId && ( diff --git a/src/app/(observer)/observer/reports/page.tsx b/src/app/(observer)/observer/reports/page.tsx index 028ff0a..c6e881b 100644 --- a/src/app/(observer)/observer/reports/page.tsx +++ b/src/app/(observer)/observer/reports/page.tsx @@ -52,8 +52,16 @@ import { DiversityMetricsChart, } from '@/components/charts' import { ExportPdfButton } from '@/components/shared/export-pdf-button' +import { AnimatedCard } from '@/components/shared/animated-container' -function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) { +// Parse selection value: "all:programId" for edition-wide, or roundId +function parseSelection(value: string | null): { roundId?: string; programId?: string } { + if (!value) return {} + if (value.startsWith('all:')) return { programId: value.slice(4) } + return { roundId: value } +} + +function OverviewTab({ selectedValue }: { selectedValue: string | null }) { const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true }) const rounds = programs?.flatMap(p => @@ -63,10 +71,13 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) { })) ) || [] + const queryInput = parseSelection(selectedValue) + const hasSelection = !!queryInput.roundId || !!queryInput.programId + const { data: overviewStats, isLoading: statsLoading } = trpc.analytics.getOverviewStats.useQuery( - { roundId: selectedRoundId! }, - { enabled: !!selectedRoundId } + queryInput, + { enabled: hasSelection } ) if (isLoading) { @@ -97,55 +108,79 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
{/* Quick Stats */}
- - - Total Rounds - - - -
{rounds.length}
-

- {activeRounds} active -

-
-
+ + + +
+
+

Total Rounds

+

{rounds.length}

+

+ {activeRounds} active +

+
+
+ +
+
+
+
+
- - - Total Projects - - - -
{totalProjects}
-

Across all rounds

-
-
+ + + +
+
+

Total Projects

+

{totalProjects}

+

Across all rounds

+
+
+ +
+
+
+
+
- - - Active Rounds - - - -
{activeRounds}
-

Currently active

-
-
+ + + +
+
+

Active Rounds

+

{activeRounds}

+

Currently active

+
+
+ +
+
+
+
+
- - - Programs - - - -
{totalPrograms}
-

Total programs

-
-
+ + + +
+
+

Programs

+

{totalPrograms}

+

Total programs

+
+
+ +
+
+
+
+
- {/* Round-specific overview stats */} - {selectedRoundId && ( + {/* Round/edition-specific overview stats */} + {hasSelection && ( <> {statsLoading ? (
@@ -163,7 +198,7 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
) : overviewStats ? (
-

Selected Round Details

+

{queryInput.programId ? 'Edition Overview' : 'Selected Round Details'}

@@ -207,7 +242,7 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
{overviewStats.completionRate}%
- +
@@ -304,41 +339,44 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) { ) } -function AnalyticsTab({ selectedRoundId }: { selectedRoundId: string }) { +function AnalyticsTab({ selectedValue }: { selectedValue: string }) { + const queryInput = parseSelection(selectedValue) + const hasSelection = !!queryInput.roundId || !!queryInput.programId + const { data: scoreDistribution, isLoading: scoreLoading } = trpc.analytics.getScoreDistribution.useQuery( - { roundId: selectedRoundId }, - { enabled: !!selectedRoundId } + queryInput, + { enabled: hasSelection } ) const { data: timeline, isLoading: timelineLoading } = trpc.analytics.getEvaluationTimeline.useQuery( - { roundId: selectedRoundId }, - { enabled: !!selectedRoundId } + queryInput, + { enabled: hasSelection } ) const { data: statusBreakdown, isLoading: statusLoading } = trpc.analytics.getStatusBreakdown.useQuery( - { roundId: selectedRoundId }, - { enabled: !!selectedRoundId } + queryInput, + { enabled: hasSelection } ) const { data: jurorWorkload, isLoading: workloadLoading } = trpc.analytics.getJurorWorkload.useQuery( - { roundId: selectedRoundId }, - { enabled: !!selectedRoundId } + queryInput, + { enabled: hasSelection } ) const { data: projectRankings, isLoading: rankingsLoading } = trpc.analytics.getProjectRankings.useQuery( - { roundId: selectedRoundId, limit: 15 }, - { enabled: !!selectedRoundId } + { ...queryInput, limit: 15 }, + { enabled: hasSelection } ) const { data: criteriaScores, isLoading: criteriaLoading } = trpc.analytics.getCriteriaScores.useQuery( - { roundId: selectedRoundId }, - { enabled: !!selectedRoundId } + queryInput, + { enabled: hasSelection } ) return ( @@ -483,11 +521,14 @@ function CrossRoundTab() { ) } -function JurorConsistencyTab({ selectedRoundId }: { selectedRoundId: string }) { +function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) { + const queryInput = parseSelection(selectedValue) + const hasSelection = !!queryInput.roundId || !!queryInput.programId + const { data: consistency, isLoading } = trpc.analytics.getJurorConsistency.useQuery( - { roundId: selectedRoundId }, - { enabled: !!selectedRoundId } + queryInput, + { enabled: hasSelection } ) if (isLoading) return @@ -508,11 +549,14 @@ function JurorConsistencyTab({ selectedRoundId }: { selectedRoundId: string }) { ) } -function DiversityTab({ selectedRoundId }: { selectedRoundId: string }) { +function DiversityTab({ selectedValue }: { selectedValue: string }) { + const queryInput = parseSelection(selectedValue) + const hasSelection = !!queryInput.roundId || !!queryInput.programId + const { data: diversity, isLoading } = trpc.analytics.getDiversityMetrics.useQuery( - { roundId: selectedRoundId }, - { enabled: !!selectedRoundId } + queryInput, + { enabled: hasSelection } ) if (isLoading) return @@ -533,22 +577,26 @@ function DiversityTab({ selectedRoundId }: { selectedRoundId: string }) { } export default function ObserverReportsPage() { - const [selectedRoundId, setSelectedRoundId] = useState(null) + const [selectedValue, setSelectedValue] = useState(null) const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true }) const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, + programId: p.id, programName: `${p.year} Edition`, })) ) || [] // Set default selected round - if (rounds.length && !selectedRoundId) { - setSelectedRoundId(rounds[0].id) + if (rounds.length && !selectedValue) { + setSelectedValue(rounds[0].id) } + const hasSelection = !!selectedValue + const selectedRound = rounds.find((r) => r.id === selectedValue) + return (
{/* Header */} @@ -565,11 +613,16 @@ export default function ObserverReportsPage() { {roundsLoading ? ( ) : rounds.length > 0 ? ( - + {programs?.map((p) => ( + + {p.year} Edition — All Rounds + + ))} {rounds.map((round) => ( {round.programName} - {round.name} @@ -590,7 +643,7 @@ export default function ObserverReportsPage() { Overview - + Analytics @@ -598,38 +651,38 @@ export default function ObserverReportsPage() { Cross-Round - + Juror Consistency - + Diversity - {selectedRoundId && ( + {selectedValue && !selectedValue.startsWith('all:') && ( r.id === selectedRoundId)?.name} - programName={rounds.find((r) => r.id === selectedRoundId)?.programName} + roundId={selectedValue} + roundName={selectedRound?.name} + programName={selectedRound?.programName} /> )}
- + - {selectedRoundId ? ( - + {hasSelection ? ( + ) : (

Select a round

- Choose a round from the dropdown above to view analytics + Choose a round or edition from the dropdown above to view analytics

@@ -641,15 +694,15 @@ export default function ObserverReportsPage() {
- {selectedRoundId ? ( - + {hasSelection ? ( + ) : (

Select a round

- Choose a round above to view juror consistency metrics + Choose a round or edition above to view juror consistency metrics

@@ -657,15 +710,15 @@ export default function ObserverReportsPage() {
- {selectedRoundId ? ( - + {hasSelection ? ( + ) : (

Select a round

- Choose a round above to view diversity metrics + Choose a round or edition above to view diversity metrics

diff --git a/src/app/(public)/my-submission/[id]/team/page.tsx b/src/app/(public)/my-submission/[id]/team/page.tsx index b6c8e56..d2f8c02 100644 --- a/src/app/(public)/my-submission/[id]/team/page.tsx +++ b/src/app/(public)/my-submission/[id]/team/page.tsx @@ -98,8 +98,12 @@ export default function TeamManagementPage() { ) const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({ - onSuccess: () => { - toast.success('Team member invited!') + onSuccess: (result) => { + if (result.requiresAccountSetup) { + toast.success('Invitation email sent to team member') + } else { + toast.success('Team member added and notified by email') + } setIsInviteOpen(false) refetch() }, diff --git a/src/components/admin/assign-projects-dialog.tsx b/src/components/admin/assign-projects-dialog.tsx index 6b29fea..a096525 100644 --- a/src/components/admin/assign-projects-dialog.tsx +++ b/src/components/admin/assign-projects-dialog.tsx @@ -23,7 +23,7 @@ import { TableHeader, TableRow, } from '@/components/ui/table' -import { Search, Loader2, Plus, Package } from 'lucide-react' +import { Search, Loader2, Plus, Package, CheckCircle2 } from 'lucide-react' import { getCountryName } from '@/lib/countries' interface AssignProjectsDialogProps { @@ -65,7 +65,6 @@ export function AssignProjectsDialog({ const { data, isLoading } = trpc.project.list.useQuery( { programId, - notInRoundId: roundId, search: debouncedSearch || undefined, page: 1, perPage: 5000, @@ -87,23 +86,28 @@ export function AssignProjectsDialog({ }) const projects = data?.projects ?? [] + const alreadyInRound = new Set( + projects.filter((p) => p.round?.id === roundId).map((p) => p.id) + ) + const assignableProjects = projects.filter((p) => !alreadyInRound.has(p.id)) const toggleProject = useCallback((id: string) => { + if (alreadyInRound.has(id)) return setSelectedIds((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) - }, []) + }, [alreadyInRound]) const toggleAll = useCallback(() => { - if (selectedIds.size === projects.length) { + if (selectedIds.size === assignableProjects.length) { setSelectedIds(new Set()) } else { - setSelectedIds(new Set(projects.map((p) => p.id))) + setSelectedIds(new Set(assignableProjects.map((p) => p.id))) } - }, [selectedIds.size, projects]) + }, [selectedIds.size, assignableProjects]) const handleAssign = () => { if (selectedIds.size === 0) return @@ -144,9 +148,9 @@ export function AssignProjectsDialog({ ) : projects.length === 0 ? (
-

No available projects

+

No projects found

- All program projects are already in this round. + {debouncedSearch ? 'No projects match your search.' : 'This program has no projects yet.'}

) : ( @@ -154,11 +158,15 @@ export function AssignProjectsDialog({
0} + checked={assignableProjects.length > 0 && selectedIds.size === assignableProjects.length} + disabled={assignableProjects.length === 0} onCheckedChange={toggleAll} /> - {selectedIds.size} of {projects.length} selected + {selectedIds.size} of {assignableProjects.length} assignable selected + {alreadyInRound.size > 0 && ( + ({alreadyInRound.size} already in round) + )}
@@ -174,34 +182,54 @@ export function AssignProjectsDialog({ - {projects.map((project) => ( - toggleProject(project.id)} - > - - toggleProject(project.id)} - onClick={(e) => e.stopPropagation()} - /> - - - {project.title} - - - {project.teamName || '—'} - - - {project.country ? ( - - {getCountryName(project.country)} - - ) : '—'} - - - ))} + {projects.map((project) => { + const isInRound = alreadyInRound.has(project.id) + return ( + toggleProject(project.id)} + > + + {isInRound ? ( + + ) : ( + toggleProject(project.id)} + onClick={(e) => e.stopPropagation()} + /> + )} + + +
+ {project.title} + {isInRound && ( + + In round + + )} +
+
+ + {project.teamName || '—'} + + + {project.country ? ( + + {getCountryName(project.country)} + + ) : '—'} + +
+ ) + })}
diff --git a/src/components/admin/evaluation-summary-card.tsx b/src/components/admin/evaluation-summary-card.tsx index d112b71..df1ccce 100644 --- a/src/components/admin/evaluation-summary-card.tsx +++ b/src/components/admin/evaluation-summary-card.tsx @@ -24,7 +24,7 @@ import { AlertDialogTrigger, } from '@/components/ui/alert-dialog' import { - Sparkles, + FileText, RefreshCw, Loader2, CheckCircle2, @@ -119,7 +119,7 @@ export function EvaluationSummaryCard({ - + AI Evaluation Summary @@ -128,7 +128,7 @@ export function EvaluationSummaryCard({
- +

No summary generated yet. Click below to analyze submitted evaluations.

@@ -136,7 +136,7 @@ export function EvaluationSummaryCard({ {isGenerating ? ( ) : ( - + )} {isGenerating ? 'Generating...' : 'Generate Summary'} @@ -155,7 +155,7 @@ export function EvaluationSummaryCard({
- + AI Evaluation Summary diff --git a/src/components/admin/members-content.tsx b/src/components/admin/members-content.tsx index e3f5b09..54c3e6c 100644 --- a/src/components/admin/members-content.tsx +++ b/src/components/admin/members-content.tsx @@ -27,7 +27,8 @@ import { Skeleton } from '@/components/ui/skeleton' import { UserAvatar } from '@/components/shared/user-avatar' import { UserActions, UserMobileActions } from '@/components/admin/user-actions' import { Pagination } from '@/components/shared/pagination' -import { Plus, Users, Search } from 'lucide-react' +import { Plus, Users, Search, Mail, Loader2 } from 'lucide-react' +import { toast } from 'sonner' import { formatRelativeTime } from '@/lib/utils' type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' @@ -60,6 +61,39 @@ const roleColors: Record = { SUPER_ADMIN: 'destructive' as 'default', } +function InlineSendInvite({ userId, userEmail }: { userId: string; userEmail: string }) { + const utils = trpc.useUtils() + const sendInvitation = trpc.user.sendInvitation.useMutation({ + onSuccess: () => { + toast.success(`Invitation sent to ${userEmail}`) + utils.user.list.invalidate() + }, + onError: (error) => { + toast.error(error.message || 'Failed to send invitation') + }, + }) + + return ( + + ) +} + export function MembersContent() { const searchParams = useSearchParams() @@ -124,7 +158,7 @@ export function MembersContent() {
@@ -223,9 +257,14 @@ export function MembersContent() {
- - {statusLabels[user.status] || user.status} - +
+ + {statusLabels[user.status] || user.status} + + {user.status === 'NONE' && ( + + )} +
{user.lastLoginAt ? ( @@ -272,9 +311,14 @@ export function MembersContent() {
- - {statusLabels[user.status] || user.status} - +
+ + {statusLabels[user.status] || user.status} + + {user.status === 'NONE' && ( + + )} +
diff --git a/src/components/layouts/admin-sidebar.tsx b/src/components/layouts/admin-sidebar.tsx index 9d3699b..6dad229 100644 --- a/src/components/layouts/admin-sidebar.tsx +++ b/src/components/layouts/admin-sidebar.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' import type { Route } from 'next' import { usePathname } from 'next/navigation' import { signOut } from 'next-auth/react' +import { useTranslations } from 'next-intl' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { @@ -33,7 +34,7 @@ import { Trophy, User, MessageSquare, - Wand2, + LayoutTemplate, } from 'lucide-react' import { getInitials } from '@/lib/utils' import { Logo } from '@/components/shared/logo' @@ -41,6 +42,7 @@ import { EditionSelector } from '@/components/shared/edition-selector' import { useEdition } from '@/contexts/edition-context' import { UserAvatar } from '@/components/shared/user-avatar' import { NotificationBell } from '@/components/shared/notification-bell' +import { LanguageSwitcher } from '@/components/shared/language-switcher' import { useSession } from 'next-auth/react' import { trpc } from '@/lib/trpc/client' @@ -120,7 +122,7 @@ const adminNavigation: NavItem[] = [ { name: 'Apply Page', href: '/admin/programs', - icon: Wand2, + icon: LayoutTemplate, activeMatch: 'apply-settings', }, { @@ -145,6 +147,7 @@ const roleLabels: Record = { export function AdminSidebar({ user }: AdminSidebarProps) { const pathname = usePathname() + const tAuth = useTranslations('auth') const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const { status: sessionStatus } = useSession() const isAuthenticated = sessionStatus === 'authenticated' @@ -170,6 +173,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
+
diff --git a/src/components/observer/observer-dashboard-content.tsx b/src/components/observer/observer-dashboard-content.tsx index b6760ab..040e1f4 100644 --- a/src/components/observer/observer-dashboard-content.tsx +++ b/src/components/observer/observer-dashboard-content.tsx @@ -42,6 +42,7 @@ import { ChevronRight, } from 'lucide-react' import { cn } from '@/lib/utils' +import { AnimatedCard } from '@/components/shared/animated-container' import { useDebouncedCallback } from 'use-debounce' const PER_PAGE_OPTIONS = [10, 20, 50] @@ -121,9 +122,9 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
{/* Observer Notice */} -
+
-
+
@@ -175,65 +176,95 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
) : stats ? (
- - - Programs - - - -
{stats.programCount}
-

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

-
-
+ + + +
+
+

Programs

+

{stats.programCount}

+

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

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

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

-
-
+ + + +
+
+

Projects

+

{stats.projectCount}

+

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

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

Active members

-
-
+ + + +
+
+

Jury Members

+

{stats.jurorCount}

+

Active members

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

- {stats.completionRate}% completion rate -

-
-
-
+ + + +
+
+

Evaluations

+

{stats.submittedEvaluations}

+
+ +

+ {stats.completionRate}% completion rate +

+
+
+
+ +
+
+
+
+
) : null} {/* Projects Table */} + - All Projects + +
+ +
+ All Projects +
{projectsData ? `${projectsData.total} project${projectsData.total !== 1 ? 's' : ''} found` : 'Loading projects...'} @@ -395,12 +426,19 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { )}
+
{/* Score Distribution */} {stats && stats.scoreDistribution.some((b) => b.count > 0) && ( + - Score Distribution + +
+ +
+ Score Distribution +
Distribution of global scores across evaluations
@@ -424,13 +462,20 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
+ )} {/* Recent Rounds */} {recentRounds.length > 0 && ( + - Recent Rounds + +
+ +
+ Recent Rounds +
Overview of the latest voting rounds
@@ -470,6 +515,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
+ )}
) diff --git a/src/components/settings/ai-settings-form.tsx b/src/components/settings/ai-settings-form.tsx index 25bab79..3a93d7d 100644 --- a/src/components/settings/ai-settings-form.tsx +++ b/src/components/settings/ai-settings-form.tsx @@ -4,7 +4,7 @@ import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { toast } from 'sonner' -import { Bot, Loader2, Zap, AlertCircle, RefreshCw, Brain } from 'lucide-react' +import { Cog, Loader2, Zap, AlertCircle, RefreshCw, SlidersHorizontal } from 'lucide-react' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -264,7 +264,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
{model.isReasoning && ( - + )} {model.name}
@@ -278,7 +278,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) { {form.watch('ai_model')?.startsWith('o') ? ( - + Reasoning model - optimized for complex analysis tasks ) : ( @@ -323,7 +323,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) { ) : ( <> - + Save AI Settings )} diff --git a/src/components/settings/ai-usage-card.tsx b/src/components/settings/ai-usage-card.tsx index 037aeb7..fd30e08 100644 --- a/src/components/settings/ai-usage-card.tsx +++ b/src/components/settings/ai-usage-card.tsx @@ -15,7 +15,7 @@ import { Zap, TrendingUp, Activity, - Brain, + SlidersHorizontal, Filter, Users, Award, @@ -26,7 +26,7 @@ const ACTION_ICONS: Record = { ASSIGNMENT: Users, FILTERING: Filter, AWARD_ELIGIBILITY: Award, - MENTOR_MATCHING: Brain, + MENTOR_MATCHING: SlidersHorizontal, } const ACTION_LABELS: Record = { @@ -235,7 +235,7 @@ export function AIUsageCard() { variant="outline" className="flex items-center gap-2" > - + {model} {(data as { costFormatted?: string }).costFormatted} diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index 8ce4fec..484f780 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -11,7 +11,7 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Skeleton } from '@/components/ui/skeleton' import { - Bot, + Cog, Palette, Mail, HardDrive, @@ -29,6 +29,7 @@ import { } from 'lucide-react' import Link from 'next/link' import { Button } from '@/components/ui/button' +import { AnimatedCard } from '@/components/shared/animated-container' import { AISettingsForm } from './ai-settings-form' import { AIUsageCard } from './ai-usage-card' import { BrandingSettingsForm } from './branding-settings-form' @@ -195,7 +196,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin {isSuperAdmin && ( - + AI )} @@ -275,7 +276,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin {isSuperAdmin && ( - + AI )} @@ -308,6 +309,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin {isSuperAdmin && ( + AI Configuration @@ -319,11 +321,13 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin + )} + @@ -353,9 +357,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin + + Platform Branding @@ -367,10 +373,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin + {isSuperAdmin && ( + Email Configuration @@ -382,10 +390,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin + )} + Notification Email Settings @@ -397,10 +407,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin + {isSuperAdmin && ( + File Storage @@ -412,11 +424,13 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin + )} {isSuperAdmin && ( + Security Settings @@ -428,10 +442,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin + )} + Default Settings @@ -443,9 +459,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin + + Digest Configuration @@ -457,9 +475,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin + + Analytics & Reports @@ -471,9 +491,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin + + Audit & Security @@ -485,9 +507,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin + + Localization @@ -499,6 +523,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin +
{/* end content area */}
{/* end lg:flex */} @@ -506,7 +531,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin {/* Quick Links to sub-pages */}
- + @@ -528,7 +553,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin {isSuperAdmin && ( - + diff --git a/src/components/shared/empty-state.tsx b/src/components/shared/empty-state.tsx index d0926fa..1d57e96 100644 --- a/src/components/shared/empty-state.tsx +++ b/src/components/shared/empty-state.tsx @@ -28,7 +28,9 @@ export function EmptyState({ className )} > - +
+ +

{title}

{description && (

diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx index 03070b4..d6e660a 100644 --- a/src/components/ui/progress.tsx +++ b/src/components/ui/progress.tsx @@ -6,8 +6,10 @@ import { cn } from '@/lib/utils' const Progress = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, value, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + gradient?: boolean + } +>(({ className, value, gradient, ...props }, ref) => ( diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts index bda385d..d3b04ba 100644 --- a/src/server/routers/analytics.ts +++ b/src/server/routers/analytics.ts @@ -1,18 +1,41 @@ import { z } from 'zod' import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc' +// Shared input schema: either roundId or programId (for entire edition) +const editionOrRoundInput = z.object({ + roundId: z.string().optional(), + programId: z.string().optional(), +}).refine(data => data.roundId || data.programId, { + message: 'Either roundId or programId is required', +}) + +// Build Prisma where-clauses from the shared input +function projectWhere(input: { roundId?: string; programId?: string }) { + if (input.roundId) return { roundId: input.roundId } + return { programId: input.programId! } +} + +function assignmentWhere(input: { roundId?: string; programId?: string }) { + if (input.roundId) return { roundId: input.roundId } + return { round: { programId: input.programId! } } +} + +function evalWhere(input: { roundId?: string; programId?: string }, extra: Record = {}) { + const base = input.roundId + ? { assignment: { roundId: input.roundId } } + : { assignment: { round: { programId: input.programId! } } } + return { ...base, ...extra } +} + export const analyticsRouter = router({ /** * Get score distribution for a round (histogram data) */ getScoreDistribution: observerProcedure - .input(z.object({ roundId: z.string() })) + .input(editionOrRoundInput) .query(async ({ ctx, input }) => { const evaluations = await ctx.prisma.evaluation.findMany({ - where: { - assignment: { roundId: input.roundId }, - status: 'SUBMITTED', - }, + where: evalWhere(input, { status: 'SUBMITTED' }), select: { criterionScoresJson: true, }, @@ -51,13 +74,10 @@ export const analyticsRouter = router({ * Get evaluation completion over time (timeline data) */ getEvaluationTimeline: observerProcedure - .input(z.object({ roundId: z.string() })) + .input(editionOrRoundInput) .query(async ({ ctx, input }) => { const evaluations = await ctx.prisma.evaluation.findMany({ - where: { - assignment: { roundId: input.roundId }, - status: 'SUBMITTED', - }, + where: evalWhere(input, { status: 'SUBMITTED' }), select: { submittedAt: true, }, @@ -97,10 +117,10 @@ export const analyticsRouter = router({ * Get juror workload distribution */ getJurorWorkload: observerProcedure - .input(z.object({ roundId: z.string() })) + .input(editionOrRoundInput) .query(async ({ ctx, input }) => { const assignments = await ctx.prisma.assignment.findMany({ - where: { roundId: input.roundId }, + where: assignmentWhere(input), include: { user: { select: { name: true, email: true } }, evaluation: { @@ -146,10 +166,10 @@ export const analyticsRouter = router({ * Get project rankings with average scores */ getProjectRankings: observerProcedure - .input(z.object({ roundId: z.string(), limit: z.number().optional() })) + .input(editionOrRoundInput.and(z.object({ limit: z.number().optional() }))) .query(async ({ ctx, input }) => { const projects = await ctx.prisma.project.findMany({ - where: { roundId: input.roundId }, + where: projectWhere(input), select: { id: true, title: true, @@ -214,11 +234,11 @@ export const analyticsRouter = router({ * Get status breakdown (pie chart data) */ getStatusBreakdown: observerProcedure - .input(z.object({ roundId: z.string() })) + .input(editionOrRoundInput) .query(async ({ ctx, input }) => { const projects = await ctx.prisma.project.groupBy({ by: ['status'], - where: { roundId: input.roundId }, + where: projectWhere(input), _count: true, }) @@ -232,7 +252,7 @@ export const analyticsRouter = router({ * Get overview stats for dashboard */ getOverviewStats: observerProcedure - .input(z.object({ roundId: z.string() })) + .input(editionOrRoundInput) .query(async ({ ctx, input }) => { const [ projectCount, @@ -241,21 +261,18 @@ export const analyticsRouter = router({ jurorCount, statusCounts, ] = await Promise.all([ - ctx.prisma.project.count({ where: { roundId: input.roundId } }), - ctx.prisma.assignment.count({ where: { roundId: input.roundId } }), + ctx.prisma.project.count({ where: projectWhere(input) }), + ctx.prisma.assignment.count({ where: assignmentWhere(input) }), ctx.prisma.evaluation.count({ - where: { - assignment: { roundId: input.roundId }, - status: 'SUBMITTED', - }, + where: evalWhere(input, { status: 'SUBMITTED' }), }), ctx.prisma.assignment.groupBy({ by: ['userId'], - where: { roundId: input.roundId }, + where: assignmentWhere(input), }), ctx.prisma.project.groupBy({ by: ['status'], - where: { roundId: input.roundId }, + where: projectWhere(input), _count: true, }), ]) @@ -282,33 +299,44 @@ export const analyticsRouter = router({ * Get criteria-level score distribution */ getCriteriaScores: observerProcedure - .input(z.object({ roundId: z.string() })) + .input(editionOrRoundInput) .query(async ({ ctx, input }) => { - // Get active evaluation form for this round - const evaluationForm = await ctx.prisma.evaluationForm.findFirst({ - where: { roundId: input.roundId, isActive: true }, + // Get active evaluation forms — either for a specific round or all rounds in the edition + const formWhere = input.roundId + ? { roundId: input.roundId, isActive: true } + : { round: { programId: input.programId! }, isActive: true } + + const evaluationForms = await ctx.prisma.evaluationForm.findMany({ + where: formWhere, }) - if (!evaluationForm?.criteriaJson) { + if (!evaluationForms.length) { return [] } - // Parse criteria from JSON - const criteria = evaluationForm.criteriaJson as Array<{ - id: string - label: string - }> + // Merge criteria from all forms (deduplicate by label for edition-wide) + const criteriaMap = new Map() + evaluationForms.forEach((form) => { + const criteria = form.criteriaJson as Array<{ id: string; label: string }> | null + if (criteria) { + criteria.forEach((c) => { + // Use label as dedup key for edition-wide, id for single round + const key = input.roundId ? c.id : c.label + if (!criteriaMap.has(key)) { + criteriaMap.set(key, c) + } + }) + } + }) - if (!criteria || criteria.length === 0) { + const criteria = Array.from(criteriaMap.values()) + if (criteria.length === 0) { return [] } // Get all evaluations const evaluations = await ctx.prisma.evaluation.findMany({ - where: { - assignment: { roundId: input.roundId }, - status: 'SUBMITTED', - }, + where: evalWhere(input, { status: 'SUBMITTED' }), select: { criterionScoresJson: true }, }) @@ -441,13 +469,10 @@ export const analyticsRouter = router({ * Get juror consistency metrics for a round */ getJurorConsistency: observerProcedure - .input(z.object({ roundId: z.string() })) + .input(editionOrRoundInput) .query(async ({ ctx, input }) => { const evaluations = await ctx.prisma.evaluation.findMany({ - where: { - assignment: { roundId: input.roundId }, - status: 'SUBMITTED', - }, + where: evalWhere(input, { status: 'SUBMITTED' }), include: { assignment: { include: { @@ -513,10 +538,10 @@ export const analyticsRouter = router({ * Get diversity metrics for projects in a round */ getDiversityMetrics: observerProcedure - .input(z.object({ roundId: z.string() })) + .input(editionOrRoundInput) .query(async ({ ctx, input }) => { const projects = await ctx.prisma.project.findMany({ - where: { roundId: input.roundId }, + where: projectWhere(input), select: { country: true, competitionCategory: true, diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index 1811754..c76c59b 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -1,12 +1,19 @@ +import crypto from 'crypto' import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, publicProcedure, protectedProcedure } from '../trpc' import { getPresignedUrl } from '@/lib/minio' +import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email' import { logAudit } from '@/server/utils/audit' import { createNotification } from '../services/in-app-notification' // Bucket for applicant submissions export const SUBMISSIONS_BUCKET = 'mopc-submissions' +const TEAM_INVITE_TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000 // 30 days + +function generateInviteToken(): string { + return crypto.randomBytes(32).toString('hex') +} export const applicantRouter = router({ /** @@ -775,6 +782,8 @@ export const applicantRouter = router({ }) ) .mutation(async ({ ctx, input }) => { + const normalizedEmail = input.email.trim().toLowerCase() + // Verify user is team lead const project = await ctx.prisma.project.findFirst({ where: { @@ -804,7 +813,7 @@ export const applicantRouter = router({ const existingMember = await ctx.prisma.teamMember.findFirst({ where: { projectId: input.projectId, - user: { email: input.email }, + user: { email: normalizedEmail }, }, }) @@ -817,13 +826,13 @@ export const applicantRouter = router({ // Find or create user let user = await ctx.prisma.user.findUnique({ - where: { email: input.email }, + where: { email: normalizedEmail }, }) if (!user) { user = await ctx.prisma.user.create({ data: { - email: input.email, + email: normalizedEmail, name: input.name, role: 'APPLICANT', status: 'NONE', @@ -831,6 +840,77 @@ export const applicantRouter = router({ }) } + if (user.status === 'SUSPENDED') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'This user account is suspended and cannot be invited', + }) + } + + const teamLeadName = ctx.user.name?.trim() || 'A team lead' + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' + const requiresAccountSetup = user.status !== 'ACTIVE' + + try { + if (requiresAccountSetup) { + const token = generateInviteToken() + await ctx.prisma.user.update({ + where: { id: user.id }, + data: { + status: 'INVITED', + inviteToken: token, + inviteTokenExpiresAt: new Date(Date.now() + TEAM_INVITE_TOKEN_EXPIRY_MS), + }, + }) + + const inviteUrl = `${baseUrl}/accept-invite?token=${token}` + await sendTeamMemberInviteEmail( + user.email, + user.name || input.name, + project.title, + teamLeadName, + inviteUrl + ) + } else { + await sendStyledNotificationEmail( + user.email, + user.name || input.name, + 'TEAM_INVITATION', + { + title: 'You were added to a project team', + message: `${teamLeadName} added you to the project "${project.title}".`, + linkUrl: `${baseUrl}/applicant/team`, + linkLabel: 'Open Team', + metadata: { + projectId: project.id, + projectName: project.title, + }, + }, + `You've been added to "${project.title}"` + ) + } + } catch (error) { + try { + await ctx.prisma.notificationLog.create({ + data: { + userId: user.id, + channel: 'EMAIL', + provider: 'SMTP', + type: 'TEAM_INVITATION', + status: 'FAILED', + errorMsg: error instanceof Error ? error.message : 'Unknown error', + }, + }) + } catch { + // Never fail on notification logging + } + + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to send invitation email. Please try again.', + }) + } + // Create team membership const teamMember = await ctx.prisma.teamMember.create({ data: { @@ -846,9 +926,43 @@ export const applicantRouter = router({ }, }) - // TODO: Send invitation email to the new team member + try { + await ctx.prisma.notificationLog.create({ + data: { + userId: user.id, + channel: 'EMAIL', + provider: 'SMTP', + type: 'TEAM_INVITATION', + status: 'SENT', + }, + }) + } catch { + // Never fail on notification logging + } - return teamMember + try { + await createNotification({ + userId: user.id, + type: 'TEAM_INVITATION', + title: 'Team Invitation', + message: `${teamLeadName} added you to "${project.title}"`, + linkUrl: '/applicant/team', + linkLabel: 'View Team', + priority: 'normal', + metadata: { + projectId: project.id, + projectName: project.title, + }, + }) + } catch { + // Never fail invitation flow on in-app notification issues + } + + return { + teamMember, + inviteEmailSent: true, + requiresAccountSetup, + } }), /** diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts index f53bbc7..b199b1a 100644 --- a/src/server/routers/round.ts +++ b/src/server/routers/round.ts @@ -87,18 +87,44 @@ export const roundRouter = router({ }, }) - // Get evaluation stats - const evaluationStats = await ctx.prisma.evaluation.groupBy({ - by: ['status'], - where: { - assignment: { roundId: input.id }, + // Get evaluation stats + progress in parallel (avoids duplicate groupBy in getProgress) + const [evaluationStats, totalAssignments, completedAssignments] = + await Promise.all([ + ctx.prisma.evaluation.groupBy({ + by: ['status'], + where: { + assignment: { roundId: input.id }, + }, + _count: true, + }), + ctx.prisma.assignment.count({ where: { roundId: input.id } }), + ctx.prisma.assignment.count({ + where: { roundId: input.id, isCompleted: true }, + }), + ]) + + const evaluationsByStatus = evaluationStats.reduce( + (acc, curr) => { + acc[curr.status] = curr._count + return acc }, - _count: true, - }) + {} as Record + ) return { ...round, evaluationStats, + // Inline progress data (eliminates need for separate getProgress call) + progress: { + totalProjects: round._count.projects, + totalAssignments, + completedAssignments, + completionPercentage: + totalAssignments > 0 + ? Math.round((completedAssignments / totalAssignments) * 100) + : 0, + evaluationsByStatus, + }, } }), diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts index c15db46..99fcd55 100644 --- a/src/server/routers/specialAward.ts +++ b/src/server/routers/specialAward.ts @@ -525,32 +525,31 @@ export const specialAwardRouter = router({ }) } - const award = await ctx.prisma.specialAward.findUniqueOrThrow({ - where: { id: input.awardId }, - }) - - // Get eligible projects - const eligibleProjects = await ctx.prisma.awardEligibility.findMany({ - where: { awardId: input.awardId, eligible: true }, - include: { - project: { - select: { - id: true, - title: true, - teamName: true, - description: true, - competitionCategory: true, - country: true, - tags: true, + // Fetch award, eligible projects, and votes in parallel + const [award, eligibleProjects, myVotes] = await Promise.all([ + ctx.prisma.specialAward.findUniqueOrThrow({ + where: { id: input.awardId }, + }), + ctx.prisma.awardEligibility.findMany({ + where: { awardId: input.awardId, eligible: true }, + include: { + project: { + select: { + id: true, + title: true, + teamName: true, + description: true, + competitionCategory: true, + country: true, + tags: true, + }, }, }, - }, - }) - - // Get user's existing votes - const myVotes = await ctx.prisma.awardVote.findMany({ - where: { awardId: input.awardId, userId: ctx.user.id }, - }) + }), + ctx.prisma.awardVote.findMany({ + where: { awardId: input.awardId, userId: ctx.user.id }, + }), + ]) return { award, @@ -646,25 +645,25 @@ export const specialAwardRouter = router({ getVoteResults: adminProcedure .input(z.object({ awardId: z.string() })) .query(async ({ ctx, input }) => { - const award = await ctx.prisma.specialAward.findUniqueOrThrow({ - where: { id: input.awardId }, - }) - - const votes = await ctx.prisma.awardVote.findMany({ - where: { awardId: input.awardId }, - include: { - project: { - select: { id: true, title: true, teamName: true }, + const [award, votes, jurorCount] = await Promise.all([ + ctx.prisma.specialAward.findUniqueOrThrow({ + where: { id: input.awardId }, + }), + ctx.prisma.awardVote.findMany({ + where: { awardId: input.awardId }, + include: { + project: { + select: { id: true, title: true, teamName: true }, + }, + user: { + select: { id: true, name: true, email: true }, + }, }, - user: { - select: { id: true, name: true, email: true }, - }, - }, - }) - - const jurorCount = await ctx.prisma.awardJuror.count({ - where: { awardId: input.awardId }, - }) + }), + ctx.prisma.awardJuror.count({ + where: { awardId: input.awardId }, + }), + ]) const votedJurorCount = new Set(votes.map((v) => v.userId)).size diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index 923291f..ccd14c0 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -485,6 +485,7 @@ export const userRouter = router({ .optional(), }) ), + sendInvitation: z.boolean().default(true), }) ) .mutation(async ({ ctx, input }) => { @@ -544,7 +545,7 @@ export const userRouter = router({ name: u.name, role: u.role, expertiseTags: u.expertiseTags, - status: 'INVITED', + status: input.sendInvitation ? 'INVITED' : 'NONE', })), }) @@ -559,8 +560,7 @@ export const userRouter = router({ userAgent: ctx.userAgent, }) - // Auto-send invitation emails to newly created users - const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' + // Fetch newly created users for assignments and optional invitation emails const createdUsers = await ctx.prisma.user.findMany({ where: { email: { in: newUsers.map((u) => u.email.toLowerCase()) } }, select: { id: true, email: true, name: true, role: true }, @@ -603,49 +603,54 @@ export const userRouter = router({ }) } + // Send invitation emails if requested let emailsSent = 0 const emailErrors: string[] = [] - for (const user of createdUsers) { - try { - const token = generateInviteToken() - await ctx.prisma.user.update({ - where: { id: user.id }, - data: { - inviteToken: token, - inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS), - }, - }) + if (input.sendInvitation) { + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' - const inviteUrl = `${baseUrl}/accept-invite?token=${token}` - await sendInvitationEmail(user.email, user.name, inviteUrl, user.role) + for (const user of createdUsers) { + try { + const token = generateInviteToken() + await ctx.prisma.user.update({ + where: { id: user.id }, + data: { + inviteToken: token, + inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS), + }, + }) - await ctx.prisma.notificationLog.create({ - data: { - userId: user.id, - channel: 'EMAIL', - provider: 'SMTP', - type: 'JURY_INVITATION', - status: 'SENT', - }, - }) - emailsSent++ - } catch (e) { - emailErrors.push(user.email) - await ctx.prisma.notificationLog.create({ - data: { - userId: user.id, - channel: 'EMAIL', - provider: 'SMTP', - type: 'JURY_INVITATION', - status: 'FAILED', - errorMsg: e instanceof Error ? e.message : 'Unknown error', - }, - }) + const inviteUrl = `${baseUrl}/accept-invite?token=${token}` + await sendInvitationEmail(user.email, user.name, inviteUrl, user.role) + + await ctx.prisma.notificationLog.create({ + data: { + userId: user.id, + channel: 'EMAIL', + provider: 'SMTP', + type: 'JURY_INVITATION', + status: 'SENT', + }, + }) + emailsSent++ + } catch (e) { + emailErrors.push(user.email) + await ctx.prisma.notificationLog.create({ + data: { + userId: user.id, + channel: 'EMAIL', + provider: 'SMTP', + type: 'JURY_INVITATION', + status: 'FAILED', + errorMsg: e instanceof Error ? e.message : 'Unknown error', + }, + }) + } } } - return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated } + return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated, invitationSent: input.sendInvitation } }), /**