Compare commits

..

97 Commits

Author SHA1 Message Date
6b40fe7726 refactor: tech debt batch 3 — type safety + assignment router split
All checks were successful
Build and Push Docker Image / build (push) Successful in 13m4s
#5 — Replaced 55x PrismaClient | any with proper Prisma types across 8 files
- Service files: PrismaClient | any → PrismaClient, tx: any → Prisma.TransactionClient
- Fixed 4 real bugs uncovered by typing:
  - mentor-workspace.ts: wrong FK fields (mentorAssignmentId → workspaceId, role → senderRole)
  - ai-shortlist.ts: untyped string passed to CompetitionCategory enum filter
  - result-lock.ts: unknown passed where Prisma.InputJsonValue required

#9 — Split assignment.ts (2,775 lines) into 6 focused files:
  - shared.ts (93 lines) — MOVABLE_EVAL_STATUSES, buildBatchNotifications, getCandidateJurors
  - assignment-crud.ts (473 lines) — 8 core CRUD procedures
  - assignment-suggestions.ts (880 lines) — AI suggestions + job runner
  - assignment-notifications.ts (138 lines) — 2 notification procedures
  - assignment-redistribution.ts (1,162 lines) — 8 reassign/transfer procedures
  - index.ts (15 lines) — barrel export with router merge, zero frontend changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:47:06 +01:00
1c78ecf21d refactor: tech debt batch 2 — drop dead models, stale columns, schema cleanup
Schema:
- Drop 4 dead models: OverrideAction, NotificationPolicy, AssignmentException, AdvancementRule
- Drop 2 dead enums: OverrideReasonCode, AdvancementRuleType
- Drop 3 stale columns: Project.roundId, ConflictOfInterest.roundId, Evaluation.version
- Remove 3 back-relation fields from User, Assignment, Round

Code:
- Fix 6 COI queries in assignment.ts + 1 in juror-reassignment.ts
  (roundId filter → assignment.roundId after column drop)
- Remove orphaned Project.roundId write in project.ts createProject
- Remove advancementRules include from round.ts getById
- Remove AdvancementRule from RoundWithRelations type
- Clean up seed.ts (remove advancement rule seeding)
- Clean up tests/helpers.ts (remove dead model cleanup)
- Add TODO comments on user delete mutations (FK violation risk)

Migration: 20260308000000_drop_dead_models_and_stale_columns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:35:23 +01:00
1356809cb1 fix: tech debt batch 1 — TS errors, vulnerabilities, dead code
- Fixed 12 TypeScript errors across analytics.ts, observer-project-detail.tsx, bulk-upload/page.tsx, settings/profile/page.tsx
- npm audit: 8 vulnerabilities resolved (1 critical, 4 high, 3 moderate)
- Deleted 3 dead files: live-control.ts (618 lines), feature-flags.ts, file-type-categories.ts
- Removed typescript.ignoreBuildErrors: true — TS errors now block builds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:51:44 +01:00
1ebdf5f9c9 fix: batch 5 — input validation tightening + health check endpoint
- z.any() replaced with z.record(z.string()) on webhook headers
- availabilityJson typed with z.array(z.object({ start, end }))
- Frontend webhook headers converted from array to Record before API call
- Docker HEALTHCHECK added to Dockerfile (health endpoint already existed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 18:26:28 +01:00
a68ec3fb45 fix: batch 4 — connection pooling, graceful shutdown, email verification UX
- Prisma: connection_limit=10, pool_timeout=30 on DATABASE_URL in both compose files
- Graceful shutdown: SIGTERM/SIGINT forwarded to Node process in docker-entrypoint.sh
- testEmailConnection: replaced real email send with transporter.verify(), simplified UI to single button
- NotificationLog.userId index: confirmed already present, no change needed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 18:16:29 +01:00
6f55fdf81f fix: batch 3 — webhook HMAC documentation + CSRF rate limiting
- Webhook HMAC: added consumer verification JSDoc with Node.js example using crypto.timingSafeEqual
- CSRF rate limiting: 20 requests/15min per IP on NextAuth /csrf endpoint
- Renamed withRateLimit to withPostRateLimit/withGetRateLimit for clarity
- 429 responses include Retry-After header

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 18:05:42 +01:00
94cbfec70a fix: email XSS sanitization, bulk invite concurrency, error handling (code review batch 2)
- Add escapeHtml() helper and apply to all user-supplied variables in 20+ HTML email templates
- Auto-escape in sectionTitle() and statCard() helpers for defense-in-depth
- Replace 5 instances of incomplete manual escaping with escapeHtml()
- Refactor bulkInviteTeamMembers: batch all DB writes in $transaction, then send emails via Promise.allSettled with concurrency pool of 10
- Fix inner catch block in award-eligibility-job.ts to capture its own error variable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:59:56 +01:00
b85a9b9a7b fix: security hardening + performance refactoring (code review batch 1)
- IDOR fix: deliberation vote now verifies juryMemberId === ctx.user.id
- Rate limiting: tRPC middleware (100/min), AI endpoints (5/hr), auth IP-based (10/15min)
- 6 compound indexes added to Prisma schema
- N+1 eliminated in processRoundClose (batch updateMany/createMany)
- N+1 eliminated in batchCheckRequirementsAndTransition (3 batch queries)
- Service extraction: juror-reassignment.ts (578 lines)
- Dead code removed: award.ts, cohort.ts, decision.ts (680 lines)
- 35 bare catch blocks replaced across 16 files
- Fire-and-forget async calls fixed
- Notification false positive bug fixed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:18:24 +01:00
a8b8643936 feat: group observer project files by round
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m15s
Files on the observer project detail page are now grouped by round
(e.g., "Application Intake", "Semi-Finals Document Submission") instead
of shown in a flat list. Uses FileViewer's existing groupedFiles prop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:24:33 +01:00
0390d05727 fix: submission round completion %, document details, project teams UX
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m32s
- Fix 0% completion on SUBMISSION pipeline cards — now based on teams
  with uploads / total projects instead of evaluation completions
- Add page count, detected language, and non-English warning indicator
  to Recent Documents list items
- Add project avatar (logo) to document and project list rows
- Make document rows clickable into project detail page
- Remove team name from project list (none have names), show country only
- Rename "Teams Submitted" to "Teams with Uploads" for clarity
- Add "See all" link to Projects section → /observer/projects
- Rename section from "Project Teams" to "Projects"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:50:51 +01:00
ec30dc83d6 feat: country flag display in remaining app pages (mentor, jury, admin, applicant)
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
Extends CountryDisplay component usage to all remaining pages that showed
raw country codes: mentor dashboard/projects, jury competitions/awards,
admin awards/project detail, applicant team, and project-list-compact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:07:40 +01:00
37351044ed feat: multi-role jury fix, country flags, applicant deadline banner, timeline
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Fix project list returning empty for users with both SUPER_ADMIN and
  JURY_MEMBER roles (jury filter now skips admins) in project, assignment,
  and evaluation routers
- Add CountryDisplay component showing flag emoji + name everywhere
  country is displayed (admin, observer, jury, mentor views — 17 files)
- Add countdown deadline banner on applicant dashboard for INTAKE,
  SUBMISSION, and MENTORING rounds with live timer
- Remove quick action buttons from applicant dashboard
- Fix competition timeline sidebar: green dots/connectors only up to
  current round, yellow dot for current round, red connector into
  rejected round, grey after

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:00:29 +01:00
a1e758bc39 feat: router.back() navigation, read-only evaluation view, auth audit logging
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m53s
- Convert all Back buttons platform-wide (38 files) to use router.back()
  for natural browser-back behavior regardless of entry point
- Add read-only view for submitted evaluations in closed rounds with
  blue banner, disabled inputs, and contextual back navigation
- Add auth audit logs: MAGIC_LINK_SENT, PASSWORD_RESET_LINK_CLICKED,
  PASSWORD_RESET_LINK_EXPIRED, PASSWORD_RESET_LINK_INVALID
- Learning Hub links navigate in same window for all roles
- Update settings descriptions to reflect all-user scope

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:25:56 +01:00
a556732b46 feat: observer UX overhaul — reports, projects, charts, session & email
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m2s
- Observer projects: default sort by status (rejected last), sortable status column
- Observer projects: search by country, institution, geographic zone
- Observer project detail: vertical timeline connectors between rounds
- Fix React key warning in ExpandableJurorTable and FilteringReportTabs
- Fix ScoreBadge text always white for better contrast on all backgrounds
- Remove misleading /30 denominator from heatmap juror reviewed count
- INTAKE stats: show Start-ups, Business Concepts, Countries (not States/Categories)
- DiversityMetrics: extractCountry() for country-only display in charts
- Fix nested button hydration error in filtering report mobile view
- Color project titles by outcome in filtering report (green/red/amber)
- Redesign CrossStageComparisonChart: funnel viz + metrics table with attrition %
- Center doughnut chart in StatusBreakdownChart
- Remove redundant RoundTypeStatsCards from evaluation report
- Move evaluation tab bar below overview header, rename to "Juror Assignments"
- Dev email override system (DEV_EMAIL_OVERRIDE env var)
- Session refresh on role change without re-login
- Role switcher in user dropdown menu
- formatCategory() utility for consistent category display
- Activity feed max height constraint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:37:50 +01:00
e7b99fff63 feat: multi-round messaging, login logo, applicant seed user
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m40s
- Communication hub now supports selecting multiple rounds when sending
  to Round Jury or Round Applicants (checkbox list instead of dropdown)
- Recipients are unioned across selected rounds with deduplication
- Recipient details grouped by round when multiple rounds selected
- Added MOPC logo above "Welcome back" on login page
- Added matt@letsbe.solutions as seeded APPLICANT account

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:22:01 +01:00
3180bfa946 feat: document language checker on round overview
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m15s
- New roundLanguageSummary query in file router aggregates per-round document
  language data from existing detectedLang/langConfidence fields
- Document Languages card on round overview tab shows analysis status and
  flags non-English documents grouped by project with confidence scores
- Green border when all documents are English, amber when issues detected
- Project names link to project detail page for easy navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:53:43 +01:00
d4c946470a feat: audit log clickable links, communication hub recipient details & link options
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Audit log: user names link to /admin/members/{id}, entity IDs link to
  relevant detail pages (projects, rounds, awards, users)
- Communication hub: expandable "View Recipients" section in sidebar shows
  actual users/projects that will receive the message, with collapsible
  project-level detail for applicants and juror assignment counts
- Email link type selector: choose between no link, messages inbox, login
  page, or invite/accept link (auto-detects new vs existing members)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:49:49 +01:00
2e8ab91e07 fix: version guard uses static file, members table shows project name with round badge
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m12s
Version guard:
- Replace API route with prebuild-generated public/build-id.json
- Captures build ID on first load, only notifies on mismatch
- Fixes false positive refresh prompts from env mismatch

Members table (applicants):
- Show project name + round badge instead of round name + state
- Red badge for rejected, gray for withdrawn, green for passed,
  outline for active rounds
- Include projectName in applicantRoundInfo from backend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:23:24 +01:00
60426c1f56 feat: expired link UX — auto-redirect to login with friendly notice
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- /error?error=Verification: shows "Link Expired" with amber icon,
  auto-redirects to /login?expired=1 after 5 seconds
- /accept-invite: expired/invalid/already-accepted tokens auto-redirect
  to login after 4 seconds with "Redirecting..." message
- /login: amber banner when ?expired=1 explains the link expired and
  prompts to sign in again or request a new magic link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:15:33 +01:00
8427999578 feat: version guard — prompt stale clients to refresh after deploys
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m42s
- Inject NEXT_PUBLIC_BUILD_ID at build time via next.config.ts
- /api/version static route returns current build ID
- VersionGuard client component checks on tab focus + every 5 min
- Shows persistent toast with Refresh button (no auto-reload)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:54:52 +01:00
a358e9940d feat: revamp admin member detail page, observer dashboard round timeline
All checks were successful
Build and Push Docker Image / build (push) Successful in 13m37s
- Member detail: tabs layout, impersonate button, icon-pill card headers,
  profile details grid, quick info sidebar, jury groups, mentor assignments
- Observer dashboard: round timeline with special award support,
  round node cards, completion indicators
- Analytics: include specialAwardId/Name in observer round overview

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:39:21 +01:00
34fc0b81e0 feat: revamp communication hub with recipient preview and state filtering
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- New previewRecipients query shows live project/applicant counts as you
  compose, with per-state breakdown for Round Applicants
- Exclude Rejected/Withdrawn checkboxes filter out terminal-state projects
- Compose form now has 2/3 + 1/3 layout with always-visible recipient
  summary sidebar showing project counts, applicant counts, state badges
- Preview dialog enlarged (max-w-3xl) with split layout: email preview
  on left, recipient/delivery summary on right
- Send button now shows recipient count ("Send to N Recipients")
- resolveRecipients accepts excludeStates param for ROUND_APPLICANTS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:32:03 +01:00
ea46d7293f feat: show applicant's current round instead of assignments in members table
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
For APPLICANT users in the admin members list, the Assignments column now
shows the project's current round name and state badge (Active, Pending,
Rejected, etc.) instead of "0 assigned".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:22:40 +01:00
0d9a985377 feat: revamp applicant jury feedback UI with score summaries and observer-style design
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Dashboard sidebar card now shows per-round avg score with progress bars.
Evaluations page redesigned with stats strip, score comparison bars,
criteria progress bars, animated cards, and styled feedback blocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:16:37 +01:00
6852278f92 fix: group project files by round in admin project detail
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m15s
- Sort files by round sortOrder first (via requirement.round.sortOrder)
- Admin project detail now uses grouped file view with round headers
- Files without a round requirement appear under "General" group

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:36:13 +01:00
22731e7978 fix: build speed, observer AI details, round tracker empty state
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Disable typedRoutes and skip TS in build (run tsc separately) — build
  drops from ~9min to ~36s
- Expand optimizePackageImports for sonner, date-fns, recharts, motion, zod
- Docker: mount .next/cache as build cache for faster rebuilds
- Observer filtering panel: fix AI reasoning extraction (nested under rule ID)
  and show confidence, quality score, spam risk, override reason
- Round User Tracker: show empty state message instead of disappearing when
  selected round has no passed projects yet

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:30:11 +01:00
0d94ee1fe8 feat: clickable status badges, observer status alignment, CSV export all
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m36s
- Admin projects: status summary badges are clickable to filter by round state
  with ring highlight, opacity fade, and clear button
- Add roundStates filter param to project.list backend query
  (filters by latest round state per project, consistent with counts)
- Observer status dropdown now uses ProjectRoundState values
  (Pending/In Progress/Completed/Passed/Rejected/Withdrawn)
- Observer status derived from latest ProjectRoundState instead of stale Project.status
- Observer CSV export fetches all matching projects, not just current page
- Add PENDING and PASSED styles to StatusBadge component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:19:12 +01:00
ffe12a9e85 feat: applicant dashboard — team cards, editable description, feedback visibility
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m20s
- Replace flat team names list with proper cards showing roles and badges
- Hide TeamMembers from metadata display, remove Withdraw from header
- Add inline-editable project description (admin-toggleable setting)
- Move applicant feedback visibility from per-round config to admin settings
- Support EVALUATION, LIVE_FINAL, DELIBERATION round types in feedback
- Backwards-compatible: falls back to old per-round config if no settings exist
- Add observer team tab toggle and 10 new SystemSettings seed entries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:08:19 +01:00
94814bd505 feat: observer team tab, admin-controlled applicant feedback visibility
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m13s
- Add Team tab to observer project detail (configurable via admin settings)
- Move applicant jury feedback visibility from per-round config to admin settings
- Add per-round-type controls: evaluation, live final, deliberation
- Support anonymous LiveVote and DeliberationVote display for applicants
- Add fine-grained toggles: scores, criteria, written feedback, hide from rejected
- Backwards compatible: falls back to old per-round config if admin settings not set
- New admin settings section under Analytics tab with all visibility controls
- Seed new SystemSettings keys for observer/applicant visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:50:20 +01:00
6b6f5e33f5 fix: admin role change, logo access, magic link validation, login help
- Add updateTeamMemberRole mutation for admins to change team member roles
- Allow any team member (not just lead) to change project logo
- Add visible "Add logo"/"Change" label under logo for discoverability
- Pre-check email existence before sending magic link (show error)
- Add "forgot which email" contact link on login page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:37:45 +01:00
67670472f7 fix: batch email sending in message system to avoid overloading SMTP
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m27s
Messages were sent in a tight for-loop with no throttling. Now uses
sendBatchNotifications (10/batch, 150ms per email, 500ms between
batches) and fires in the background so the admin gets instant response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:07:52 +01:00
461551b489 fix: separate main pipeline from award tracks on rounds page
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m24s
Award-specific rounds (e.g. Spotlight on Africa) were mixed into the
main pipeline as a flat list, making them look like sequential steps
after Deliberation. Now they render in their own amber-tinted card
sections below the main pipeline, each with a header showing award
name, pool size, eligibility mode, and status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:55:17 +01:00
b7905a82e1 fix: impersonation, dashboard counter, logo lightbox, submission config, auth logs
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m12s
- Fix impersonation by bypassing useSession().update() loading gate with direct session POST
- Fix dashboard account counter defaulting to latest round with PASSED projects
- Add clickToEnlarge lightbox for project logos on admin detail page
- Remove submission eligibility config (all passed projects must upload)
- Suppress CredentialsSignin auth errors in production (minified name check)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:40:08 +01:00
fd2624f198 feat: fix project status counts, add top pagination and sortable columns
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m39s
- Status counts now show each project's latest round state only
  (no more inflated counts from projects passing multiple rounds)
- Add pagination controls at top of projects, members, and observer lists
- Add sortable column headers to admin projects table (title, category,
  program, assignments, status) and members table (name, role, status,
  last login)
- Backend: add sortBy/sortDir params to project.list and user.list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:49:17 +01:00
2be7318cb9 fix: project status counts now show latest round state per project
Previously counted distinct projects per state across ALL rounds,
inflating counts (e.g., 215 Passed when many were later rejected).
Now picks each project's latest round state (highest sortOrder) to
determine its current status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:44:14 +01:00
8d6f3ca11f fix: dashboard flickering + clickable logo change for applicants
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m7s
- Fix session auto-refresh causing re-render cascade by using useRef
  instead of useState and delaying the refresh by 3s
- Make project logo clickable on dashboard and team page for team leads
  with hover pencil overlay and ProjectLogoUpload dialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:23:27 +01:00
12e4864d36 fix: impersonation logout, applicant learning hub redirect, nav click tracking
- Sign Out button during impersonation now returns to admin instead of
  destroying the session (fixes multi-click logout issue)
- Applicant nav now respects learning_hub_external setting like jury/mentor
- Track Learning Hub nav clicks via audit log (LEARNING_HUB_CLICK action)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:15:59 +01:00
abb6e6df83 feat: inline document preview for applicant documents page
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m28s
Replace "View" (opens new tab) with inline collapsible preview panel.
Supports PDF, video, image, and Office documents using existing
FilePreview component. Download button triggers native download.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:08:39 +01:00
8cdcc85555 feat: round user tracker + fix INVITED status not updating on login
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Replace Semi-Finalist Tracker with Round User Tracker on dashboard
- New getRoundUserStats query: round-aware account activation stats
- Round selector dropdown to view any round's passed projects
- sendAccountReminders now accepts optional roundId for scoped reminders
- Fix: signIn callback now sets status=ACTIVE for INVITED users on login
- DB fix: 5 users who logged in via magic link but stayed INVITED → ACTIVE

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:00:19 +01:00
ee8e90132e feat: forgot password flow, member page fixes, country name display
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m7s
Password reset:
- /forgot-password page: enter email, receive reset link via email
- /reset-password?token=xxx page: set new password with validation
- user.requestPasswordReset: generates token, sends styled email
- user.resetPassword: validates token, hashes new password
- Does NOT trigger re-onboarding — only resets the password
- 30-minute token expiry, cleared after use
- Added passwordResetToken/passwordResetExpiresAt to User model

Member detail page fixes:
- Hide "Expertise & Capacity" card for applicants/audience roles
- Show country names with flag emojis instead of raw ISO codes
- Login "Forgot password?" now links to /forgot-password page

Project detail page:
- Team member details show full country names with flags

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:49:43 +01:00
b6ba5d7145 feat: member profile pages with clickable links from all member lists
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m51s
- Member detail page (/admin/members/[id]) now shows:
  - Profile details card (nationality, country, institution, bio)
  - Team memberships / projects with links to project pages
  - Jury groups with role (Chair/Member/Observer)
  - All roles including Applicant, Award Master, Audience in role selector
- Project detail page team members now show:
  - Nationality, institution, country inline
  - Names are clickable links to member profile pages
- Members list: names are clickable links to profile pages (all tabs)
- Applicants tab: added nationality and institution columns
- Backend: user.get includes teamMemberships and juryGroupMemberships
- Backend: project.getFullDetail includes nationality/country/institution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:29:56 +01:00
c6d0f90038 fix: presigned URL signatures, bucket consolidation, login & invite status
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m44s
- MinIO: use separate public client for presigned URLs so AWS V4 signature
  matches the browser's Host header (fixes SignatureDoesNotMatch on all uploads)
- Consolidate applicant/partner uploads to mopc-files bucket (removes
  non-existent mopc-submissions and mopc-partners buckets)
- Auth: allow magic links for any non-SUSPENDED user (was ACTIVE-only,
  blocking first-time CSV-seeded applicants)
- Auth: accept invite tokens for any non-SUSPENDED user (was INVITED-only)
- Ensure all 14 invite token locations set status to INVITED

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:06:17 +01:00
78334676d0 fix: avatar/logo display diagnostics and upload error handling
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m31s
Add error logging to silent catch blocks in avatar/logo URL generation,
show user avatar on admin member detail page, and surface specific error
messages for upload failures (CORS/network issues) instead of generic errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:11:16 +01:00
335c736219 fix: pipeline selected round ring cutoff by overflow scroll
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m46s
Add vertical padding to the scrollable pipeline container so
the ring-2 highlight on selected rounds isn't clipped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:47:28 +01:00
ca888b4eb7 fix: impersonation navigation uses full page reload
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m59s
Replace router.push() with window.location.href for both
start and end impersonation to ensure the updated JWT cookie
is sent with the new request. Client-side routing can race
with cookie propagation, causing the server to see the old
session and redirect back to admin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:30:46 +01:00
27ecbc40b3 fix: lock down application form when intake round is not active
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
getConfig now throws FORBIDDEN when round is not ROUND_ACTIVE,
preventing the form from loading entirely. Also blocks draft
saving when round is inactive. Defense-in-depth: submit already
rejected inactive rounds, this adds the frontend gate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:26:34 +01:00
875c2e8f48 fix: security hardening — block self-registration, SSE auth, audit logging fixes
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Security fixes:
- Block self-registration via magic link (PrismaAdapter createUser throws)
- Magic links only sent to existing ACTIVE users (prevents enumeration)
- signIn callback rejects non-existent users (defense-in-depth)
- Change schema default role from JURY_MEMBER to APPLICANT
- Add authentication to live-voting SSE stream endpoint
- Fix false FILE_OPENED/FILE_DOWNLOADED audit events on page load
  (remove purpose from eagerly pre-fetched URL queries)

Bug fixes:
- Fix impersonation skeleton screen on applicant dashboard
- Fix onboarding redirect loop in auth layout

Observer dashboard redesign (Steps 1-6):
- Clickable round pipeline with selected round highlighting
- Round-type-specific dashboard panels (intake, filtering, evaluation,
  submission, mentoring, live final, deliberation)
- Enhanced activity feed with server-side humanization
- Previous round comparison section
- New backend queries for round-specific analytics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:18:50 +01:00
13f125af28 feat: error audit middleware, impersonation attribution, account lockout logging
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m13s
- Add withErrorAudit middleware tracking FORBIDDEN/UNAUTHORIZED/NOT_FOUND per user
- Fix impersonation attribution: log real admin ID, prefix IMPERSONATED_ on actions
- Add ACCOUNT_LOCKED audit events on login lockout (distinct from LOGIN_FAILED)
- Audit export of assignments and audit logs (meta-audit gap)
- Update audit page UI with new security event types and colors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 18:28:56 +01:00
c8c26beed2 feat: granular file access audit logging (viewed/opened/downloaded)
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Replace single FILE_DOWNLOADED action with three granular actions:
- FILE_VIEWED: inline preview loaded in the UI
- FILE_OPENED: file opened in a new browser tab
- FILE_DOWNLOADED: explicit download button clicked

Add 'purpose' field to getDownloadUrl input (preview/open/download).
All client callers updated to pass the appropriate purpose.
Audit page updated with new filter options and color mappings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 18:18:28 +01:00
503a375701 fix: only log FILE_DOWNLOADED for actual downloads, not preview URLs
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
getDownloadUrl was logging FILE_DOWNLOADED on every call including
inline previews and thumbnail loads. Now only logs when forDownload
is true (explicit download button click), massively reducing noise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 18:14:39 +01:00
79ac60dc1e feat: automatic mutation audit logging for all non-super-admin users
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Implement withMutationAudit middleware in tRPC that automatically logs
every successful mutation for non-SUPER_ADMIN users. Captures procedure
path, sanitized input (passwords/tokens redacted), user role, IP, and
user agent. Applied to all procedure types except superAdminProcedure.

- Input sanitization: strips sensitive fields, truncates long strings
  (500 chars), limits array size (20 items), caps nesting depth (4)
- Entity ID auto-extraction from common input patterns (id, userId,
  projectId, roundId, etc.)
- Action names derived from procedure path (e.g., evaluation.submit
  becomes EVALUATION_SUBMIT)
- Audit page updated with new action types and entity types for
  filtering auto-generated entries
- Failures silently caught — audit logging never breaks operations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 18:04:52 +01:00
6c52e519e5 feat: impersonation system, semi-finalist detail page, tRPC resilience
- Add super-admin impersonation: "Login As" from user list, red banner
  with "Return to Admin", audit logged start/end, nested impersonation
  blocked, onboarding gate skipped during impersonation
- Fix semi-finalist stats: check latest terminal state (not any PASSED),
  use passwordHash OR status=ACTIVE for activation check
- Add /admin/semi-finalists detail page with search, category/status filters
- Add account_reminder_days setting to notifications tab
- Add tRPC resilience: retry on 503/HTML responses, custom fetch detects
  nginx error pages, exponential backoff (2s/4s/8s)
- Reduce dashboard polling intervals (60s stats, 30s activity, 120s semi)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:55:44 +01:00
b1a994a9d6 fix: enforce onboarding gate for applicants and observers
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m37s
Applicants could bypass onboarding and land directly on the dashboard.
Added onboardingCompletedAt check + redirect to /onboarding in both
the applicant and observer layouts (jury/mentor already had this gate).
Also removed premature status ACTIVE on magic-link first login — now
only completeOnboarding sets ACTIVE.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:00:19 +01:00
f0d5599167 feat: add audit logging for applicant file uploads and deletions
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m59s
Applicant saveFileMetadata and deleteFile mutations now log
APPLICANT_UPLOAD_FILE and APPLICANT_DELETE_FILE to the audit trail,
matching the admin file router's existing audit coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:53:55 +01:00
43e21c6c6e feat: semi-finalist tracker dashboard, account reminders, search + UX fixes
- Add getSemiFinalistStats query with per-category/per-award breakdown
- Add sendAccountReminders mutation with invite token generation and dedup
- Add SemiFinalistTracker dashboard widget with progress bars and remind buttons
- Add ACCOUNT_REMINDER email template
- Extend project search to match team member name/email (7 locations)
- Fix Passed count deduplication: count distinct projects, not round-state rows
- Fix role switcher: visible pills above user section, auto-refresh session on mount

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:41:03 +01:00
af03c12ae5 feat: per-round advancement selection, email preview, Docker/auth fixes
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m42s
- Bulk notification dialog: per-round checkboxes (default none selected),
  selected count badge, "Preview Email" button with rendered iframe
- Backend: roundIds filter on sendBulkPassedNotifications, new
  previewAdvancementEmail query
- Docker: add external MinIO network so app container can reach MinIO
- File router: try/catch on getPresignedUrl with descriptive error
- Auth: custom NextAuth logger suppresses CredentialsSignin stack traces

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:31:01 +01:00
267d26581d feat: resolve project logo URLs server-side, show logos in admin + observer
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m30s
Add attachProjectLogoUrls utility mirroring avatar URL pattern. Pipe
project.list and analytics.getAllProjects through logo URL resolver so
ProjectLogo components receive presigned URLs. Add logos to observer
projects table and mobile cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:29:54 +01:00
a39e27f6ff fix: applicant portal — document uploads, round filtering, auth hardening
Fix round-specific document uploads (submittedAt no longer blocks uploads),
add view/download buttons for existing files, enforce active-round-only for
uploads/deletes. Harden auth layout and set-password page. Filter applicant
portal rounds by award track membership.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:29:39 +01:00
1103d42439 feat: admin UX improvements — notify buttons, eval config, round finalization
Custom body support for advancement/rejection notification emails, evaluation
config toggle fix, user actions improvements, round finalization with reorder
support, project detail page enhancements, award pool duplicate prevention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:29:22 +01:00
f24bea3df2 feat: extend notification system with batch sender, bulk dialog, and logging
Add NotificationLog schema extensions (nullable userId, email, roundId,
projectId, batchId fields), batch notification sender service, and bulk
notification dialog UI. Include utility scripts for debugging and seeding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:29:06 +01:00
8f2f054c57 fix: remove invalid 'reason' field from ProjectStatusHistory.create
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m10s
The field doesn't exist on the model, causing finalization to crash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:55:08 +01:00
5854aa37a9 feat: prevent duplicate award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m1s
Add notifiedAt field to AwardEligibility. notifyEligibleProjects now
skips already-notified entries and stamps notifiedAt after sending,
so re-clicking "Notify Pool" only emails newly added projects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:32:14 +01:00
ebc6331d1f fix: harden award track filtering edge cases in applicant portal
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- getNavFlags/getMyEvaluations: return empty when project has no round
  states instead of dropping the filter (prevented phantom eval rounds)
- getUpcomingDeadlines: detect isInAwardTrack from ProjectRoundState
  join instead of pre-filtered deadline rounds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:27:53 +01:00
d183d98d9a fix: filter applicant portal rounds by award track membership
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Projects in SEPARATE_POOL awards now only see their award rounds (not main
pool rounds) across all applicant queries: openRounds, deadlines, document
completeness, nav flags, and evaluations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:24:33 +01:00
84d90e1978 fix: soften award notification email tone from "selected" to "under consideration"
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m32s
The email was implying projects had won the award. Updated banner, subject,
and body copy to clarify they are being considered, not awarded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:04:28 +01:00
daf50831f1 feat: award round reordering, assign-to-first-round, and applicant timeline for award tracks
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m22s
- Add drag-and-drop round reordering on award detail Rounds tab (dnd-kit)
- Replace "Open Voting" with "Assign to First Round" for SEPARATE_POOL awards
- Add reorderAwardRounds mutation (two-phase transaction for unique constraint)
- Add assignToFirstRound mutation (re-runnable, moves/creates ProjectRoundState)
- Extend applicant timeline to show award-specific rounds for SEPARATE_POOL projects
- Hide irrelevant main competition rounds when project is in award track
- Prefix award round labels with award name in timeline

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:42:21 +01:00
1d4e31ddd1 feat: external Learning Hub toggle + applicant help button
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m52s
- Add admin settings: learning_hub_external, learning_hub_external_url, support_email
- Jury/Mentor nav respects external Learning Hub URL (opens in new tab)
- RoleNav supports external nav items with ExternalLink icon
- Applicant header shows Help button with configurable support email
- Settings update mutation now upserts (creates on first use)
- Shared inferSettingCategory for consistent category assignment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:09:29 +01:00
924f8071e1 feat: add email preview to award notification and finalization tab
- Award "Notify Pool" dialog now uses EmailPreviewDialog with live preview
- Button shows eligible project count: "Notify Pool (38)"
- Finalization tab email section has "Preview" buttons for both
  advancement and rejection messages
- EmailPreviewDialog supports previewOnly mode (close button, no send)
- Backend: previewAwardSelectionEmail, previewFinalizationAdvancementEmail,
  previewFinalizationRejectionEmail queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:57:52 +01:00
f79a6d1341 fix: project edit status dropdown empty for projects with blank status
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m35s
Use || instead of ?? so empty string '' falls back to 'SUBMITTED'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:30:49 +01:00
050836d522 feat: finalization tab respects ranking overrides, grouped by category
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m2s
- processRoundClose now applies reordersJson drag-reorder overrides
  when building the evaluation pass set (was ignoring admin reorders)
- Finalization tab groups proposed outcomes by category (Startup/Concept)
  with per-group pass/reject/total counts
- Added category filter dropdown alongside the existing outcome filter
- Removed legacy "Advance Top N" button and dialog from ranking page
  (replaced by the finalization workflow)
- Fix project edit status defaultValue showing empty placeholder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:10:04 +01:00
43801340f8 fix: add missing round finalization migration
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m49s
The migration adding gracePeriodEndsAt, finalizedAt, finalizedBy to Round
and proposedOutcome to ProjectRoundState was never committed, causing
production to fail with "column does not exist" errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:49:06 +01:00
2be3f9d02f fix: add missing seed-team-leads.ts required by docker entrypoint
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m58s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:36:07 +01:00
cfee3bc8a9 feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
- processRoundClose EVALUATION uses ranking scores + advanceMode config
  (threshold vs count) to auto-set proposedOutcome instead of defaulting all to PASSED
- Advancement emails generate invite tokens for passwordless users with
  "Create Your Account" CTA; rejection emails have no link
- Finalization UI shows account stats (invite vs dashboard link counts)
- Fixed getFinalizationSummary ranking query (was using non-existent rankingsJson)
- New award pool notification system: getAwardSelectionNotificationTemplate email,
  notifyEligibleProjects mutation with invite token generation,
  "Notify Pool" button on award detail page with custom message dialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:14:41 +01:00
7735f3ecdf fix: hide Advancement Targets for non-scoring round types
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m24s
Submission, Intake, and Mentoring rounds don't use score-based
advancement (Fixed Count / Score Threshold). The section is now
only shown for Evaluation and Filtering rounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:47:20 +01:00
0285622fe1 fix: tooltip no longer covers status dropdown menu items
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
The outer Tooltip on the status badge was overlaying the DropdownMenu
content, hiding "Reopen Round". Now the tooltip is forced closed
whenever the dropdown is open.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:43:28 +01:00
c0f2b9bd38 fix: show 0/N Yes instead of "N jurors", color 1/2 Yes amber
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m38s
- Always display yes/no count format (e.g. "0/2 Yes") instead of
  generic "2 jurors" when no advance votes exist
- Color coding: 2/2 Yes = green, 1/2 Yes = amber, 0/2 Yes = red

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:31:13 +01:00
8c5f4998a8 fix: sort ranking display by avgGlobalScore, not compositeScore
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m33s
The display was sorted by compositeScore (which factors in pass rate and
z-score normalization) but the cutoff line and displayed values use
avgGlobalScore. This caused projects with high averages but low pass
rates (e.g. 1/2 Yes) to appear below the cutoff even when their average
exceeded the threshold.

Now sorts by avgGlobalScore (the visible metric) with compositeScore as
tiebreaker. Also adds a green left border to advancing projects for
clearer threshold highlighting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:20:11 +01:00
761a203063 fix: sort ranking display by compositeScore, fix threshold cutoff line
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m41s
- Sort deduped ranking entries by compositeScore descending during
  localOrder init — ensures correct display order for both formula
  and old AI snapshots
- Fix threshold cutoff: scan forward to find first non-qualifying
  project instead of backward scan that left non-qualifying projects
  above the line

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:43:49 +01:00
cb688ba3e6 feat: formula-based ranking with optional AI, configurable score/pass-rate weights
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m56s
Add scoreWeight and passRateWeight (0-10) to evaluation config for
configurable composite score formula. When ranking criteria text is
empty, triggerAutoRank uses pure formula ranking (no LLM calls).
When criteria text is present, AI-assisted ranking runs as before.

- Add FORMULA to RankingMode enum with migration
- Extract fetchCategoryProjects helper, add formulaRank service
- Update computeCompositeScore to accept configurable weights
- Add score/pass-rate weight sliders to ranking dashboard UI
- Mode-aware button labels (Calculator/formula vs Sparkles/AI)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:24:17 +01:00
ac86e025e2 feat: ranking in-progress indicator persists across all admin users
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m1s
- Create snapshot with status RUNNING before AI call starts
- Update to COMPLETED/FAILED when done
- Dashboard derives rankingInProgress from server snapshot status
- All admins see the spinner, not just the one who triggered it
- Poll snapshots every 3s so progress updates appear quickly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:57:11 +01:00
5a3f8d9837 revert: keep compositeScore sorting (accounts for yes/no criteria)
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:50:15 +01:00
2bccb52a16 fix: ranking sorted by composite score, deduplicate AI results, single cutoff line
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m0s
- Sort all ranked projects by compositeScore descending so highest-rated
  projects always appear first (instead of relying on AI's inconsistent rank order)
- Deduplicate AI ranking response (AI sometimes returns same project multiple times)
- Deduplicate ranking entries and reorder IDs on dashboard load as defensive measure
- Show advancement cutoff line only once (precompute last advancing index)
- Override badge only shown when admin has actually drag-reordered (not on fresh rankings)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:34:31 +01:00
1f4f29c2cc fix: clear windowCloseAt when reopening a round
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m57s
When a round is reopened (ROUND_CLOSED → ROUND_ACTIVE), the old
windowCloseAt was still in the past, causing jury submissions to fail
with "Voting window has closed" even though the round status was active.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:20:43 +01:00
8db9c72f4c fix: ranking reorders persist across all admin sessions
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m49s
Apply saved reordersJson when initializing the dashboard so any admin
sees the latest drag-reorder state, not just the original AI order.
The latest reorder event per category is used as the initial localOrder.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:04:14 +01:00
80a7bedddc fix: ranking shows all reviewed projects, fix override badge sync issue
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m17s
- AI ranking now includes ALL projects (never filters/excludes any)
- Updated system prompt: filter criteria inform priority, not exclusion
- Dynamic maxTokens scaling for large project pools (80 tokens/project)
- Fallback: projects AI omits are appended sorted by composite score
- Override badge uses snapshotOrder state (synced with localOrder in same
  useEffect) instead of rankingMap.originalIndex to prevent stale-render
  mismatch where all items incorrectly showed as overridden

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:10:48 +01:00
d2e0dbdc94 fix: override badge only shows when admin actually reordered a project
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m17s
Compare against original snapshot array position, not AI's internal rank number.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 13:29:40 +01:00
36045bef9d fix: ranking dashboard respects threshold advancement mode
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Header shows "Score >= X advance" instead of "Top N" in threshold mode
- Cutoff line placed after last project meeting threshold, not at fixed count
- Projects advancing determined by avg score vs threshold, not position

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 13:25:49 +01:00
2df9c54de2 fix: backfill binaryDecision, fix boolean criterion lookup, add assign buttons
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m9s
- Backfilled 166 evaluations' binaryDecision from criterionScoresJson on production DB
- Fixed roundEvaluationScores and ai-ranking to look in EvaluationForm.criteriaJson
  instead of round.configJson for the boolean "Move to the Next Stage?" criterion
- Added advanceMode (count/threshold) toggle to round config Advancement Targets
- Added "Assign to Jurors" button on Unassigned Projects section and Projects tab bulk bar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 12:48:08 +01:00
19b58e4434 feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m16s
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
  z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
  alongside existing top-N mode in advance dialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
c6ebd169dd feat: admin evaluation editing, ranking improvements, status transition fix
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m26s
- Add adminEditEvaluation mutation and getJurorEvaluations query
- Create shared EvaluationEditSheet component with inline feedback editing
- Add Evaluations tab to member detail page (grouped by round)
- Make jury group member names clickable (link to member detail)
- Replace inline EvaluationDetailSheet on project page with shared component
- Fix project status transition validation (skip when status unchanged)
- Fix frontend to not send status when unchanged on project edit
- Ranking dashboard improvements and boolean decision converter fixes
- Backfill script updates for binary decisions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:46:52 +01:00
49e706f2cf feat: applicant onboarding, bulk invite, team management enhancements
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m50s
- Add nationality/institution fields to User model with migration
- Applicant onboarding wizard (name, photo, nationality, country, institution, bio, project logo, preferences)
- Project logo upload from applicant context with team membership verification
- APPLICANT redirects in set-password, onboarding, and auth layout
- Mask evaluation round names as "Evaluation Round 1/2/..." for applicants
- Extend inviteTeamMember with nationality/country/institution/sendInvite fields
- Admin getApplicants query with search/filter/pagination
- Admin bulkInviteApplicants mutation with token generation and emails
- Applicants tab on Members page with bulk select and floating invite bar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:11:11 +01:00
68aa393559 feat: show submission round file requirements on project edit page
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m40s
Adds a new tRPC procedure `round.getSubmissionRoundForProgram` that
fetches the most recent SUBMISSION round for a given program, then
displays any `requiredDocuments` from its configJson as labeled info
cards above the general file upload section on the project edit page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:47:42 +01:00
9b3a9f6cbf feat: enhance project search to include all criteria, add AI tag generation button
- ProjectStatesTable local search now covers country, institution, competitionCategory, geographicZone
- project-pool.ts DB search extended to institution, country, geographicZone, team member names
- AwardShortlist eligibility table gains a search input filtering by title, team, country, institution, category
- IndividualAssignmentsTable project filter extended to include country and institution
- Add "Generate AI Tags" dropdown item per row in ProjectStatesTable using tag.tagProject mutation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:42:37 +01:00
dd004baf79 feat: add View Project links to admin tables, conditionally show Awards tab
- IndividualAssignmentsTable: add View Project (new tab) as first dropdown item
- AwardShortlist: make project title a clickable link opening in new tab
- ProjectStatesTable: change View Project from same-tab Link to new-tab anchor
- Round page: Awards tab now only shown when roundAwards.length > 0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:37:45 +01:00
2f1136646e feat: ranking UI improvements - highlight advancing projects, expandable reviews, view project link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:34:32 +01:00
36560a1837 fix: assign project to round on creation (create ProjectRoundState)
- Add optional roundId field to project.create mutation input schema
- After project creation, update project.roundId FK and create a
  ProjectRoundState record (state: PENDING) when roundId is supplied
- Pass selectedRoundId from the new-project form to createProject.mutate()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:31:08 +01:00
25e06e11e4 feat: add all missing fields to project update mutation and edit form
Adds competitionCategory, oceanIssue, institution, geographicZone,
wantsMentorship, and foundedAt to the tRPC update mutation input schema
and the admin project edit form UI (with CountrySelect + Switch).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:28:26 +01:00
237 changed files with 29452 additions and 9439 deletions

4
.gitignore vendored
View File

@@ -58,3 +58,7 @@ build-output.txt
# Misc # Misc
*.log *.log
.vercel .vercel
# Private keys and secrets
private/
public/build-id.json

View File

@@ -23,9 +23,9 @@ COPY . .
# Generate Prisma client # Generate Prisma client
RUN npx prisma generate RUN npx prisma generate
# Build Next.js # Build Next.js — mount .next/cache as a Docker build cache for faster rebuilds
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build RUN --mount=type=cache,target=/app/.next/cache npm run build
# Production image, copy all the files and run next # Production image, copy all the files and run next
FROM base AS runner FROM base AS runner
@@ -69,5 +69,8 @@ EXPOSE 7600
ENV PORT=7600 ENV PORT=7600
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget -qO- http://localhost:7600/api/health || exit 1
# Run via entrypoint (migrate then start) # Run via entrypoint (migrate then start)
CMD ["/app/docker-entrypoint.sh"] CMD ["/app/docker-entrypoint.sh"]

View File

@@ -8,7 +8,7 @@ services:
image: postgres:16-alpine image: postgres:16-alpine
container_name: mopc-postgres-dev container_name: mopc-postgres-dev
ports: ports:
- "5432:5432" - "5433:5432"
environment: environment:
- POSTGRES_USER=${POSTGRES_USER:-mopc} - POSTGRES_USER=${POSTGRES_USER:-mopc}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-devpassword} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-devpassword}
@@ -68,7 +68,7 @@ services:
env_file: env_file:
- ../.env - ../.env
environment: environment:
- DATABASE_URL=postgresql://${POSTGRES_USER:-mopc}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-mopc} - DATABASE_URL=postgresql://${POSTGRES_USER:-mopc}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-mopc}?connection_limit=10&pool_timeout=30
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000} - NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-dev-secret-key-for-local-development-only} - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-dev-secret-key-for-local-development-only}
- AUTH_SECRET=${AUTH_SECRET:-dev-secret-key-for-local-development-only} - AUTH_SECRET=${AUTH_SECRET:-dev-secret-key-for-local-development-only}

View File

@@ -23,7 +23,7 @@ services:
- .env - .env
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc - DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc?connection_limit=10&pool_timeout=30
- NEXTAUTH_URL=${NEXTAUTH_URL} - NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET} - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- AUTH_SECRET=${NEXTAUTH_SECRET} - AUTH_SECRET=${NEXTAUTH_SECRET}
@@ -50,6 +50,7 @@ services:
condition: service_healthy condition: service_healthy
networks: networks:
- mopc-network - mopc-network
- minio-external
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:7600/api/health').then(r=>{if(!r.ok)throw r;process.exit(0)}).catch(()=>process.exit(1))"] test: ["CMD", "node", "-e", "fetch('http://localhost:7600/api/health').then(r=>{if(!r.ok)throw r;process.exit(0)}).catch(()=>process.exit(1))"]
interval: 30s interval: 30s
@@ -82,3 +83,6 @@ volumes:
networks: networks:
mopc-network: mopc-network:
driver: bridge driver: bridge
minio-external:
external: true
name: minio_mopc-minio

View File

@@ -37,5 +37,40 @@ fi
echo "==> Syncing notification email settings..." echo "==> Syncing notification email settings..."
npx tsx prisma/seed-notification-settings.ts || echo "WARNING: Notification settings sync failed." npx tsx prisma/seed-notification-settings.ts || echo "WARNING: Notification settings sync failed."
# Sync team lead links only if there are unlinked submitters
UNLINKED_COUNT=$(node -e "
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient();
p.\$queryRaw\`
SELECT COUNT(*)::int AS c FROM \"Project\" p
WHERE p.\"submittedByUserId\" IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM \"TeamMember\" tm
WHERE tm.\"projectId\" = p.id AND tm.\"userId\" = p.\"submittedByUserId\"
)
\`.then(r => { console.log(r[0].c); p.\$disconnect(); }).catch(() => { console.log('0'); p.\$disconnect(); });
" 2>/dev/null || echo "0")
if [ "$UNLINKED_COUNT" != "0" ]; then
echo "==> Syncing ${UNLINKED_COUNT} unlinked team lead links..."
npx tsx prisma/seed-team-leads.ts || echo "WARNING: Team lead sync failed."
else
echo "==> Team lead links already synced, skipping."
fi
echo "==> Starting application..." echo "==> Starting application..."
exec node server.js
# Graceful shutdown: forward SIGTERM/SIGINT to the Node process
# so in-flight requests can complete before the container exits.
shutdown() {
echo "==> Received shutdown signal, stopping gracefully..."
kill -TERM "$NODE_PID" 2>/dev/null
wait "$NODE_PID"
exit $?
}
trap shutdown TERM INT
node server.js &
NODE_PID=$!
wait "$NODE_PID"

View File

@@ -2,10 +2,20 @@ import type { NextConfig } from 'next'
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
typedRoutes: true,
serverExternalPackages: ['@prisma/client', 'minio'], serverExternalPackages: ['@prisma/client', 'minio'],
typescript: {
ignoreBuildErrors: false,
},
experimental: { experimental: {
optimizePackageImports: ['lucide-react'], optimizePackageImports: [
'lucide-react',
'sonner',
'date-fns',
'recharts',
'motion/react',
'zod',
'@radix-ui/react-icons',
],
}, },
images: { images: {
remotePatterns: [ remotePatterns: [

1995
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"prebuild": "node -e \"require('fs').writeFileSync('public/build-id.json', JSON.stringify({buildId: Date.now().toString()}))\"",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
@@ -95,6 +96,7 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.49.1", "@playwright/test": "^1.49.1",
"@react-grab/mcp": "^0.1.25",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
@@ -109,6 +111,7 @@
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
"prisma": "^6.19.2", "prisma": "^6.19.2",
"react-grab": "^0.1.25",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tsx": "^4.19.2", "tsx": "^4.19.2",

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "nationality" TEXT;
ALTER TABLE "User" ADD COLUMN "institution" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "RankingMode" ADD VALUE 'FORMULA';

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "AwardEligibility" ADD COLUMN "notifiedAt" TIMESTAMP(3);

View File

@@ -0,0 +1,79 @@
-- Round finalization fields
ALTER TABLE "Round" ADD COLUMN "gracePeriodEndsAt" TIMESTAMP(3);
ALTER TABLE "Round" ADD COLUMN "finalizedAt" TIMESTAMP(3);
ALTER TABLE "Round" ADD COLUMN "finalizedBy" TEXT;
-- ProjectRoundState proposed outcome for finalization pool
ALTER TABLE "ProjectRoundState" ADD COLUMN "proposedOutcome" "ProjectRoundStateValue";
-- Mark already-closed rounds as pre-finalized IF their projects were already
-- advanced to the IMMEDIATELY NEXT round (sortOrder = current + 1).
-- We check the next sequential round only, not any subsequent round, because
-- projects can appear in non-adjacent rounds (e.g. special award tracks) without
-- implying the current round was finalized.
UPDATE "Round" r
SET "finalizedAt" = NOW(), "finalizedBy" = 'system-migration'
WHERE r.status IN ('ROUND_CLOSED', 'ROUND_ARCHIVED')
AND EXISTS (
SELECT 1
FROM "Round" next_r
JOIN "ProjectRoundState" next_prs ON next_prs."roundId" = next_r.id
JOIN "ProjectRoundState" cur_prs ON cur_prs."roundId" = r.id
AND cur_prs."projectId" = next_prs."projectId"
WHERE next_r."competitionId" = r."competitionId"
AND next_r."sortOrder" = r."sortOrder" + 1
LIMIT 1
);
-- ─── Backfill terminal states for already-finalized rounds ───────────────────
-- These rounds were finalized manually before this system existed.
-- Set ProjectRoundState to accurate terminal states so the data matches reality.
-- All updates are guarded by current state + round type to avoid touching anything unexpected.
-- R0 (INTAKE, closed): All 214 projects completed intake successfully → PASSED
-- Guard: only touch COMPLETED states in closed INTAKE rounds marked as finalized
UPDATE "ProjectRoundState" prs
SET state = 'PASSED', "proposedOutcome" = 'PASSED'
FROM "Round" r
WHERE prs."roundId" = r.id
AND r."roundType" = 'INTAKE'
AND r.status = 'ROUND_CLOSED'
AND r."finalizedAt" IS NOT NULL
AND prs.state = 'COMPLETED';
-- R1 (FILTERING, closed): Set states based on FilteringResult outcomes
-- Projects that passed filtering → PASSED
UPDATE "ProjectRoundState" prs
SET state = 'PASSED', "proposedOutcome" = 'PASSED'
FROM "Round" r
WHERE prs."roundId" = r.id
AND r."roundType" = 'FILTERING'
AND r.status = 'ROUND_CLOSED'
AND r."finalizedAt" IS NOT NULL
AND prs.state = 'PENDING'
AND EXISTS (
SELECT 1 FROM "FilteringResult" fr
WHERE fr."projectId" = prs."projectId"
AND (
fr."finalOutcome" = 'PASSED'
OR (fr."finalOutcome" IS NULL AND fr.outcome IN ('PASSED', 'FLAGGED'))
)
);
-- Projects that were filtered out → REJECTED
UPDATE "ProjectRoundState" prs
SET state = 'REJECTED', "proposedOutcome" = 'REJECTED'
FROM "Round" r
WHERE prs."roundId" = r.id
AND r."roundType" = 'FILTERING'
AND r.status = 'ROUND_CLOSED'
AND r."finalizedAt" IS NOT NULL
AND prs.state = 'PENDING'
AND EXISTS (
SELECT 1 FROM "FilteringResult" fr
WHERE fr."projectId" = prs."projectId"
AND (
fr."finalOutcome" = 'FILTERED_OUT'
OR (fr."finalOutcome" IS NULL AND fr.outcome = 'FILTERED_OUT')
)
);

View File

@@ -0,0 +1,31 @@
-- DropForeignKey
ALTER TABLE "NotificationLog" DROP CONSTRAINT "NotificationLog_userId_fkey";
-- AlterTable
ALTER TABLE "NotificationLog" ADD COLUMN "batchId" TEXT,
ADD COLUMN "email" TEXT,
ADD COLUMN "projectId" TEXT,
ADD COLUMN "roundId" TEXT,
ALTER COLUMN "userId" DROP NOT NULL,
ALTER COLUMN "channel" SET DEFAULT 'EMAIL';
-- CreateIndex
CREATE INDEX "NotificationLog_roundId_type_idx" ON "NotificationLog"("roundId", "type");
-- CreateIndex
CREATE INDEX "NotificationLog_projectId_idx" ON "NotificationLog"("projectId");
-- CreateIndex
CREATE INDEX "NotificationLog_batchId_idx" ON "NotificationLog"("batchId");
-- CreateIndex
CREATE INDEX "NotificationLog_email_idx" ON "NotificationLog"("email");
-- AddForeignKey
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "passwordResetToken" TEXT,
ADD COLUMN "passwordResetExpiresAt" TIMESTAMP(3);
-- CreateIndex
CREATE UNIQUE INDEX "User_passwordResetToken_key" ON "User"("passwordResetToken");

View File

@@ -0,0 +1,20 @@
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'APPLICANT';
-- CreateIndex
CREATE INDEX "Assignment_roundId_isCompleted_idx" ON "Assignment"("roundId", "isCompleted");
-- CreateIndex
CREATE INDEX "ConflictOfInterest_projectId_idx" ON "ConflictOfInterest"("projectId");
-- CreateIndex
CREATE INDEX "ConflictOfInterest_userId_hasConflict_idx" ON "ConflictOfInterest"("userId", "hasConflict");
-- CreateIndex
CREATE INDEX "NotificationLog_type_status_idx" ON "NotificationLog"("type", "status");
-- CreateIndex
CREATE INDEX "ProjectRoundState_roundId_state_idx" ON "ProjectRoundState"("roundId", "state");
-- CreateIndex
CREATE INDEX "RankingSnapshot_roundId_createdAt_idx" ON "RankingSnapshot"("roundId", "createdAt");

View File

@@ -0,0 +1,35 @@
-- DropForeignKey
ALTER TABLE "AdvancementRule" DROP CONSTRAINT "AdvancementRule_roundId_fkey";
-- DropForeignKey
ALTER TABLE "AssignmentException" DROP CONSTRAINT "AssignmentException_approvedById_fkey";
-- DropForeignKey
ALTER TABLE "AssignmentException" DROP CONSTRAINT "AssignmentException_assignmentId_fkey";
-- AlterTable
ALTER TABLE "ConflictOfInterest" DROP COLUMN "roundId";
-- AlterTable
ALTER TABLE "Evaluation" DROP COLUMN "version";
-- AlterTable
ALTER TABLE "Project" DROP COLUMN "roundId";
-- DropTable
DROP TABLE "AdvancementRule";
-- DropTable
DROP TABLE "AssignmentException";
-- DropTable
DROP TABLE "NotificationPolicy";
-- DropTable
DROP TABLE "OverrideAction";
-- DropEnum
DROP TYPE "AdvancementRuleType";
-- DropEnum
DROP TYPE "OverrideReasonCode";

View File

@@ -11,6 +11,10 @@ generator client {
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
// connection_limit and pool_timeout are set via query params in DATABASE_URL:
// ?connection_limit=10&pool_timeout=30
// Defaults: connection_limit = num_cpus * 2 + 1, pool_timeout = 10s.
// Override in .env for production to prevent connection exhaustion.
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
@@ -130,13 +134,6 @@ enum PartnerType {
OTHER OTHER
} }
enum OverrideReasonCode {
DATA_CORRECTION
POLICY_EXCEPTION
JURY_CONFLICT
SPONSOR_DECISION
ADMIN_DISCRETION
}
// ============================================================================= // =============================================================================
// COMPETITION / ROUND ENGINE ENUMS // COMPETITION / ROUND ENGINE ENUMS
@@ -175,13 +172,6 @@ enum ProjectRoundStateValue {
WITHDRAWN WITHDRAWN
} }
enum AdvancementRuleType {
AUTO_ADVANCE
SCORE_THRESHOLD
TOP_N
ADMIN_SELECTION
AI_RECOMMENDED
}
enum CapMode { enum CapMode {
HARD HARD
@@ -302,12 +292,14 @@ model User {
email String @unique email String @unique
name String? name String?
emailVerified DateTime? // Required by NextAuth Prisma adapter emailVerified DateTime? // Required by NextAuth Prisma adapter
role UserRole @default(JURY_MEMBER) role UserRole @default(APPLICANT)
roles UserRole[] @default([]) roles UserRole[] @default([])
status UserStatus @default(INVITED) status UserStatus @default(INVITED)
expertiseTags String[] @default([]) expertiseTags String[] @default([])
maxAssignments Int? // Per-round limit maxAssignments Int? // Per-round limit
country String? // User's home country (for mentor matching) country String? // User's home country (for mentor matching)
nationality String? // User's nationality (for applicant profiles)
institution String? // User's institution/organization
metadataJson Json? @db.JsonB metadataJson Json? @db.JsonB
// Profile // Profile
@@ -333,6 +325,10 @@ model User {
inviteToken String? @unique inviteToken String? @unique
inviteTokenExpiresAt DateTime? inviteTokenExpiresAt DateTime?
// Password reset token
passwordResetToken String? @unique
passwordResetExpiresAt DateTime?
// Digest & availability preferences // Digest & availability preferences
digestFrequency String @default("none") // 'none' | 'daily' | 'weekly' digestFrequency String @default("none") // 'none' | 'daily' | 'weekly'
preferredWorkload Int? preferredWorkload Int?
@@ -421,7 +417,6 @@ model User {
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor") mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
resultLocksCreated ResultLock[] @relation("ResultLockCreator") resultLocksCreated ResultLock[] @relation("ResultLockCreator")
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker") resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
assignmentExceptionsApproved AssignmentException[] @relation("AssignmentExceptionApprover")
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter") submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement") deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
@@ -553,7 +548,6 @@ model EvaluationForm {
model Project { model Project {
id String @id @default(cuid()) id String @id @default(cuid())
programId String programId String
roundId String?
status ProjectStatus @default(SUBMITTED) status ProjectStatus @default(SUBMITTED)
// Core fields // Core fields
@@ -636,6 +630,7 @@ model Project {
deliberationVotes DeliberationVote[] deliberationVotes DeliberationVote[]
deliberationResults DeliberationResult[] deliberationResults DeliberationResult[]
submissionPromotions SubmissionPromotionEvent[] submissionPromotions SubmissionPromotionEvent[]
notificationLogs NotificationLog[]
@@index([programId]) @@index([programId])
@@index([status]) @@index([status])
@@ -752,7 +747,6 @@ model Assignment {
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull) juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
evaluation Evaluation? evaluation Evaluation?
conflictOfInterest ConflictOfInterest? conflictOfInterest ConflictOfInterest?
exceptions AssignmentException[]
@@unique([userId, projectId, roundId]) @@unique([userId, projectId, roundId])
@@index([roundId]) @@index([roundId])
@@ -761,6 +755,7 @@ model Assignment {
@@index([isCompleted]) @@index([isCompleted])
@@index([projectId, userId]) @@index([projectId, userId])
@@index([juryGroupId]) @@index([juryGroupId])
@@index([roundId, isCompleted])
} }
model Evaluation { model Evaluation {
@@ -778,11 +773,6 @@ model Evaluation {
binaryDecision Boolean? // Yes/No for semi-finalist binaryDecision Boolean? // Yes/No for semi-finalist
feedbackText String? @db.Text feedbackText String? @db.Text
// Versioning (currently unused - evaluations are updated in-place.
// TODO: Implement proper versioning by creating new rows on re-submission
// if version history is needed for audit purposes)
version Int @default(1)
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -929,22 +919,35 @@ model AIUsageLog {
model NotificationLog { model NotificationLog {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String?
channel NotificationChannel channel NotificationChannel @default(EMAIL)
provider String? // META, TWILIO, SMTP provider String? // META, TWILIO, SMTP
type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION, ADVANCEMENT_NOTIFICATION, etc.
status String // PENDING, SENT, DELIVERED, FAILED status String // PENDING, SENT, DELIVERED, FAILED
externalId String? // Message ID from provider externalId String? // Message ID from provider
errorMsg String? @db.Text errorMsg String? @db.Text
// Bulk notification tracking
email String? // Recipient email address
roundId String?
projectId String?
batchId String? // Groups emails from same send operation
createdAt DateTime @default(now()) createdAt DateTime @default(now())
// Relations // Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
@@index([userId]) @@index([userId])
@@index([status]) @@index([status])
@@index([createdAt]) @@index([createdAt])
@@index([roundId, type])
@@index([projectId])
@@index([batchId])
@@index([email])
@@index([type, status])
} }
// ============================================================================= // =============================================================================
@@ -1423,6 +1426,7 @@ enum RankingMode {
PREVIEW // Parsed rules shown to admin (not yet applied) PREVIEW // Parsed rules shown to admin (not yet applied)
CONFIRMED // Admin confirmed rules, ranking applied CONFIRMED // Admin confirmed rules, ranking applied
QUICK // Quick-rank: parse + apply without preview QUICK // Quick-rank: parse + apply without preview
FORMULA // Formula-only: no LLM, pure math ranking
} }
enum RankingSnapshotStatus { enum RankingSnapshotStatus {
@@ -1474,6 +1478,7 @@ model RankingSnapshot {
@@index([roundId]) @@index([roundId])
@@index([triggeredById]) @@index([triggeredById])
@@index([createdAt]) @@index([createdAt])
@@index([roundId, createdAt])
} }
// Tracks progress of long-running AI tagging jobs // Tracks progress of long-running AI tagging jobs
@@ -1619,6 +1624,9 @@ model AwardEligibility {
confirmedAt DateTime? confirmedAt DateTime?
confirmedBy String? confirmedBy String?
// Pool notification tracking
notifiedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -1699,7 +1707,6 @@ model ConflictOfInterest {
assignmentId String @unique assignmentId String @unique
userId String userId String
projectId String projectId String
roundId String? // Legacy — kept for historical data
hasConflict Boolean @default(false) hasConflict Boolean @default(false)
conflictType String? // "financial", "personal", "organizational", "other" conflictType String? // "financial", "personal", "organizational", "other"
description String? @db.Text description String? @db.Text
@@ -1717,6 +1724,8 @@ model ConflictOfInterest {
@@index([userId]) @@index([userId])
@@index([hasConflict]) @@index([hasConflict])
@@index([projectId])
@@index([userId, hasConflict])
} }
// ============================================================================= // =============================================================================
@@ -2079,24 +2088,6 @@ model LiveProgressCursor {
@@index([sessionId]) @@index([sessionId])
} }
model OverrideAction {
id String @id @default(cuid())
entityType String // ProjectRoundState, FilteringResult, AwardEligibility, etc.
entityId String
previousValue Json? @db.JsonB
newValueJson Json @db.JsonB
reasonCode OverrideReasonCode
reasonText String? @db.Text
actorId String
createdAt DateTime @default(now())
@@index([entityType, entityId])
@@index([actorId])
@@index([reasonCode])
@@index([createdAt])
}
model DecisionAuditLog { model DecisionAuditLog {
id String @id @default(cuid()) id String @id @default(cuid())
eventType String // stage.transitioned, routing.executed, filtering.completed, etc. eventType String // stage.transitioned, routing.executed, filtering.completed, etc.
@@ -2114,21 +2105,6 @@ model DecisionAuditLog {
@@index([createdAt]) @@index([createdAt])
} }
model NotificationPolicy {
id String @id @default(cuid())
eventType String @unique // stage.transitioned, filtering.completed, etc.
channel String @default("EMAIL") // EMAIL, IN_APP, BOTH, NONE
templateId String? // Optional reference to MessageTemplate
isActive Boolean @default(true)
configJson Json? @db.JsonB // Additional config (delay, batch, etc.)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([eventType])
@@index([isActive])
}
// ============================================================================= // =============================================================================
// COMPETITION / ROUND ENGINE MODELS (NEW — coexists with Pipeline/Track/Stage) // COMPETITION / ROUND ENGINE MODELS (NEW — coexists with Pipeline/Track/Stage)
// ============================================================================= // =============================================================================
@@ -2190,6 +2166,11 @@ model Round {
submissionWindowId String? submissionWindowId String?
specialAwardId String? specialAwardId String?
// Finalization
gracePeriodEndsAt DateTime?
finalizedAt DateTime?
finalizedBy String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -2199,7 +2180,6 @@ model Round {
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull) juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull) submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
projectRoundStates ProjectRoundState[] projectRoundStates ProjectRoundState[]
advancementRules AdvancementRule[]
visibleSubmissionWindows RoundSubmissionVisibility[] visibleSubmissionWindows RoundSubmissionVisibility[]
assignmentIntents AssignmentIntent[] assignmentIntents AssignmentIntent[]
deliberationSessions DeliberationSession[] deliberationSessions DeliberationSession[]
@@ -2222,6 +2202,7 @@ model Round {
evaluationSummaries EvaluationSummary[] evaluationSummaries EvaluationSummary[]
evaluationDiscussions EvaluationDiscussion[] evaluationDiscussions EvaluationDiscussion[]
messages Message[] messages Message[]
notificationLogs NotificationLog[]
cohorts Cohort[] cohorts Cohort[]
liveCursor LiveProgressCursor? liveCursor LiveProgressCursor?
@@ -2234,13 +2215,14 @@ model Round {
} }
model ProjectRoundState { model ProjectRoundState {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String projectId String
roundId String roundId String
state ProjectRoundStateValue @default(PENDING) state ProjectRoundStateValue @default(PENDING)
enteredAt DateTime @default(now()) proposedOutcome ProjectRoundStateValue?
exitedAt DateTime? enteredAt DateTime @default(now())
metadataJson Json? @db.JsonB exitedAt DateTime?
metadataJson Json? @db.JsonB
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -2253,24 +2235,7 @@ model ProjectRoundState {
@@index([projectId]) @@index([projectId])
@@index([roundId]) @@index([roundId])
@@index([state]) @@index([state])
} @@index([roundId, state])
model AdvancementRule {
id String @id @default(cuid())
roundId String
targetRoundId String?
ruleType AdvancementRuleType
configJson Json @db.JsonB
isDefault Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
@@unique([roundId, sortOrder])
@@index([roundId])
} }
// ============================================================================= // =============================================================================
@@ -2449,22 +2414,6 @@ model AssignmentIntent {
@@index([status]) @@index([status])
} }
model AssignmentException {
id String @id @default(cuid())
assignmentId String
reason String @db.Text
overCapBy Int
approvedById String
createdAt DateTime @default(now())
// Relations
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
approvedBy User @relation("AssignmentExceptionApprover", fields: [approvedById], references: [id])
@@index([assignmentId])
@@index([approvedById])
}
// ============================================================================= // =============================================================================
// MENTORING WORKSPACE MODELS (NEW) // MENTORING WORKSPACE MODELS (NEW)
// ============================================================================= // =============================================================================

52
prisma/seed-team-leads.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* Idempotent sync: ensure every project with a submittedByUserId has a
* corresponding TeamMember(LEAD) record. Safe to run on every deploy.
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const projects = await prisma.project.findMany({
where: { submittedByUserId: { not: null } },
select: {
id: true,
submittedByUserId: true,
teamMembers: { select: { userId: true } },
},
})
const toCreate: Array<{ projectId: string; userId: string; role: 'LEAD' }> = []
for (const project of projects) {
if (!project.submittedByUserId) continue
const alreadyLinked = project.teamMembers.some(
(tm) => tm.userId === project.submittedByUserId
)
if (!alreadyLinked) {
toCreate.push({
projectId: project.id,
userId: project.submittedByUserId,
role: 'LEAD',
})
}
}
if (toCreate.length > 0) {
await prisma.teamMember.createMany({
data: toCreate,
skipDuplicates: true,
})
console.log(`✓ Linked ${toCreate.length} project submitters as TeamMember(LEAD)`)
} else {
console.log('✓ All project submitters already linked — nothing to do')
}
}
main()
.catch((e) => {
console.error('Team lead sync failed:', e)
process.exit(1)
})
.finally(() => prisma.$disconnect())

View File

@@ -15,7 +15,6 @@ import {
RoundStatus, RoundStatus,
CapMode, CapMode,
JuryGroupMemberRole, JuryGroupMemberRole,
AdvancementRuleType,
} from '@prisma/client' } from '@prisma/client'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
// Inline default configs so seed has ZERO dependency on src/ (not available in Docker prod image) // Inline default configs so seed has ZERO dependency on src/ (not available in Docker prod image)
@@ -316,6 +315,7 @@ async function main() {
const staffAccounts = [ const staffAccounts = [
{ email: 'matt@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' }, { email: 'matt@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' },
{ email: 'matt@letsbe.solutions', name: 'Matt (Applicant)', role: UserRole.APPLICANT, password: '195260Mp!' },
{ email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' }, { email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' },
{ email: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' }, { email: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' },
] ]
@@ -323,10 +323,10 @@ async function main() {
const staffUsers: Record<string, string> = {} const staffUsers: Record<string, string> = {}
for (const account of staffAccounts) { for (const account of staffAccounts) {
const passwordHash = await bcrypt.hash(account.password, 12) const passwordHash = await bcrypt.hash(account.password, 12)
const isSuperAdmin = account.role === UserRole.SUPER_ADMIN const needsPassword = account.role === UserRole.SUPER_ADMIN || account.role === UserRole.APPLICANT
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email: account.email }, where: { email: account.email },
update: isSuperAdmin update: needsPassword
? { ? {
status: UserStatus.ACTIVE, status: UserStatus.ACTIVE,
passwordHash, passwordHash,
@@ -348,11 +348,11 @@ async function main() {
name: account.name, name: account.name,
role: account.role, role: account.role,
roles: [account.role], roles: [account.role],
status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE, status: needsPassword ? UserStatus.ACTIVE : UserStatus.NONE,
passwordHash: isSuperAdmin ? passwordHash : null, passwordHash: needsPassword ? passwordHash : null,
mustSetPassword: !isSuperAdmin, mustSetPassword: !needsPassword,
passwordSetAt: isSuperAdmin ? new Date() : null, passwordSetAt: needsPassword ? new Date() : null,
onboardingCompletedAt: isSuperAdmin ? new Date() : null, onboardingCompletedAt: needsPassword ? new Date() : null,
}, },
}) })
staffUsers[account.email] = user.id staffUsers[account.email] = user.id
@@ -559,7 +559,7 @@ async function main() {
}) })
// Create project // Create project
await prisma.project.create({ const createdProject = await prisma.project.create({
data: { data: {
programId: program.id, programId: program.id,
title: projectName || `Project by ${name}`, title: projectName || `Project by ${name}`,
@@ -584,13 +584,24 @@ async function main() {
}, },
}) })
// Link submitter as team lead
await prisma.teamMember.upsert({
where: { projectId_userId: { projectId: createdProject.id, userId: user.id } },
update: {},
create: {
projectId: createdProject.id,
userId: user.id,
role: 'LEAD',
},
})
projectCount++ projectCount++
if (projectCount % 50 === 0) { if (projectCount % 50 === 0) {
console.log(` ... ${projectCount} projects created`) console.log(` ... ${projectCount} projects created`)
} }
} }
console.log(` ✓ Created ${projectCount} projects`) console.log(` ✓ Created ${projectCount} projects (with team lead links)`)
if (skippedNoEmail > 0) { if (skippedNoEmail > 0) {
console.log(` ⚠ Skipped ${skippedNoEmail} rows with no valid email`) console.log(` ⚠ Skipped ${skippedNoEmail} rows with no valid email`)
} }
@@ -846,23 +857,23 @@ async function main() {
} }
console.log(`${rounds.length} rounds created (R1-R8)`) console.log(`${rounds.length} rounds created (R1-R8)`)
// --- Advancement Rules (auto-advance between rounds) --- // --- Assign all projects to intake round (COMPLETED, since intake is closed) ---
for (let i = 0; i < rounds.length - 1; i++) { const intakeRound = rounds[0]
await prisma.advancementRule.upsert({ const allProjects = await prisma.project.findMany({
where: { where: { programId: program.id },
roundId_sortOrder: { roundId: rounds[i].id, sortOrder: 0 }, select: { id: true },
}, })
update: {}, if (allProjects.length > 0) {
create: { await prisma.projectRoundState.createMany({
roundId: rounds[i].id, data: allProjects.map((p) => ({
ruleType: AdvancementRuleType.AUTO_ADVANCE, projectId: p.id,
sortOrder: 0, roundId: intakeRound.id,
targetRoundId: rounds[i + 1].id, state: 'COMPLETED' as const,
configJson: {}, })),
}, skipDuplicates: true,
}) })
console.log(`${allProjects.length} projects assigned to intake round (COMPLETED)`)
} }
console.log(`${rounds.length - 1} advancement rules created`)
// --- Round-Submission Visibility (which rounds can see which submission windows) --- // --- Round-Submission Visibility (which rounds can see which submission windows) ---
// R2 and R3 can see R1 docs, R5 can see R4 docs // R2 and R3 can see R1 docs, R5 can see R4 docs
@@ -887,6 +898,28 @@ async function main() {
} }
console.log(`${visibilityLinks.length} submission visibility links created`) console.log(`${visibilityLinks.length} submission visibility links created`)
// --- Applicant/Observer visibility settings ---
const visibilitySettings = [
{ key: 'observer_show_team_tab', value: 'true', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show Team tab on observer project detail page' },
{ key: 'applicant_show_evaluation_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show anonymous jury evaluation feedback to applicants' },
{ key: 'applicant_show_evaluation_scores', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show global scores in evaluation feedback' },
{ key: 'applicant_show_evaluation_criteria', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show per-criterion scores in evaluation feedback' },
{ key: 'applicant_show_evaluation_text', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show written feedback text in evaluation feedback' },
{ key: 'applicant_show_livefinal_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show live final scores to applicants' },
{ key: 'applicant_show_livefinal_scores', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show individual jury scores from live finals' },
{ key: 'applicant_show_deliberation_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show deliberation results to applicants' },
{ key: 'applicant_hide_feedback_from_rejected', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Hide feedback from rejected projects' },
{ key: 'applicant_allow_description_edit', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Allow applicants to edit their project description' },
]
for (const s of visibilitySettings) {
await prisma.systemSettings.upsert({
where: { key: s.key },
update: {},
create: s,
})
}
console.log(` ✓ Created ${visibilitySettings.length} applicant/observer visibility settings`)
// --- Feature flag: enable competition model --- // --- Feature flag: enable competition model ---
await prisma.systemSettings.upsert({ await prisma.systemSettings.upsert({
where: { key: 'feature.useCompetitionModel' }, where: { key: 'feature.useCompetitionModel' },

View File

@@ -8,9 +8,11 @@
* 1. Finds all rounds with a boolean criterion labeled "Move to the Next Stage?" * 1. Finds all rounds with a boolean criterion labeled "Move to the Next Stage?"
* 2. For evaluations in those rounds where binaryDecision IS NULL, * 2. For evaluations in those rounds where binaryDecision IS NULL,
* copies the boolean value from criterionScoresJson into binaryDecision * copies the boolean value from criterionScoresJson into binaryDecision
*
* Safe to re-run: only updates evaluations where binaryDecision is still null.
*/ */
import { PrismaClient } from '@prisma/client' import { PrismaClient, Prisma } from '@prisma/client'
const prisma = new PrismaClient() const prisma = new PrismaClient()
@@ -28,6 +30,7 @@ async function main() {
}) })
let totalUpdated = 0 let totalUpdated = 0
let totalSkipped = 0
for (const round of rounds) { for (const round of rounds) {
const config = round.configJson as Record<string, unknown> | null const config = round.configJson as Record<string, unknown> | null
@@ -47,36 +50,55 @@ async function main() {
console.log(`Round "${round.name}" (${round.id}): found criterion "${boolCriterion.label}" (${boolCriterion.id})`) console.log(`Round "${round.name}" (${round.id}): found criterion "${boolCriterion.label}" (${boolCriterion.id})`)
// Find evaluations in this round where binaryDecision is null // Find evaluations in this round where binaryDecision is null
// Use Prisma.JsonNull for proper null filtering
const evaluations = await prisma.evaluation.findMany({ const evaluations = await prisma.evaluation.findMany({
where: { where: {
assignment: { roundId: round.id }, assignment: { roundId: round.id },
binaryDecision: null, binaryDecision: null,
status: 'SUBMITTED', status: 'SUBMITTED',
criterionScoresJson: { not: undefined },
}, },
select: { id: true, criterionScoresJson: true }, select: { id: true, criterionScoresJson: true },
}) })
let updated = 0 let updated = 0
let skipped = 0
for (const ev of evaluations) { for (const ev of evaluations) {
const scores = ev.criterionScoresJson as Record<string, unknown> | null const scores = ev.criterionScoresJson as Record<string, unknown> | null
if (!scores) continue if (!scores) {
skipped++
continue
}
const value = scores[boolCriterion.id] const value = scores[boolCriterion.id]
if (typeof value !== 'boolean') continue let resolved: boolean | null = null
if (typeof value === 'boolean') {
resolved = value
} else if (value === 'true' || value === 1) {
resolved = true
} else if (value === 'false' || value === 0) {
resolved = false
}
if (resolved === null) {
console.log(` Skipping eval ${ev.id}: criterion value is ${JSON.stringify(value)}`)
skipped++
continue
}
await prisma.evaluation.update({ await prisma.evaluation.update({
where: { id: ev.id }, where: { id: ev.id },
data: { binaryDecision: value }, data: { binaryDecision: resolved },
}) })
updated++ updated++
} }
console.log(` Updated ${updated}/${evaluations.length} evaluations`) console.log(` Updated ${updated}/${evaluations.length} evaluations (skipped ${skipped})`)
totalUpdated += updated totalUpdated += updated
totalSkipped += skipped
} }
console.log(`\nDone. Total evaluations updated: ${totalUpdated}`) console.log(`\nDone. Total updated: ${totalUpdated}, Total skipped: ${totalSkipped}`)
} }
main() main()

View File

@@ -0,0 +1,112 @@
/**
* Backfill all projects into the intake round (and any intermediate rounds
* between intake and their earliest assigned round) with COMPLETED state.
*
* Usage: npx tsx scripts/backfill-intake-round.ts
* Add --dry-run to preview without making changes.
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const dryRun = process.argv.includes('--dry-run')
async function main() {
console.log(dryRun ? '🔍 DRY RUN — no changes will be made\n' : '🚀 Backfilling intake round states...\n')
// Find the intake round
const intakeRound = await prisma.round.findFirst({
where: { roundType: 'INTAKE' },
select: { id: true, name: true, sortOrder: true, competitionId: true },
})
if (!intakeRound) {
console.log('❌ No INTAKE round found')
return
}
console.log(`Intake round: "${intakeRound.name}" (sortOrder: ${intakeRound.sortOrder})`)
// Get all rounds in the competition ordered by sortOrder
const allRounds = await prisma.round.findMany({
where: { competitionId: intakeRound.competitionId },
select: { id: true, name: true, sortOrder: true },
orderBy: { sortOrder: 'asc' },
})
// Find all projects NOT in the intake round
const projects = await prisma.project.findMany({
where: {
projectRoundStates: {
none: { roundId: intakeRound.id },
},
},
select: {
id: true,
title: true,
projectRoundStates: {
select: { roundId: true, round: { select: { sortOrder: true } } },
orderBy: { round: { sortOrder: 'asc' } },
},
},
})
console.log(`${projects.length} projects not in intake round\n`)
if (projects.length === 0) {
console.log('✅ All projects already in intake round')
return
}
// For each project, create COMPLETED states for intake + any intermediate rounds
const toCreate: Array<{ projectId: string; roundId: string; state: 'COMPLETED' }> = []
for (const project of projects) {
// Find the earliest round this project is already in
const earliestSortOrder = project.projectRoundStates.length > 0
? Math.min(...project.projectRoundStates.map(ps => ps.round.sortOrder))
: Infinity
const existingRoundIds = new Set(project.projectRoundStates.map(ps => ps.roundId))
// Add COMPLETED for intake + all intermediate rounds before the earliest assigned round
for (const round of allRounds) {
if (round.sortOrder >= earliestSortOrder) break
if (existingRoundIds.has(round.id)) continue
toCreate.push({
projectId: project.id,
roundId: round.id,
state: 'COMPLETED',
})
}
}
console.log(`Creating ${toCreate.length} ProjectRoundState records...`)
if (!dryRun) {
await prisma.projectRoundState.createMany({
data: toCreate,
skipDuplicates: true,
})
}
// Summary by round
const byRound = new Map<string, number>()
for (const r of toCreate) {
const name = allRounds.find(ar => ar.id === r.roundId)?.name ?? r.roundId
byRound.set(name, (byRound.get(name) ?? 0) + 1)
}
for (const [name, count] of byRound) {
console.log(` ${name}: ${count} projects`)
}
console.log(`\n✅ Done! ${toCreate.length} records ${dryRun ? 'would be' : ''} created`)
}
main()
.catch((e) => {
console.error('❌ Error:', e)
process.exit(1)
})
.finally(() => prisma.$disconnect())

View File

@@ -0,0 +1,78 @@
/**
* Backfill TeamMember records for all projects that have a submittedByUserId
* but no corresponding TeamMember link.
*
* Usage: npx tsx scripts/backfill-team-leads.ts
* Add --dry-run to preview without making changes.
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const dryRun = process.argv.includes('--dry-run')
async function main() {
console.log(dryRun ? '🔍 DRY RUN — no changes will be made\n' : '🚀 Backfilling team leads...\n')
// Find all projects with a submitter but no TeamMember link for that user
const projects = await prisma.project.findMany({
where: {
submittedByUserId: { not: null },
},
select: {
id: true,
title: true,
submittedByUserId: true,
teamMembers: {
select: { userId: true },
},
},
})
let created = 0
let alreadyLinked = 0
let noSubmitter = 0
for (const project of projects) {
if (!project.submittedByUserId) {
noSubmitter++
continue
}
const alreadyHasLink = project.teamMembers.some(
(tm) => tm.userId === project.submittedByUserId
)
if (alreadyHasLink) {
alreadyLinked++
continue
}
console.log(` + Linking "${project.title}" → user ${project.submittedByUserId}`)
if (!dryRun) {
await prisma.teamMember.create({
data: {
projectId: project.id,
userId: project.submittedByUserId,
role: 'LEAD',
},
})
}
created++
}
console.log(`\n✅ Done!`)
console.log(` ${created} TeamMember records ${dryRun ? 'would be' : ''} created`)
console.log(` ${alreadyLinked} projects already had the submitter linked`)
console.log(` ${noSubmitter} projects had no submitter`)
console.log(` ${projects.length} total projects checked`)
}
main()
.catch((e) => {
console.error('❌ Error:', e)
process.exit(1)
})
.finally(() => prisma.$disconnect())

32
scripts/check-invites.cjs Normal file
View File

@@ -0,0 +1,32 @@
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
(async () => {
const members = await p.teamMember.findMany({
orderBy: { joinedAt: 'desc' },
take: 10,
include: {
user: { select: { id: true, name: true, email: true, status: true, inviteToken: true } },
project: { select: { title: true } }
}
});
for (const m of members) {
console.log(m.role, '|', m.user.name, '|', m.user.email, '|', m.user.status, '|', m.project.title, '|', m.joinedAt.toISOString().slice(0,16), '| token:', m.user.inviteToken ? 'yes' : 'no');
}
const logs = await p.notificationLog.findMany({
where: { type: 'TEAM_INVITATION' },
orderBy: { createdAt: 'desc' },
take: 5,
});
if (logs.length) {
console.log('\n--- Notification logs:');
for (const l of logs) {
console.log(l.status, '|', l.channel, '|', l.errorMsg, '|', l.createdAt.toISOString().slice(0,16));
}
} else {
console.log('\n--- No TEAM_INVITATION notification logs found');
}
await p.$disconnect();
})();

20
scripts/check-rounds.cjs Normal file
View File

@@ -0,0 +1,20 @@
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
(async () => {
const rounds = await p.round.findMany({
orderBy: { sortOrder: 'asc' },
select: { id: true, name: true, roundType: true, status: true, sortOrder: true, competitionId: true },
});
for (const r of rounds) console.log(r.sortOrder, '|', r.name, '|', r.roundType, '|', r.status, '|', r.id);
console.log('\n--- File Requirements:');
const reqs = await p.fileRequirement.findMany({ include: { round: { select: { name: true } } } });
for (const r of reqs) console.log(r.round.name, '|', r.name, '|', r.isRequired, '|', r.id);
console.log('\n--- Submission Windows:');
const wins = await p.submissionWindow.findMany({ select: { id: true, name: true, roundNumber: true, windowOpenAt: true, windowCloseAt: true, competitionId: true } });
for (const w of wins) console.log(w.name, '| round#', w.roundNumber, '| open:', w.windowOpenAt?.toISOString().slice(0,16), '| close:', w.windowCloseAt?.toISOString().slice(0,16));
await p.$disconnect();
})();

View File

@@ -0,0 +1,71 @@
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
(async () => {
// R2 - AI Screening round ID
const roundId = 'cmmafe7et00ldy53kxpdhhvf0';
// Check existing
const existing = await p.fileRequirement.count({ where: { roundId } });
if (existing > 0) {
console.log(`Round already has ${existing} file requirements, skipping.`);
await p.$disconnect();
return;
}
const requirements = [
{
roundId,
name: 'Executive Summary',
description: 'A 2-page executive summary of your project (PDF format, max 10MB)',
acceptedMimeTypes: ['application/pdf'],
maxSizeMB: 10,
isRequired: true,
sortOrder: 0,
},
{
roundId,
name: 'Business Plan',
description: 'Full business plan or project proposal (PDF format, max 25MB)',
acceptedMimeTypes: ['application/pdf'],
maxSizeMB: 25,
isRequired: true,
sortOrder: 1,
},
{
roundId,
name: 'Pitch Presentation',
description: 'Slide deck presenting your project (PDF or PowerPoint, max 50MB)',
acceptedMimeTypes: ['application/pdf', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
maxSizeMB: 50,
isRequired: true,
sortOrder: 2,
},
{
roundId,
name: 'Video Pitch',
description: 'A short video (max 3 minutes) explaining your project (MP4, max 200MB). Optional but recommended.',
acceptedMimeTypes: ['video/mp4', 'video/quicktime', 'video/webm'],
maxSizeMB: 200,
isRequired: false,
sortOrder: 3,
},
{
roundId,
name: 'Supporting Documents',
description: 'Any additional supporting documents such as research papers, letters of support, etc. (PDF, max 20MB)',
acceptedMimeTypes: ['application/pdf'],
maxSizeMB: 20,
isRequired: false,
sortOrder: 4,
},
];
for (const req of requirements) {
const created = await p.fileRequirement.create({ data: req });
console.log('Created:', created.name, '| required:', created.isRequired, '| id:', created.id);
}
console.log('\nDone! Created', requirements.length, 'file requirements for R2.');
await p.$disconnect();
})();

View File

@@ -0,0 +1,68 @@
import { PrismaClient } from '@prisma/client'
import crypto from 'crypto'
import { sendInvitationEmail } from '../src/lib/email'
const prisma = new PrismaClient()
async function main() {
// Find a program to attach the project to
const program = await prisma.program.findFirst()
if (!program) throw new Error('No program found - run seed first')
// Create applicant user
const inviteToken = crypto.randomBytes(32).toString('hex')
const user = await prisma.user.create({
data: {
id: 'test_applicant_matt_ciaccio',
name: 'Matt Ciaccio',
email: 'matt.ciaccio@gmail.com',
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'INVITED',
mustSetPassword: true,
inviteToken,
inviteTokenExpiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000),
},
})
console.log('Created user:', user.id)
// Create test project
const project = await prisma.project.create({
data: {
id: 'test_project_qa',
title: 'OceanWatch AI',
description: 'AI-powered ocean monitoring platform for marine conservation',
programId: program.id,
submittedByUserId: user.id,
},
})
console.log('Created project:', project.id)
// Create team member (LEAD)
await prisma.teamMember.create({
data: {
id: 'test_tm_lead',
projectId: project.id,
userId: user.id,
role: 'LEAD',
},
})
console.log('Created team member (LEAD)')
// Send styled invitation email
const url = `http://localhost:3000/accept-invite?token=${inviteToken}`
console.log('Invite URL:', url)
await sendInvitationEmail(
'matt.ciaccio@gmail.com',
'Matt Ciaccio',
url,
'APPLICANT',
72
)
console.log('Styled invitation email sent!')
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect().then(() => process.exit(0)))

View File

@@ -0,0 +1,165 @@
/**
* Seed NotificationLog with confirmed SMTP delivery data.
*
* Sources:
* 1. 33 emails confirmed delivered in Poste.io SMTP logs (2026-03-04)
* 2. Users with status ACTIVE who are LEADs on PASSED projects
* (they clearly received and used their invite link)
*
* Usage: npx tsx scripts/seed-notification-log.ts
* Add --dry-run to preview without making changes.
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const dryRun = process.argv.includes('--dry-run')
// Emails confirmed delivered via SMTP logs on 2026-03-04
const CONFIRMED_SMTP_EMAILS = new Set([
'fbayong@balazstudio.com',
'gnoel@kilimora.africa',
'amal.chebbi@pigmentoco.com',
'nairita@yarsi.net',
'martin.itamalo@greenbrinetechnologies.com',
'petervegan1223@gmail.com',
'dmarinov@redget.io',
'adrien@seavium.com',
'l.buob@whisper-ef.com',
'silvia@omnivorus.com',
'marzettisebastian@gmail.com',
'fiona.mcomish@algae-scope.com',
'karimeguillen@rearvora.com',
'info@skywatt.tech',
'julia@nereia-coatings.com',
'info@janmaisenbacher.com',
'xbm_0201@qq.com',
'irinakharitonova0201@gmail.com',
'seablocksrecif@gmail.com',
'oscar@seafuser.com',
'charles.maher@blueshadow.dk',
'sabirabokhari@gmail.com',
'munayimbabura@gmail.com',
'amritha.ramadevu@edu.escp.eu',
'nele.jordan@myhsba.de',
'karl.mihhels@aalto.fi',
'christine.a.kurz@gmail.com',
'aki@corall.eco',
'topias.kilpinen@hotmail.fi',
'nina.riutta.camilla@gmail.com',
'sofie.boggiosella@my.jcu.edu.au',
'giambattistafigari@gmail.com',
'mussinig0@gmail.com',
])
const SENT_AT = new Date('2026-03-04T01:00:00Z')
async function main() {
console.log(dryRun ? '--- DRY RUN ---\n' : 'Seeding NotificationLog...\n')
// Find LEAD team members on PASSED projects
const passedLeads = await prisma.teamMember.findMany({
where: {
role: 'LEAD',
project: {
projectRoundStates: {
some: { state: 'PASSED' },
},
},
},
select: {
userId: true,
projectId: true,
project: {
select: {
projectRoundStates: {
where: { state: 'PASSED' },
select: { roundId: true },
take: 1,
},
},
},
user: {
select: {
id: true,
email: true,
status: true,
inviteToken: true,
},
},
},
})
console.log(`Found ${passedLeads.length} LEAD team members on PASSED projects\n`)
let created = 0
let skipped = 0
for (const lead of passedLeads) {
const email = lead.user.email?.toLowerCase()
if (!email) {
skipped++
continue
}
// Check if a NotificationLog already exists for this project+email
const existing = await prisma.notificationLog.findFirst({
where: {
email,
projectId: lead.projectId,
type: 'ADVANCEMENT_NOTIFICATION',
status: 'SENT',
},
})
if (existing) {
skipped++
continue
}
// Determine confidence of delivery
const isConfirmedSMTP = CONFIRMED_SMTP_EMAILS.has(email)
const isActive = lead.user.status === 'ACTIVE'
const isInvited = lead.user.status === 'INVITED' && !!lead.user.inviteToken
// Only seed for confirmed deliveries or active users
if (!isConfirmedSMTP && !isActive && !isInvited) {
console.log(` SKIP ${email} (status=${lead.user.status}, not in SMTP logs)`)
skipped++
continue
}
const roundId = lead.project.projectRoundStates[0]?.roundId ?? null
const label = isConfirmedSMTP ? 'SMTP-confirmed' : isActive ? 'user-active' : 'invite-sent'
console.log(` ${dryRun ? 'WOULD CREATE' : 'CREATE'} ${email} [${label}] project=${lead.projectId}`)
if (!dryRun) {
await prisma.notificationLog.create({
data: {
userId: lead.user.id,
channel: 'EMAIL',
type: 'ADVANCEMENT_NOTIFICATION',
status: 'SENT',
email,
projectId: lead.projectId,
roundId,
batchId: 'seed-2026-03-04',
createdAt: SENT_AT,
},
})
created++
} else {
created++
}
}
console.log(`\nDone. Created: ${created}, Skipped: ${skipped}`)
}
main()
.catch((err) => {
console.error('Error:', err)
process.exit(1)
})
.finally(() => prisma.$disconnect())

View File

@@ -0,0 +1,120 @@
import nodemailer from 'nodemailer';
// Import just the template helper without hitting DB
// We'll construct the email manually since the DB connection fails
const BRAND = {
red: '#de0f1e',
darkBlue: '#053d57',
white: '#fefefe',
teal: '#557f8c',
};
const token = '6f974b1da9fae95f74bbcd2419df589730979ac945aeaa5413021c00311b5165';
const url = 'http://localhost:3000/accept-invite?token=' + token;
// Replicate the styled email template from email.ts
function getStyledHtml(name: string, inviteUrl: string) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>You're invited to join the MOPC Portal</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #f8fafc;">
<tr>
<td align="center" style="padding: 40px 20px;">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width: 600px; width: 100%;">
<!-- Header -->
<tr>
<td style="background: linear-gradient(135deg, ${BRAND.darkBlue} 0%, ${BRAND.teal} 100%); border-radius: 16px 16px 0 0; padding: 32px 40px; text-align: center;">
<h1 style="color: ${BRAND.white}; font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em;">
Monaco Ocean Protection Challenge
</h1>
<p style="color: rgba(255,255,255,0.8); font-size: 13px; font-weight: 300; margin: 8px 0 0 0; letter-spacing: 0.05em; text-transform: uppercase;">
Together for a healthier ocean
</p>
</td>
</tr>
<!-- Body -->
<tr>
<td style="background-color: #ffffff; padding: 40px; border-radius: 0 0 16px 16px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);">
<h2 style="color: ${BRAND.darkBlue}; font-size: 20px; font-weight: 600; margin: 0 0 24px 0;">
Hello ${name},
</h2>
<p style="color: #475569; font-size: 15px; line-height: 1.7; margin: 0 0 16px 0; font-weight: 400;">
You've been invited to join the Monaco Ocean Protection Challenge platform as an <strong>applicant</strong>.
</p>
<p style="color: #475569; font-size: 15px; line-height: 1.7; margin: 0 0 24px 0; font-weight: 400;">
Click the button below to set up your account and get started.
</p>
<!-- CTA Button -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 28px 0;">
<tr>
<td align="center">
<a href="${inviteUrl}" style="display: inline-block; background: linear-gradient(135deg, ${BRAND.red} 0%, #c40d19 100%); color: #ffffff; text-decoration: none; padding: 14px 36px; border-radius: 10px; font-size: 15px; font-weight: 600; letter-spacing: 0.02em; box-shadow: 0 4px 14px rgba(222, 15, 30, 0.3);">
Accept Invitation
</a>
</td>
</tr>
</table>
<!-- Info Box -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: #eff6ff; border-left: 4px solid ${BRAND.darkBlue}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="color: #1e40af; margin: 0; font-size: 13px; line-height: 1.6;">
This link will expire in 3 days.
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 24px 40px; text-align: center;">
<p style="color: #94a3b8; font-size: 12px; line-height: 1.6; margin: 0;">
Monaco Ocean Protection Challenge<br>
<span style="color: #cbd5e1;">Together for a healthier ocean.</span>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
async function main() {
console.log('Creating transporter...');
const transporter = nodemailer.createTransport({
host: 'mail.monaco-opc.com',
port: 587,
secure: false,
auth: {
user: 'noreply@monaco-opc.com',
pass: '9EythPDcz1Fya4M88iigkB1wojNf8QEVPuRRnD9dJMBpT3pk2',
},
});
console.log('Sending styled invitation email...');
const info = await transporter.sendMail({
from: 'MOPC Portal <noreply@monaco-opc.com>',
to: 'matt.ciaccio@gmail.com',
subject: "You're invited to join the MOPC Portal",
text: `Hello Matt Ciaccio,\n\nYou've been invited to join the Monaco Ocean Protection Challenge platform as an applicant.\n\nClick the link below to set up your account:\n\n${url}\n\nThis link will expire in 3 days.\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
html: getStyledHtml('Matt Ciaccio', url),
});
console.log('SUCCESS! Message ID:', info.messageId);
process.exit(0);
}
main().catch(err => {
console.error('FAILED:', err);
process.exit(1);
});

26
scripts/send-invite.ts Normal file
View File

@@ -0,0 +1,26 @@
import { sendInvitationEmail } from '../src/lib/email';
const token = '6f974b1da9fae95f74bbcd2419df589730979ac945aeaa5413021c00311b5165';
const url = 'http://localhost:3000/accept-invite?token=' + token;
async function main() {
console.log('Sending styled invitation email...');
console.log('To: matt.ciaccio@gmail.com');
console.log('URL:', url);
try {
await sendInvitationEmail(
'matt.ciaccio@gmail.com',
'Matt Ciaccio',
url,
'APPLICANT',
72
);
console.log('SUCCESS: Styled invitation email sent!');
} catch (err: any) {
console.error('FAILED:', err.message || err);
}
process.exit(0);
}
main();

20
scripts/test-db.cjs Normal file
View File

@@ -0,0 +1,20 @@
require('dotenv').config();
const { PrismaClient } = require('@prisma/client');
async function main() {
console.log('DATABASE_URL:', process.env.DATABASE_URL);
const p = new PrismaClient({ log: ['query', 'info', 'warn', 'error'] });
try {
const result = await p.$queryRawUnsafe('SELECT 1 as ok');
console.log('Connected!', result);
} catch (e) {
console.error('Error code:', e.code);
console.error('Error meta:', JSON.stringify(e.meta, null, 2));
console.error('Message:', e.message);
} finally {
await p.$disconnect();
process.exit(0);
}
}
main();

View File

@@ -56,9 +56,11 @@ import { Switch } from '@/components/ui/switch'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog' import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { formatDate } from '@/lib/utils' import { formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import Link from 'next/link'
// Action type options // Action type options (manual audit actions + auto-generated mutation audit actions)
const ACTION_TYPES = [ const ACTION_TYPES = [
// Manual audit actions
'CREATE', 'CREATE',
'UPDATE', 'UPDATE',
'DELETE', 'DELETE',
@@ -76,6 +78,8 @@ const ACTION_TYPES = [
'ROUND_ARCHIVED', 'ROUND_ARCHIVED',
'UPLOAD_FILE', 'UPLOAD_FILE',
'DELETE_FILE', 'DELETE_FILE',
'FILE_VIEWED',
'FILE_OPENED',
'FILE_DOWNLOADED', 'FILE_DOWNLOADED',
'BULK_CREATE', 'BULK_CREATE',
'BULK_UPDATE_STATUS', 'BULK_UPDATE_STATUS',
@@ -88,12 +92,53 @@ const ACTION_TYPES = [
'APPLY_AI_SUGGESTIONS', 'APPLY_AI_SUGGESTIONS',
'APPLY_SUGGESTIONS', 'APPLY_SUGGESTIONS',
'NOTIFY_JURORS_OF_ASSIGNMENTS', 'NOTIFY_JURORS_OF_ASSIGNMENTS',
'IMPERSONATION_START',
'IMPERSONATION_END',
// Auto-generated mutation audit actions (non-super-admin)
'EVALUATION_START',
'EVALUATION_SUBMIT',
'EVALUATION_AUTOSAVE',
'EVALUATION_DECLARE_COI',
'EVALUATION_ADD_COMMENT',
'APPLICANT_SAVE_SUBMISSION',
'APPLICANT_SAVE_FILE_METADATA',
'APPLICANT_DELETE_FILE',
'APPLICANT_REQUEST_MENTORING',
'APPLICANT_WITHDRAW_FROM_COMPETITION',
'APPLICANT_INVITE_TEAM_MEMBER',
'APPLICANT_REMOVE_TEAM_MEMBER',
'APPLICANT_SEND_MENTOR_MESSAGE',
'APPLICATION_SUBMIT',
'APPLICATION_SAVE_DRAFT',
'APPLICATION_SUBMIT_DRAFT',
'MENTOR_SEND_MESSAGE',
'MENTOR_CREATE_NOTE',
'MENTOR_DELETE_NOTE',
'MENTOR_COMPLETE_MILESTONE',
'LIVE_CAST_VOTE',
'LIVE_CAST_STAGE_VOTE',
'LIVE_VOTING_VOTE',
'LIVE_VOTING_CAST_AUDIENCE_VOTE',
'DELIBERATION_SUBMIT_VOTE',
'NOTIFICATION_MARK_AS_READ',
'NOTIFICATION_MARK_ALL_AS_READ',
'USER_UPDATE_PROFILE',
'USER_SET_PASSWORD',
'USER_CHANGE_PASSWORD',
'USER_COMPLETE_ONBOARDING',
'SPECIAL_AWARD_SUBMIT_VOTE',
// Security events
'ACCOUNT_LOCKED',
'ACCESS_DENIED_FORBIDDEN',
'ACCESS_DENIED_UNAUTHORIZED',
'ACCESS_DENIED_NOT_FOUND',
] ]
// Entity type options // Entity type options
const ENTITY_TYPES = [ const ENTITY_TYPES = [
'User', 'User',
'Program', 'Program',
'Competition',
'Round', 'Round',
'Project', 'Project',
'Assignment', 'Assignment',
@@ -101,6 +146,21 @@ const ENTITY_TYPES = [
'EvaluationForm', 'EvaluationForm',
'ProjectFile', 'ProjectFile',
'GracePeriod', 'GracePeriod',
'Applicant',
'Application',
'Mentor',
'Live',
'LiveVoting',
'Deliberation',
'Notification',
'SpecialAward',
'File',
'Tag',
'Message',
'Settings',
'Ranking',
'Filtering',
'RoundEngine',
] ]
// Color map for action types // Color map for action types
@@ -119,6 +179,8 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
ROUND_ACTIVATED: 'default', ROUND_ACTIVATED: 'default',
ROUND_CLOSED: 'secondary', ROUND_CLOSED: 'secondary',
ROUND_ARCHIVED: 'secondary', ROUND_ARCHIVED: 'secondary',
FILE_VIEWED: 'outline',
FILE_OPENED: 'outline',
FILE_DOWNLOADED: 'outline', FILE_DOWNLOADED: 'outline',
ROLE_CHANGED: 'secondary', ROLE_CHANGED: 'secondary',
PASSWORD_SET: 'outline', PASSWORD_SET: 'outline',
@@ -128,6 +190,58 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
APPLY_AI_SUGGESTIONS: 'default', APPLY_AI_SUGGESTIONS: 'default',
APPLY_SUGGESTIONS: 'default', APPLY_SUGGESTIONS: 'default',
NOTIFY_JURORS_OF_ASSIGNMENTS: 'outline', NOTIFY_JURORS_OF_ASSIGNMENTS: 'outline',
IMPERSONATION_START: 'destructive',
IMPERSONATION_END: 'secondary',
// Auto-generated mutation audit actions
EVALUATION_START: 'default',
EVALUATION_SUBMIT: 'default',
EVALUATION_AUTOSAVE: 'outline',
EVALUATION_DECLARE_COI: 'secondary',
EVALUATION_ADD_COMMENT: 'outline',
APPLICANT_SAVE_SUBMISSION: 'default',
APPLICANT_DELETE_FILE: 'destructive',
APPLICANT_WITHDRAW_FROM_COMPETITION: 'destructive',
APPLICANT_INVITE_TEAM_MEMBER: 'default',
APPLICANT_REMOVE_TEAM_MEMBER: 'destructive',
APPLICATION_SUBMIT: 'default',
MENTOR_SEND_MESSAGE: 'outline',
MENTOR_CREATE_NOTE: 'default',
MENTOR_DELETE_NOTE: 'destructive',
LIVE_CAST_VOTE: 'default',
LIVE_CAST_STAGE_VOTE: 'default',
LIVE_VOTING_CAST_AUDIENCE_VOTE: 'default',
DELIBERATION_SUBMIT_VOTE: 'default',
SPECIAL_AWARD_SUBMIT_VOTE: 'default',
USER_UPDATE_PROFILE: 'secondary',
USER_SET_PASSWORD: 'outline',
USER_CHANGE_PASSWORD: 'outline',
USER_COMPLETE_ONBOARDING: 'default',
// Security events
ACCOUNT_LOCKED: 'destructive',
ACCESS_DENIED_FORBIDDEN: 'destructive',
ACCESS_DENIED_UNAUTHORIZED: 'destructive',
ACCESS_DENIED_NOT_FOUND: 'secondary',
}
function getEntityLink(entityType: string, entityId: string): string | null {
switch (entityType) {
case 'User':
return `/admin/members/${entityId}`
case 'Project':
return `/admin/projects/${entityId}`
case 'Round':
return `/admin/rounds/${entityId}`
case 'Competition':
return `/admin/competitions`
case 'Evaluation':
case 'EvaluationForm':
return null // no dedicated page
case 'SpecialAward':
return `/admin/awards/${entityId}`
default:
return null
}
} }
export default function AuditLogPage() { export default function AuditLogPage() {
@@ -462,14 +576,24 @@ export default function AuditLogPage() {
{formatDate(log.timestamp)} {formatDate(log.timestamp)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div> {log.userId ? (
<p className="font-medium text-sm"> <Link
{log.user?.name || 'System'} href={`/admin/members/${log.userId}`}
</p> className="group block"
<p className="text-xs text-muted-foreground"> onClick={(e) => e.stopPropagation()}
{log.user?.email} >
</p> <p className="font-medium text-sm group-hover:text-primary group-hover:underline">
</div> {log.user?.name || 'System'}
</p>
<p className="text-xs text-muted-foreground">
{log.user?.email}
</p>
</Link>
) : (
<div>
<p className="font-medium text-sm">System</p>
</div>
)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge <Badge
@@ -481,11 +605,22 @@ export default function AuditLogPage() {
<TableCell> <TableCell>
<div> <div>
<p className="text-sm">{log.entityType}</p> <p className="text-sm">{log.entityType}</p>
{log.entityId && ( {log.entityId && (() => {
<p className="text-xs text-muted-foreground font-mono"> const link = getEntityLink(log.entityType, log.entityId)
{log.entityId.slice(0, 8)}... return link ? (
</p> <Link
)} href={link}
className="text-xs text-primary font-mono hover:underline"
onClick={(e) => e.stopPropagation()}
>
{log.entityId.slice(0, 8)}...
</Link>
) : (
<p className="text-xs text-muted-foreground font-mono">
{log.entityId.slice(0, 8)}...
</p>
)
})()}
</div> </div>
</TableCell> </TableCell>
<TableCell className="font-mono text-xs"> <TableCell className="font-mono text-xs">
@@ -508,9 +643,18 @@ export default function AuditLogPage() {
<p className="text-xs font-medium text-muted-foreground"> <p className="text-xs font-medium text-muted-foreground">
Entity ID Entity ID
</p> </p>
<p className="font-mono text-sm"> {log.entityId ? (() => {
{log.entityId || 'N/A'} const link = getEntityLink(log.entityType, log.entityId)
</p> return link ? (
<Link href={link} className="font-mono text-sm text-primary hover:underline" onClick={(e) => e.stopPropagation()}>
{log.entityId}
</Link>
) : (
<p className="font-mono text-sm">{log.entityId}</p>
)
})() : (
<p className="font-mono text-sm">N/A</p>
)}
</div> </div>
<div> <div>
<p className="text-xs font-medium text-muted-foreground"> <p className="text-xs font-medium text-muted-foreground">
@@ -607,12 +751,23 @@ export default function AuditLogPage() {
{formatDate(log.timestamp)} {formatDate(log.timestamp)}
</span> </span>
</div> </div>
<div className="flex items-center gap-1 text-muted-foreground"> {log.userId ? (
<User className="h-3 w-3" /> <Link
<span className="text-xs"> href={`/admin/members/${log.userId}`}
{log.user?.name || 'System'} className="flex items-center gap-1 text-muted-foreground hover:text-primary"
</span> onClick={(e) => e.stopPropagation()}
</div> >
<User className="h-3 w-3" />
<span className="text-xs hover:underline">
{log.user?.name || 'System'}
</span>
</Link>
) : (
<div className="flex items-center gap-1 text-muted-foreground">
<User className="h-3 w-3" />
<span className="text-xs">System</span>
</div>
)}
</div> </div>
{isExpanded && ( {isExpanded && (

View File

@@ -122,11 +122,9 @@ export default function EditAwardPage({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href={`/admin/awards/${awardId}`}> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Award
</Link>
</Button> </Button>
</div> </div>

View File

@@ -57,6 +57,7 @@ import { Progress } from '@/components/ui/progress'
import { UserAvatar } from '@/components/shared/user-avatar' import { UserAvatar } from '@/components/shared/user-avatar'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { Pagination } from '@/components/shared/pagination' import { Pagination } from '@/components/shared/pagination'
import { EmailPreviewDialog } from '@/components/admin/round/email-preview-dialog'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
Tooltip, Tooltip,
@@ -91,7 +92,28 @@ import {
AlertCircle, AlertCircle,
Layers, Layers,
Info, Info,
Mail,
GripVertical,
ArrowRight,
} from 'lucide-react' } from 'lucide-react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { CountryDisplay } from '@/components/shared/country-display'
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = { const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
DRAFT: 'secondary', DRAFT: 'secondary',
@@ -114,6 +136,199 @@ function getStepIndex(status: string): number {
return idx >= 0 ? idx : (status === 'ARCHIVED' ? 3 : 0) return idx >= 0 ? idx : (status === 'ARCHIVED' ? 3 : 0)
} }
const ROUND_TYPE_COLORS: Record<string, string> = {
EVALUATION: 'bg-violet-100 text-violet-700',
FILTERING: 'bg-amber-100 text-amber-700',
SUBMISSION: 'bg-blue-100 text-blue-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-rose-100 text-rose-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
const ROUND_STATUS_COLORS: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-600',
ACTIVE: 'bg-emerald-100 text-emerald-700',
CLOSED: 'bg-blue-100 text-blue-700',
ARCHIVED: 'bg-muted text-muted-foreground',
}
function SortableRoundCard({
round,
index,
isFirst,
onDelete,
isDeleting,
}: {
round: any
index: number
isFirst: boolean
onDelete: (roundId: string) => void
isDeleting: boolean
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: round.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
const projectCount = round._count?.projectRoundStates ?? 0
const assignmentCount = round._count?.assignments ?? 0
const statusLabel = round.status.replace('ROUND_', '')
return (
<Card
ref={setNodeRef}
style={style}
className={`hover:shadow-md transition-shadow ${isDragging ? 'opacity-50 shadow-lg z-50' : ''}`}
>
<CardContent className="pt-4 pb-3 space-y-3">
<div className="flex items-start gap-2.5">
<button
className="cursor-grab touch-none text-muted-foreground hover:text-foreground mt-1 shrink-0"
aria-label="Drag to reorder"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
{index + 1}
</div>
<div className="min-w-0 flex-1">
<Link href={`/admin/rounds/${round.id}` as any} className="text-sm font-semibold truncate hover:underline">
{round.name}
</Link>
<div className="flex flex-wrap gap-1.5 mt-1">
<Badge variant="secondary" className={`text-[10px] ${ROUND_TYPE_COLORS[round.roundType] ?? 'bg-gray-100 text-gray-700'}`}>
{round.roundType.replace('_', ' ')}
</Badge>
<Badge variant="outline" className={`text-[10px] ${ROUND_STATUS_COLORS[statusLabel]}`}>
{statusLabel}
</Badge>
{isFirst && (
<Badge variant="outline" className="text-[10px] border-amber-300 bg-amber-50 text-amber-700">
Entry point
</Badge>
)}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Layers className="h-3.5 w-3.5" />
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
</div>
{assignmentCount > 0 && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<ListChecks className="h-3.5 w-3.5" />
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
</div>
)}
</div>
{round.status === 'ROUND_DRAFT' && (
<div className="flex justify-end pt-1">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
<Trash2 className="h-3.5 w-3.5 mr-1" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Round</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{round.name}&quot;. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(round.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</CardContent>
</Card>
)
}
function RoundsDndGrid({
rounds,
awardId,
onReorder,
onDelete,
isDeleting,
}: {
rounds: any[]
awardId: string
onReorder: (roundIds: string[]) => void
onDelete: (roundId: string) => void
isDeleting: boolean
}) {
const [items, setItems] = useState(rounds.map((r: any) => r.id))
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
// Sync if server data changes
useEffect(() => {
setItems(rounds.map((r: any) => r.id))
}, [rounds])
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = items.indexOf(active.id as string)
const newIndex = items.indexOf(over.id as string)
const newItems = arrayMove(items, oldIndex, newIndex)
setItems(newItems)
onReorder(newItems)
}
const roundMap = new Map(rounds.map((r: any) => [r.id, r]))
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{items.map((id, index) => {
const round = roundMap.get(id)
if (!round) return null
return (
<SortableRoundCard
key={id}
round={round}
index={index}
isFirst={index === 0}
onDelete={onDelete}
isDeleting={isDeleting}
/>
)
})}
</div>
</SortableContext>
</DndContext>
)
}
function ConfidenceBadge({ confidence }: { confidence: number }) { function ConfidenceBadge({ confidence }: { confidence: number }) {
if (confidence > 0.8) { if (confidence > 0.8) {
return ( return (
@@ -155,6 +370,8 @@ export default function AwardDetailPage({
const [activeTab, setActiveTab] = useState('eligibility') const [activeTab, setActiveTab] = useState('eligibility')
const [addRoundOpen, setAddRoundOpen] = useState(false) const [addRoundOpen, setAddRoundOpen] = useState(false)
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string }) const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
const [notifyDialogOpen, setNotifyDialogOpen] = useState(false)
const [notifyCustomMessage, setNotifyCustomMessage] = useState<string | undefined>()
// Pagination for eligibility list // Pagination for eligibility list
const [eligibilityPage, setEligibilityPage] = useState(1) const [eligibilityPage, setEligibilityPage] = useState(1)
@@ -282,6 +499,31 @@ export default function AwardDetailPage({
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
const reorderRounds = trpc.specialAward.reorderAwardRounds.useMutation({
onSuccess: () => refetchRounds(),
onError: (err) => toast.error(err.message),
})
const assignToFirstRound = trpc.specialAward.assignToFirstRound.useMutation({
onSuccess: (result) => {
toast.success(`Assigned ${result.totalAssigned} projects to first round (${result.createdCount} new, ${result.movedCount} moved)`)
refetchRounds()
refetch()
},
onError: (err) => toast.error(err.message),
})
const notifyPreview = trpc.specialAward.previewAwardSelectionEmail.useQuery(
{ awardId, customMessage: notifyCustomMessage },
{ enabled: notifyDialogOpen }
)
const notifyEligible = trpc.specialAward.notifyEligibleProjects.useMutation({
onSuccess: (result) => {
toast.success(`Notified ${result.notified} projects (${result.emailsSent} emails sent${result.emailsFailed ? `, ${result.emailsFailed} failed` : ''})`)
setNotifyDialogOpen(false)
setNotifyCustomMessage(undefined)
},
onError: (err) => toast.error(err.message),
})
const handleStatusChange = async ( const handleStatusChange = async (
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED' status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
@@ -422,11 +664,9 @@ export default function AwardDetailPage({
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href="/admin/awards"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Awards
</Link>
</Button> </Button>
</div> </div>
@@ -468,13 +708,44 @@ export default function AwardDetailPage({
</Button> </Button>
)} )}
{award.status === 'NOMINATIONS_OPEN' && ( {award.status === 'NOMINATIONS_OPEN' && (
<Button <>
onClick={() => handleStatusChange('VOTING_OPEN')} <Button variant="outline" disabled={award.eligibleCount === 0} onClick={() => setNotifyDialogOpen(true)}>
disabled={updateStatus.isPending} <Mail className="mr-2 h-4 w-4" />
> Notify Pool ({award.eligibleCount})
<Play className="mr-2 h-4 w-4" /> </Button>
Open Voting <EmailPreviewDialog
</Button> open={notifyDialogOpen}
onOpenChange={setNotifyDialogOpen}
title="Notify Eligible Projects"
description={`Send "Under consideration for ${award.name}" emails to all ${award.eligibleCount} eligible projects.`}
recipientCount={notifyPreview.data?.recipientCount ?? 0}
previewHtml={notifyPreview.data?.html}
isPreviewLoading={notifyPreview.isLoading}
onSend={(msg) => notifyEligible.mutate({ awardId, customMessage: msg })}
isSending={notifyEligible.isPending}
onRefreshPreview={(msg) => setNotifyCustomMessage(msg)}
/>
{award.eligibilityMode === 'SEPARATE_POOL' ? (
<Button
onClick={() => assignToFirstRound.mutate({ awardId })}
disabled={assignToFirstRound.isPending || award.eligibleCount === 0}
>
{assignToFirstRound.isPending ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Assigning...</>
) : (
<><ArrowRight className="mr-2 h-4 w-4" />Assign to First Round</>
)}
</Button>
) : (
<Button
onClick={() => handleStatusChange('VOTING_OPEN')}
disabled={updateStatus.isPending}
>
<Play className="mr-2 h-4 w-4" />
Open Voting
</Button>
)}
</>
)} )}
{award.status === 'VOTING_OPEN' && ( {award.status === 'VOTING_OPEN' && (
<Button <Button
@@ -752,7 +1023,7 @@ export default function AwardDetailPage({
'-' '-'
)} )}
</TableCell> </TableCell>
<TableCell className="text-sm">{project.country || '-'}</TableCell> <TableCell className="text-sm">{project.country ? <CountryDisplay country={project.country} /> : '-'}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button <Button
size="sm" size="sm"
@@ -918,7 +1189,7 @@ export default function AwardDetailPage({
'-' '-'
)} )}
</TableCell> </TableCell>
<TableCell>{e.project.country || '-'}</TableCell> <TableCell>{e.project.country ? <CountryDisplay country={e.project.country} /> : '-'}</TableCell>
<TableCell> <TableCell>
<Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs gap-1"> <Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs gap-1">
{e.method === 'MANUAL' ? 'Manual' : <><Bot className="h-3 w-3" />AI Assessed</>} {e.method === 'MANUAL' ? 'Manual' : <><Bot className="h-3 w-3" />AI Assessed</>}
@@ -1065,7 +1336,7 @@ export default function AwardDetailPage({
<TableRow key={j.id}> <TableRow key={j.id}>
<TableCell> <TableCell>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<UserAvatar user={j.user} size="sm" /> <UserAvatar user={j.user} avatarUrl={j.user.avatarUrl} size="sm" />
<div> <div>
<p className="font-medium"> <p className="font-medium">
{j.user.name || 'Unnamed'} {j.user.name || 'Unnamed'}
@@ -1208,99 +1479,13 @@ export default function AwardDetailPage({
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3"> <RoundsDndGrid
{awardRounds.map((round: any, index: number) => { rounds={awardRounds}
const projectCount = round._count?.projectRoundStates ?? 0 awardId={awardId}
const assignmentCount = round._count?.assignments ?? 0 onReorder={(roundIds) => reorderRounds.mutate({ awardId, roundIds })}
const statusLabel = round.status.replace('ROUND_', '') onDelete={(roundId) => deleteRound.mutate({ roundId })}
const statusColors: Record<string, string> = { isDeleting={deleteRound.isPending}
DRAFT: 'bg-gray-100 text-gray-600', />
ACTIVE: 'bg-emerald-100 text-emerald-700',
CLOSED: 'bg-blue-100 text-blue-700',
ARCHIVED: 'bg-muted text-muted-foreground',
}
const roundTypeColors: Record<string, string> = {
EVALUATION: 'bg-violet-100 text-violet-700',
FILTERING: 'bg-amber-100 text-amber-700',
SUBMISSION: 'bg-blue-100 text-blue-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-rose-100 text-rose-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
return (
<Card key={round.id} className="hover:shadow-md transition-shadow h-full">
<CardContent className="pt-4 pb-3 space-y-3">
<div className="flex items-start gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
{index + 1}
</div>
<div className="min-w-0 flex-1">
<Link href={`/admin/rounds/${round.id}` as any} className="text-sm font-semibold truncate hover:underline">
{round.name}
</Link>
<div className="flex flex-wrap gap-1.5 mt-1">
<Badge variant="secondary" className={`text-[10px] ${roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}`}>
{round.roundType.replace('_', ' ')}
</Badge>
<Badge variant="outline" className={`text-[10px] ${statusColors[statusLabel]}`}>
{statusLabel}
</Badge>
{index === 0 && (
<Badge variant="outline" className="text-[10px] border-amber-300 bg-amber-50 text-amber-700">
Entry point
</Badge>
)}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Layers className="h-3.5 w-3.5" />
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
</div>
{assignmentCount > 0 && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<ListChecks className="h-3.5 w-3.5" />
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
</div>
)}
</div>
{round.status === 'ROUND_DRAFT' && (
<div className="flex justify-end pt-1">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
<Trash2 className="h-3.5 w-3.5 mr-1" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Round</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{round.name}&quot;. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteRound.mutate({ roundId: round.id })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</CardContent>
</Card>
)
})}
</div>
)} )}
</TabsContent> </TabsContent>

View File

@@ -69,11 +69,9 @@ export default function CreateAwardPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href="/admin/awards"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Awards
</Link>
</Button> </Button>
</div> </div>

View File

@@ -35,6 +35,7 @@ import { ActivityFeed } from '@/components/dashboard/activity-feed'
import { CategoryBreakdown } from '@/components/dashboard/category-breakdown' import { CategoryBreakdown } from '@/components/dashboard/category-breakdown'
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton' import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton'
import { RecentEvaluations } from '@/components/dashboard/recent-evaluations' import { RecentEvaluations } from '@/components/dashboard/recent-evaluations'
import { RoundUserTracker } from '@/components/dashboard/round-user-tracker'
type DashboardContentProps = { type DashboardContentProps = {
editionId: string editionId: string
@@ -115,16 +116,17 @@ function getContextualActions(
export function DashboardContent({ editionId, sessionName }: DashboardContentProps) { export function DashboardContent({ editionId, sessionName }: DashboardContentProps) {
const { data, isLoading, error } = trpc.dashboard.getStats.useQuery( const { data, isLoading, error } = trpc.dashboard.getStats.useQuery(
{ editionId }, { editionId },
{ enabled: !!editionId, retry: 1, refetchInterval: 30_000 } { enabled: !!editionId, refetchInterval: 60_000 }
) )
const { data: recentEvals } = trpc.dashboard.getRecentEvaluations.useQuery( const { data: recentEvals } = trpc.dashboard.getRecentEvaluations.useQuery(
{ editionId, limit: 8 }, { editionId, limit: 8 },
{ enabled: !!editionId, refetchInterval: 30_000 } { enabled: !!editionId, refetchInterval: 60_000 }
) )
const { data: liveActivity } = trpc.dashboard.getRecentActivity.useQuery( const { data: liveActivity } = trpc.dashboard.getRecentActivity.useQuery(
{ limit: 8 }, { limit: 8 },
{ enabled: !!editionId, refetchInterval: 5_000 } { enabled: !!editionId, refetchInterval: 30_000 }
) )
// Round User Tracker is self-contained — it fetches its own data
if (isLoading) { if (isLoading) {
return <DashboardSkeleton /> return <DashboardSkeleton />
@@ -272,6 +274,10 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</AnimatedCard> </AnimatedCard>
<AnimatedCard index={6}> <AnimatedCard index={6}>
<RoundUserTracker editionId={editionId} />
</AnimatedCard>
<AnimatedCard index={7}>
<ActivityFeed activity={liveActivity ?? recentActivity} /> <ActivityFeed activity={liveActivity ?? recentActivity} />
</AnimatedCard> </AnimatedCard>
</div> </div>
@@ -280,12 +286,12 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{/* Bottom Full Width */} {/* Bottom Full Width */}
<div className="grid gap-6 lg:grid-cols-12"> <div className="grid gap-6 lg:grid-cols-12">
<div className="lg:col-span-8"> <div className="lg:col-span-8">
<AnimatedCard index={7}> <AnimatedCard index={8}>
<GeographicSummaryCard programId={editionId} /> <GeographicSummaryCard programId={editionId} />
</AnimatedCard> </AnimatedCard>
</div> </div>
<div className="lg:col-span-4"> <div className="lg:col-span-4">
<AnimatedCard index={8}> <AnimatedCard index={9}>
<CategoryBreakdown <CategoryBreakdown
categories={categoryBreakdown} categories={categoryBreakdown}
issues={oceanIssueBreakdown} issues={oceanIssueBreakdown}

View File

@@ -194,10 +194,8 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
<Card className="border-dashed"> <Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12 text-center"> <CardContent className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-muted-foreground">The requested jury group could not be found.</p> <p className="text-muted-foreground">The requested jury group could not be found.</p>
<Button asChild className="mt-4" variant="outline"> <Button className="mt-4" variant="outline" onClick={() => router.back()}>
<Link href={'/admin/juries' as Route}> Back
Back to Juries
</Link>
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -212,13 +210,11 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
asChild
className="mb-2" className="mb-2"
onClick={() => router.back()}
> >
<Link href={'/admin/juries' as Route}> <ArrowLeft className="h-4 w-4 mr-1" />
<ArrowLeft className="h-4 w-4 mr-1" /> Back
Back to Juries
</Link>
</Button> </Button>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
@@ -289,7 +285,9 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
{group.members.map((member) => ( {group.members.map((member) => (
<TableRow key={member.id}> <TableRow key={member.id}>
<TableCell className="font-medium"> <TableCell className="font-medium">
{member.user.name || 'Unnamed'} <Link href={`/admin/members/${member.user.id}` as Route} className="hover:underline text-primary">
{member.user.name || 'Unnamed'}
</Link>
</TableCell> </TableCell>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{member.user.email} {member.user.email}

View File

@@ -2,7 +2,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -257,11 +256,9 @@ export default function EditLearningResourcePage() {
The resource you&apos;re looking for does not exist. The resource you&apos;re looking for does not exist.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Button asChild> <Button onClick={() => router.back()}>
<Link href="/admin/learning"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Learning Hub
</Link>
</Button> </Button>
</div> </div>
) )
@@ -271,11 +268,9 @@ export default function EditLearningResourcePage() {
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
{/* Sticky toolbar */} {/* Sticky toolbar */}
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" onClick={() => router.back()}>
<Link href="/admin/learning"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back
</Link>
</Button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -2,7 +2,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -165,11 +164,9 @@ export default function NewLearningResourcePage() {
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
{/* Sticky toolbar */} {/* Sticky toolbar */}
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" onClick={() => router.back()}>
<Link href="/admin/learning"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back
</Link>
</Button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { directSessionUpdate } from '@/lib/session-update'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@@ -33,8 +34,10 @@ import {
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { TagInput } from '@/components/shared/tag-input' import { TagInput } from '@/components/shared/tag-input'
import { UserActivityLog } from '@/components/shared/user-activity-log' import { UserActivityLog } from '@/components/shared/user-activity-log'
import { EvaluationEditSheet } from '@/components/admin/evaluation-edit-sheet'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -45,6 +48,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { UserAvatar } from '@/components/shared/user-avatar'
import { import {
ArrowLeft, ArrowLeft,
Save, Save,
@@ -53,7 +57,48 @@ import {
Shield, Shield,
Loader2, Loader2,
AlertCircle, AlertCircle,
ClipboardList,
Eye,
ThumbsUp,
ThumbsDown,
Globe,
Building2,
FileText,
FolderOpen,
LogIn,
Calendar,
Clock,
} from 'lucide-react' } from 'lucide-react'
import { getCountryName, getCountryFlag } from '@/lib/countries'
import { formatRelativeTime } from '@/lib/utils'
function getRoleHomePath(role: string): string {
switch (role) {
case 'JURY_MEMBER': return '/jury'
case 'APPLICANT': return '/applicant'
case 'MENTOR': return '/mentor'
case 'OBSERVER': return '/observer'
default: return '/admin'
}
}
const statusVariant: Record<string, 'default' | 'success' | 'destructive' | 'secondary'> = {
ACTIVE: 'success',
SUSPENDED: 'destructive',
INVITED: 'secondary',
NONE: 'secondary',
}
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
JURY_MEMBER: 'default',
MENTOR: 'secondary',
OBSERVER: 'outline',
PROGRAM_ADMIN: 'default',
SUPER_ADMIN: 'default',
APPLICANT: 'secondary',
AWARD_MASTER: 'outline',
AUDIENCE: 'outline',
}
export default function MemberDetailPage() { export default function MemberDetailPage() {
const params = useParams() const params = useParams()
@@ -66,6 +111,7 @@ export default function MemberDetailPage() {
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN' const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
const updateUser = trpc.user.update.useMutation() const updateUser = trpc.user.update.useMutation()
const sendInvitation = trpc.user.sendInvitation.useMutation() const sendInvitation = trpc.user.sendInvitation.useMutation()
const startImpersonation = trpc.user.startImpersonation.useMutation()
// Mentor assignments (only fetched for mentors) // Mentor assignments (only fetched for mentors)
const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery( const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery(
@@ -73,6 +119,16 @@ export default function MemberDetailPage() {
{ enabled: user?.role === 'MENTOR' } { enabled: user?.role === 'MENTOR' }
) )
// Juror evaluations (only fetched for jury members)
const isJuror = user?.role === 'JURY_MEMBER' || user?.roles?.includes('JURY_MEMBER')
const { data: jurorEvaluations } = trpc.evaluation.getJurorEvaluations.useQuery(
{ userId },
{ enabled: !!user && !!isJuror }
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [selectedEvaluation, setSelectedEvaluation] = useState<any>(null)
const [name, setName] = useState('') const [name, setName] = useState('')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [role, setRole] = useState<string>('JURY_MEMBER') const [role, setRole] = useState<string>('JURY_MEMBER')
@@ -99,7 +155,7 @@ export default function MemberDetailPage() {
id: userId, id: userId,
email: email || undefined, email: email || undefined,
name: name || null, name: name || null,
role: role as 'SUPER_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN', role: role as 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE',
status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED', status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED',
expertiseTags, expertiseTags,
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null, maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
@@ -107,7 +163,6 @@ export default function MemberDetailPage() {
utils.user.get.invalidate({ id: userId }) utils.user.get.invalidate({ id: userId })
utils.user.list.invalidate() utils.user.list.invalidate()
toast.success('Member updated successfully') toast.success('Member updated successfully')
router.push('/admin/members')
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update member') toast.error(error instanceof Error ? error.message : 'Failed to update member')
} }
@@ -124,20 +179,37 @@ export default function MemberDetailPage() {
} }
} }
const handleImpersonate = async () => {
try {
const result = await startImpersonation.mutateAsync({ targetUserId: userId })
const ok = await directSessionUpdate({ impersonate: userId })
if (!ok) {
toast.error('Failed to update session for impersonation')
return
}
window.location.href = getRoleHomePath(result.targetRole)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to start impersonation')
}
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Skeleton className="h-9 w-32" /> <Skeleton className="h-9 w-32" />
<Card> <div className="flex items-center gap-4">
<CardHeader> <Skeleton className="h-16 w-16 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-6 w-48" /> <Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" /> <Skeleton className="h-4 w-72" />
</CardHeader> </div>
<CardContent className="space-y-4"> </div>
<Skeleton className="h-10 w-full" /> <div className="grid gap-6 lg:grid-cols-3">
<Skeleton className="h-10 w-full" /> <div className="lg:col-span-2 space-y-6">
</CardContent> <Skeleton className="h-48 w-full" />
</Card> </div>
<Skeleton className="h-64 w-full" />
</div>
</div> </div>
) )
} }
@@ -150,286 +222,573 @@ export default function MemberDetailPage() {
<AlertTitle>Error Loading Member</AlertTitle> <AlertTitle>Error Loading Member</AlertTitle>
<AlertDescription> <AlertDescription>
{error?.message || 'The member you\'re looking for does not exist.'} {error?.message || 'The member you\'re looking for does not exist.'}
{process.env.NODE_ENV === 'development' && (
<div className="mt-2 text-xs opacity-75">
User ID: {userId}
</div>
)}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Button asChild> <Button onClick={() => router.back()}>
<Link href="/admin/members"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Members
</Link>
</Button> </Button>
</div> </div>
) )
} }
const displayRoles = user.roles?.length ? user.roles : [user.role]
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Back nav */}
<div className="flex items-center gap-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Button variant="ghost" asChild className="-ml-4"> <ArrowLeft className="mr-2 h-4 w-4" />
<Link href="/admin/members"> Back
<ArrowLeft className="mr-2 h-4 w-4" /> </Button>
Back to Members
</Link>
</Button>
</div>
<div className="flex items-start justify-between"> {/* Header Hero */}
<div> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight"> <div className="flex items-center gap-4">
{user.name || 'Unnamed Member'} <UserAvatar user={user} avatarUrl={user.avatarUrl} size="lg" />
</h1> <div>
<div className="flex items-center gap-2 mt-1"> <h1 className="text-2xl font-semibold tracking-tight">
{user.name || 'Unnamed Member'}
</h1>
<p className="text-muted-foreground">{user.email}</p> <p className="text-muted-foreground">{user.email}</p>
<Badge variant={user.status === 'ACTIVE' ? 'success' : user.status === 'SUSPENDED' ? 'destructive' : 'secondary'}> <div className="flex items-center gap-2 mt-1.5">
{user.status === 'NONE' ? 'Not Invited' : user.status} <Badge variant={statusVariant[user.status] || 'secondary'}>
</Badge> {user.status === 'NONE' ? 'Not Invited' : user.status}
</Badge>
{displayRoles.map((r) => (
<Badge key={r} variant={roleColors[r] || 'secondary'} className="text-xs">
{r.replace(/_/g, ' ')}
</Badge>
))}
</div>
</div> </div>
</div> </div>
{(user.status === 'NONE' || user.status === 'INVITED') && ( <div className="flex items-center gap-2 shrink-0">
{(user.status === 'NONE' || user.status === 'INVITED') && (
<Button
variant="outline"
onClick={handleSendInvitation}
disabled={sendInvitation.isPending}
>
{sendInvitation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
{user.status === 'INVITED' ? 'Resend Invite' : 'Send Invitation'}
</Button>
)}
<Button <Button
variant="outline" variant="outline"
onClick={handleSendInvitation} onClick={handleImpersonate}
disabled={sendInvitation.isPending} disabled={startImpersonation.isPending}
> >
{sendInvitation.isPending ? ( {startImpersonation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
<Mail className="mr-2 h-4 w-4" /> <LogIn className="mr-2 h-4 w-4" />
)} )}
Send Invitation Impersonate
</Button> </Button>
)} </div>
</div> </div>
<div className="grid gap-6 md:grid-cols-2"> <Tabs defaultValue="profile" className="space-y-6">
{/* Basic Info */} <TabsList>
<Card> <TabsTrigger value="profile">
<CardHeader> <User className="h-4 w-4 mr-1" />
<CardTitle className="flex items-center gap-2"> Profile
<User className="h-5 w-5" /> </TabsTrigger>
Basic Information {isJuror && (
</CardTitle> <TabsTrigger value="evaluations">
</CardHeader> <ClipboardList className="h-4 w-4 mr-1" />
<CardContent className="space-y-4"> Evaluations
<div className="space-y-2"> {jurorEvaluations && jurorEvaluations.length > 0 && (
<Label htmlFor="email">Email</Label> <Badge variant="secondary" className="ml-1.5 text-xs px-1.5 py-0">
<Input {jurorEvaluations.length}
id="email" </Badge>
type="email" )}
value={email} </TabsTrigger>
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={role}
onValueChange={(v) => {
if (v === 'SUPER_ADMIN') {
setPendingSuperAdminRole(true)
setShowSuperAdminConfirm(true)
} else {
setRole(v)
}
}}
disabled={!isSuperAdmin && (user.role === 'SUPER_ADMIN' || user.role === 'PROGRAM_ADMIN')}
>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
{isSuperAdmin && (
<SelectItem value="SUPER_ADMIN">Super Admin</SelectItem>
)}
{isSuperAdmin && (
<SelectItem value="PROGRAM_ADMIN">Program Admin</SelectItem>
)}
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
<SelectItem value="MENTOR">Mentor</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger id="status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">Not Invited</SelectItem>
<SelectItem value="INVITED">Invited</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="SUSPENDED">Suspended</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Expertise & Capacity */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Expertise & Capacity
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Expertise Tags</Label>
<TagInput
value={expertiseTags}
onChange={setExpertiseTags}
placeholder="Select expertise tags..."
maxTags={15}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxAssignments">Max Assignments</Label>
<Input
id="maxAssignments"
type="number"
min="1"
max="100"
value={maxAssignments}
onChange={(e) => setMaxAssignments(e.target.value)}
placeholder="Unlimited"
/>
</div>
{user._count && (
<div className="pt-4 border-t">
<h4 className="font-medium mb-2">Statistics</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Jury Assignments</p>
<p className="text-2xl font-semibold">{user._count.assignments}</p>
</div>
<div>
<p className="text-muted-foreground">Mentor Assignments</p>
<p className="text-2xl font-semibold">{user._count.mentorAssignments}</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* Mentor Assignments Section */}
{user.role === 'MENTOR' && mentorAssignments && mentorAssignments.assignments.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Mentored Projects</CardTitle>
<CardDescription>
Projects this mentor is assigned to
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Status</TableHead>
<TableHead>Assigned</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mentorAssignments.assignments.map((assignment) => (
<TableRow key={assignment.id}>
<TableCell>
<Link
href={`/admin/projects/${assignment.project.id}`}
className="font-medium hover:underline"
>
{assignment.project.title}
</Link>
{assignment.project.teamName && (
<p className="text-sm text-muted-foreground">
{assignment.project.teamName}
</p>
)}
</TableCell>
<TableCell>
{assignment.project.competitionCategory ? (
<Badge variant="outline">
{assignment.project.competitionCategory.replace('_', ' ')}
</Badge>
) : (
'-'
)}
</TableCell>
<TableCell>
<Badge variant="secondary">
{assignment.project.status ?? 'SUBMITTED'}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(assignment.assignedAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Activity Log */}
<UserActivityLog userId={userId} />
{/* Status Alert */}
{user.status === 'NONE' && (
<Alert>
<Mail className="h-4 w-4" />
<AlertTitle>Not Yet Invited</AlertTitle>
<AlertDescription>
This member was added to the platform via project import but hasn&apos;t been
invited yet. Send them an invitation using the button above.
</AlertDescription>
</Alert>
)}
{user.status === 'INVITED' && (
<Alert>
<Mail className="h-4 w-4" />
<AlertTitle>Invitation Pending</AlertTitle>
<AlertDescription>
This member hasn&apos;t accepted their invitation yet. You can resend the
invitation email using the button above.
</AlertDescription>
</Alert>
)}
{/* Save Button */}
<div className="flex justify-end gap-4">
<Button variant="outline" asChild>
<Link href="/admin/members">Cancel</Link>
</Button>
<Button onClick={handleSave} disabled={updateUser.isPending}>
{updateUser.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)} )}
Save Changes </TabsList>
</Button>
</div> <TabsContent value="profile" className="space-y-6">
<div className="grid gap-6 lg:grid-cols-3">
{/* Left column: Profile info + Projects */}
<div className="lg:col-span-2 space-y-6">
{/* Profile Details (read-only) */}
{(user.nationality || user.country || user.institution || user.bio) && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<Globe className="h-4 w-4 text-blue-500" />
</div>
Profile Details
</CardTitle>
<CardDescription>Information provided during onboarding</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
{user.nationality && (
<div className="flex items-start gap-3 rounded-lg border p-3">
<span className="text-xl mt-0.5 shrink-0" role="img">{getCountryFlag(user.nationality)}</span>
<div>
<p className="text-xs font-medium text-muted-foreground">Nationality</p>
<p className="text-sm font-medium">{getCountryName(user.nationality)}</p>
</div>
</div>
)}
{user.country && (
<div className="flex items-start gap-3 rounded-lg border p-3">
<span className="text-xl mt-0.5 shrink-0" role="img">{getCountryFlag(user.country)}</span>
<div>
<p className="text-xs font-medium text-muted-foreground">Country of Residence</p>
<p className="text-sm font-medium">{getCountryName(user.country)}</p>
</div>
</div>
)}
{user.institution && (
<div className="flex items-start gap-3 rounded-lg border p-3">
<Building2 className="h-5 w-5 mt-0.5 text-muted-foreground shrink-0" />
<div>
<p className="text-xs font-medium text-muted-foreground">Institution / Organization</p>
<p className="text-sm font-medium">{user.institution}</p>
</div>
</div>
)}
{user.bio && (
<div className="sm:col-span-2 rounded-lg border p-3">
<div className="flex items-start gap-3">
<FileText className="h-5 w-5 mt-0.5 text-muted-foreground shrink-0" />
<div>
<p className="text-xs font-medium text-muted-foreground">Bio</p>
<p className="text-sm whitespace-pre-line mt-1">{user.bio}</p>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Projects */}
{user.teamMemberships && user.teamMemberships.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<FolderOpen className="h-4 w-4 text-emerald-500" />
</div>
Projects ({user.teamMemberships.length})
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
{user.teamMemberships.map((tm) => (
<Link
key={tm.id}
href={`/admin/projects/${tm.project.id}`}
className="flex items-center justify-between px-6 py-3 hover:bg-muted/50 transition-colors"
>
<div className="min-w-0">
<p className="font-medium text-sm truncate">{tm.project.title}</p>
{tm.project.teamName && (
<p className="text-xs text-muted-foreground">{tm.project.teamName}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
{tm.project.competitionCategory && (
<Badge variant="outline" className="text-xs">
{tm.project.competitionCategory.replace('_', ' ')}
</Badge>
)}
<Badge variant="secondary" className="text-xs">
{tm.role === 'LEAD' ? 'Lead' : tm.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</Badge>
</div>
</Link>
))}
</div>
</CardContent>
</Card>
)}
{/* Jury Groups */}
{user.juryGroupMemberships && user.juryGroupMemberships.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Shield className="h-4 w-4 text-violet-500" />
</div>
Jury Groups ({user.juryGroupMemberships.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{user.juryGroupMemberships.map((m: { id: string; role: string; juryGroup: { id: string; name: string } }) => (
<Badge key={m.id} variant="outline" className="text-sm py-1.5 px-3">
{m.juryGroup.name}
<span className="ml-1.5 text-xs text-muted-foreground">
({m.role === 'CHAIR' ? 'Chair' : m.role === 'OBSERVER' ? 'Observer' : 'Member'})
</span>
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Mentor Assignments */}
{user.role === 'MENTOR' && mentorAssignments && mentorAssignments.assignments.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<div className="rounded-lg bg-amber-500/10 p-1.5">
<ClipboardList className="h-4 w-4 text-amber-500" />
</div>
Mentored Projects
</CardTitle>
<CardDescription>
{mentorAssignments.assignments.length} project{mentorAssignments.assignments.length !== 1 ? 's' : ''} assigned
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Status</TableHead>
<TableHead>Assigned</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mentorAssignments.assignments.map((assignment) => (
<TableRow key={assignment.id}>
<TableCell>
<Link
href={`/admin/projects/${assignment.project.id}`}
className="font-medium hover:underline"
>
{assignment.project.title}
</Link>
{assignment.project.teamName && (
<p className="text-sm text-muted-foreground">{assignment.project.teamName}</p>
)}
</TableCell>
<TableCell>
{assignment.project.competitionCategory ? (
<Badge variant="outline">{assignment.project.competitionCategory.replace('_', ' ')}</Badge>
) : '-'}
</TableCell>
<TableCell>
<Badge variant="secondary">{assignment.project.status ?? 'SUBMITTED'}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(assignment.assignedAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Activity Log */}
<UserActivityLog userId={userId} />
</div>
{/* Right sidebar: Edit form + Quick info */}
<div className="space-y-6">
{/* Quick Info Card */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<div className="rounded-lg bg-slate-500/10 p-1.5">
<Clock className="h-4 w-4 text-slate-500" />
</div>
Quick Info
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Created</span>
<span>{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last Login</span>
<span>
{user.lastLoginAt ? (
<span title={new Date(user.lastLoginAt).toLocaleString()}>
{formatRelativeTime(user.lastLoginAt)}
</span>
) : 'Never'}
</span>
</div>
{user._count && !['APPLICANT', 'AUDIENCE'].includes(user.role) && (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Jury Assignments</span>
<span className="font-semibold">{user._count.assignments}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Mentor Assignments</span>
<span className="font-semibold">{user._count.mentorAssignments}</span>
</div>
</>
)}
</CardContent>
</Card>
{/* Status Alerts */}
{user.status === 'NONE' && (
<Alert>
<Mail className="h-4 w-4" />
<AlertTitle>Not Yet Invited</AlertTitle>
<AlertDescription>
This member was added via import but hasn&apos;t been invited yet.
</AlertDescription>
</Alert>
)}
{user.status === 'INVITED' && (
<Alert>
<Mail className="h-4 w-4" />
<AlertTitle>Invitation Pending</AlertTitle>
<AlertDescription>
This member hasn&apos;t accepted their invitation yet.
</AlertDescription>
</Alert>
)}
{/* Basic Info Edit */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<User className="h-4 w-4 text-blue-500" />
</div>
Edit Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={role}
onValueChange={(v) => {
if (v === 'SUPER_ADMIN') {
setPendingSuperAdminRole(true)
setShowSuperAdminConfirm(true)
} else {
setRole(v)
}
}}
disabled={!isSuperAdmin && (user.role === 'SUPER_ADMIN' || user.role === 'PROGRAM_ADMIN')}
>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
{isSuperAdmin && <SelectItem value="SUPER_ADMIN">Super Admin</SelectItem>}
{isSuperAdmin && <SelectItem value="PROGRAM_ADMIN">Program Admin</SelectItem>}
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
<SelectItem value="MENTOR">Mentor</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
<SelectItem value="APPLICANT">Applicant</SelectItem>
<SelectItem value="AWARD_MASTER">Award Master</SelectItem>
<SelectItem value="AUDIENCE">Audience</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger id="status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">Not Invited</SelectItem>
<SelectItem value="INVITED">Invited</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="SUSPENDED">Suspended</SelectItem>
</SelectContent>
</Select>
</div>
{/* Expertise & Capacity for non-applicant */}
{!['APPLICANT', 'AUDIENCE'].includes(user.role) && (
<>
<div className="border-t pt-4 space-y-2">
<Label>Expertise Tags</Label>
<TagInput
value={expertiseTags}
onChange={setExpertiseTags}
placeholder="Select expertise tags..."
maxTags={15}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxAssignments">Max Assignments</Label>
<Input
id="maxAssignments"
type="number"
min="1"
max="100"
value={maxAssignments}
onChange={(e) => setMaxAssignments(e.target.value)}
placeholder="Unlimited"
/>
</div>
</>
)}
<Button onClick={handleSave} disabled={updateUser.isPending} className="w-full">
{updateUser.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
</CardContent>
</Card>
</div>
</div>
</TabsContent>
{/* Evaluations Tab */}
{isJuror && (
<TabsContent value="evaluations" className="space-y-4">
{!jurorEvaluations || jurorEvaluations.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<ClipboardList className="h-12 w-12 text-muted-foreground/30" />
<p className="mt-2 text-muted-foreground">No evaluations submitted yet</p>
</CardContent>
</Card>
) : (
(() => {
const byRound = new Map<string, typeof jurorEvaluations>()
for (const ev of jurorEvaluations) {
const key = ev.roundName
if (!byRound.has(key)) byRound.set(key, [])
byRound.get(key)!.push(ev)
}
return Array.from(byRound.entries()).map(([roundName, evals]) => (
<Card key={roundName}>
<CardHeader>
<CardTitle className="text-base">{roundName}</CardTitle>
<CardDescription>{evals.length} evaluation{evals.length !== 1 ? 's' : ''}</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Score</TableHead>
<TableHead>Decision</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{evals.map((ev) => (
<TableRow key={ev.assignmentId}>
<TableCell className="font-medium">
<Link
href={`/admin/projects/${ev.projectId}`}
className="hover:underline text-primary"
>
{ev.projectTitle}
</Link>
</TableCell>
<TableCell>
{ev.evaluation.globalScore !== null && ev.evaluation.globalScore !== undefined
? <span className="font-medium">{ev.evaluation.globalScore}/10</span>
: <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell>
{ev.evaluation.binaryDecision !== null && ev.evaluation.binaryDecision !== undefined ? (
ev.evaluation.binaryDecision ? (
<div className="flex items-center gap-1 text-green-600">
<ThumbsUp className="h-4 w-4" />
<span className="text-sm">Yes</span>
</div>
) : (
<div className="flex items-center gap-1 text-red-600">
<ThumbsDown className="h-4 w-4" />
<span className="text-sm">No</span>
</div>
)
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<Badge variant={ev.evaluation.status === 'SUBMITTED' ? 'default' : 'secondary'}>
{ev.evaluation.status.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{ev.evaluation.submittedAt
? new Date(ev.evaluation.submittedAt).toLocaleDateString()
: '-'}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedEvaluation({
...ev,
user: user,
evaluation: ev.evaluation,
})}
>
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
))
})()
)}
<EvaluationEditSheet
assignment={selectedEvaluation}
open={!!selectedEvaluation}
onOpenChange={(open) => { if (!open) setSelectedEvaluation(null) }}
onSaved={() => utils.evaluation.getJurorEvaluations.invalidate({ userId })}
/>
</TabsContent>
)}
</Tabs>
{/* Super Admin Confirmation Dialog */} {/* Super Admin Confirmation Dialog */}
<AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}> <AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}>
@@ -443,11 +802,7 @@ export default function MemberDetailPage() {
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel <AlertDialogCancel onClick={() => setPendingSuperAdminRole(false)}>
onClick={() => {
setPendingSuperAdminRole(false)
}}
>
Cancel Cancel
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction

View File

@@ -2,6 +2,7 @@
import { useState, useCallback, useMemo } from 'react' import { useState, useCallback, useMemo } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation'
import Papa from 'papaparse' import Papa from 'papaparse'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -257,6 +258,7 @@ function TagPicker({
} }
export default function MemberInvitePage() { export default function MemberInvitePage() {
const router = useRouter()
const [step, setStep] = useState<Step>('input') const [step, setStep] = useState<Step>('input')
const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual') const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual')
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()]) const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
@@ -1044,11 +1046,9 @@ export default function MemberInvitePage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href="/admin/members"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Members
</Link>
</Button> </Button>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import Link from 'next/link' import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { import {
Card, Card,
@@ -83,6 +83,7 @@ const defaultForm: TemplateFormData = {
} }
export default function MessageTemplatesPage() { export default function MessageTemplatesPage() {
const router = useRouter()
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = useState<string | null>(null)
@@ -183,11 +184,9 @@ export default function MessageTemplatesPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href="/admin/messages"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Messages
</Link>
</Button> </Button>
</div> </div>

View File

@@ -135,11 +135,9 @@ export default function EditPartnerPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/admin/partners"> <Button variant="ghost" size="icon" onClick={() => router.back()}>
<Button variant="ghost" size="icon"> <ArrowLeft className="h-4 w-4" />
<ArrowLeft className="h-4 w-4" /> </Button>
</Button>
</Link>
<div> <div>
<h1 className="text-2xl font-bold">Edit Partner</h1> <h1 className="text-2xl font-bold">Edit Partner</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">

View File

@@ -66,11 +66,9 @@ export default function NewPartnerPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/admin/partners"> <Button variant="ghost" size="icon" onClick={() => router.back()}>
<Button variant="ghost" size="icon"> <ArrowLeft className="h-4 w-4" />
<ArrowLeft className="h-4 w-4" /> </Button>
</Button>
</Link>
<div> <div>
<h1 className="text-2xl font-bold">Add Partner</h1> <h1 className="text-2xl font-bold">Add Partner</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">

View File

@@ -134,11 +134,9 @@ export default function EditProgramPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href={`/admin/programs/${id}`}> <Button variant="ghost" size="icon" onClick={() => router.back()}>
<Button variant="ghost" size="icon"> <ArrowLeft className="h-4 w-4" />
<ArrowLeft className="h-4 w-4" /> </Button>
</Button>
</Link>
<div> <div>
<h1 className="text-2xl font-bold">Edit Program</h1> <h1 className="text-2xl font-bold">Edit Program</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">

View File

@@ -1,8 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useParams } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { import {
Card, Card,
@@ -68,6 +67,7 @@ const defaultMilestoneForm: MilestoneFormData = {
export default function MentorshipMilestonesPage() { export default function MentorshipMilestonesPage() {
const params = useParams() const params = useParams()
const router = useRouter()
const programId = params.id as string const programId = params.id as string
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
@@ -184,11 +184,9 @@ export default function MentorshipMilestonesPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href="/admin/programs"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Programs
</Link>
</Button> </Button>
</div> </div>

View File

@@ -56,11 +56,9 @@ export default function NewProgramPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/admin/programs"> <Button variant="ghost" size="icon" onClick={() => router.back()}>
<Button variant="ghost" size="icon"> <ArrowLeft className="h-4 w-4" />
<ArrowLeft className="h-4 w-4" /> </Button>
</Button>
</Link>
<div> <div>
<h1 className="text-2xl font-bold">Create Program</h1> <h1 className="text-2xl font-bold">Create Program</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { Suspense, use, useState, useEffect, useCallback } from 'react' import { Suspense, use, useState, useEffect, useCallback, useMemo } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
@@ -19,6 +19,8 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { CountrySelect } from '@/components/ui/country-select'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { import {
Select, Select,
@@ -55,6 +57,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { Separator } from '@/components/ui/separator'
import { FileUpload } from '@/components/shared/file-upload' import { FileUpload } from '@/components/shared/file-upload'
import { ProjectLogo } from '@/components/shared/project-logo' import { ProjectLogo } from '@/components/shared/project-logo'
import { LogoUpload } from '@/components/shared/logo-upload' import { LogoUpload } from '@/components/shared/logo-upload'
@@ -87,8 +90,15 @@ const updateProjectSchema = z.object({
'SEMIFINALIST', 'SEMIFINALIST',
'FINALIST', 'FINALIST',
'REJECTED', 'REJECTED',
]), ]).optional(),
tags: z.array(z.string()), tags: z.array(z.string()),
competitionCategory: z.string().optional(),
oceanIssue: z.string().optional(),
institution: z.string().optional(),
country: z.string().optional(),
geographicZone: z.string().optional(),
wantsMentorship: z.boolean().optional(),
foundedAt: z.string().optional(),
}) })
type UpdateProjectForm = z.infer<typeof updateProjectSchema> type UpdateProjectForm = z.infer<typeof updateProjectSchema>
@@ -124,6 +134,27 @@ function EditProjectContent({ projectId }: { projectId: string }) {
// Fetch existing tags for suggestions // Fetch existing tags for suggestions
const { data: existingTags } = trpc.project.getTags.useQuery({}) const { data: existingTags } = trpc.project.getTags.useQuery({})
// Fetch submission round config to show required documents
const programId = project?.programId
const { data: submissionRound } = trpc.round.getSubmissionRoundForProgram.useQuery(
{ programId: programId! },
{ enabled: !!programId }
)
const submissionRoundConfig = useMemo(() => {
if (!submissionRound?.configJson) return null
const config = submissionRound.configJson as Record<string, unknown>
const docs = config.requiredDocuments as
| Array<{ name: string; required?: boolean; description?: string }>
| null
| undefined
if (!docs || docs.length === 0) return null
return {
roundName: submissionRound.name,
requiredDocuments: docs,
}
}, [submissionRound])
// Mutations // Mutations
const utils = trpc.useUtils() const utils = trpc.useUtils()
const updateProject = trpc.project.update.useMutation({ const updateProject = trpc.project.update.useMutation({
@@ -155,8 +186,15 @@ function EditProjectContent({ projectId }: { projectId: string }) {
title: '', title: '',
teamName: '', teamName: '',
description: '', description: '',
status: 'SUBMITTED', status: 'SUBMITTED' as const,
tags: [], tags: [],
competitionCategory: '',
oceanIssue: '',
institution: '',
country: '',
geographicZone: '',
wantsMentorship: false,
foundedAt: '',
}, },
}) })
@@ -167,8 +205,15 @@ function EditProjectContent({ projectId }: { projectId: string }) {
title: project.title, title: project.title,
teamName: project.teamName || '', teamName: project.teamName || '',
description: project.description || '', description: project.description || '',
status: (project.status ?? 'SUBMITTED') as UpdateProjectForm['status'], status: (project.status || 'SUBMITTED') as UpdateProjectForm['status'],
tags: project.tags || [], tags: project.tags || [],
competitionCategory: project.competitionCategory || '',
oceanIssue: project.oceanIssue || '',
institution: project.institution || '',
country: project.country || '',
geographicZone: project.geographicZone || '',
wantsMentorship: project.wantsMentorship ?? false,
foundedAt: project.foundedAt ? new Date(project.foundedAt).toISOString().split('T')[0] : '',
}) })
} }
}, [project, form]) }, [project, form])
@@ -176,7 +221,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
const tags = form.watch('tags') const tags = form.watch('tags')
const selectedStatus = form.watch('status') const selectedStatus = form.watch('status')
const previousStatus = (project?.status ?? 'SUBMITTED') as UpdateProjectForm['status'] const previousStatus = (project?.status ?? 'SUBMITTED') as UpdateProjectForm['status']
const statusTriggersNotifications = ['SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(selectedStatus) const statusTriggersNotifications = !!selectedStatus && ['SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(selectedStatus)
const requiresStatusNotificationConfirmation = Boolean( const requiresStatusNotificationConfirmation = Boolean(
project && selectedStatus !== previousStatus && statusTriggersNotifications project && selectedStatus !== previousStatus && statusTriggersNotifications
) )
@@ -222,13 +267,21 @@ function EditProjectContent({ projectId }: { projectId: string }) {
return return
} }
const statusChanged = data.status !== previousStatus
await updateProject.mutateAsync({ await updateProject.mutateAsync({
id: projectId, id: projectId,
title: data.title, title: data.title,
teamName: data.teamName || null, teamName: data.teamName || null,
description: data.description || null, description: data.description || null,
status: data.status, ...(statusChanged && { status: data.status }),
tags: data.tags, tags: data.tags,
competitionCategory: (data.competitionCategory || null) as 'STARTUP' | 'BUSINESS_CONCEPT' | null,
oceanIssue: (data.oceanIssue || null) as 'POLLUTION_REDUCTION' | 'CLIMATE_MITIGATION' | 'TECHNOLOGY_INNOVATION' | 'SUSTAINABLE_SHIPPING' | 'BLUE_CARBON' | 'HABITAT_RESTORATION' | 'COMMUNITY_CAPACITY' | 'SUSTAINABLE_FISHING' | 'CONSUMER_AWARENESS' | 'OCEAN_ACIDIFICATION' | 'OTHER' | null,
institution: data.institution || null,
country: data.country || null,
geographicZone: data.geographicZone || null,
wantsMentorship: data.wantsMentorship,
foundedAt: data.foundedAt ? new Date(data.foundedAt).toISOString() : null,
}) })
} }
@@ -247,19 +300,17 @@ function EditProjectContent({ projectId }: { projectId: string }) {
if (!project) { if (!project) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href="/admin/projects"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Projects
</Link>
</Button> </Button>
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center"> <CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" /> <AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium">Project Not Found</p> <p className="mt-2 font-medium">Project Not Found</p>
<Button asChild className="mt-4"> <Button className="mt-4" onClick={() => router.back()}>
<Link href="/admin/projects">Back to Projects</Link> Back
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -277,11 +328,9 @@ function EditProjectContent({ projectId }: { projectId: string }) {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href={`/admin/projects/${projectId}`}> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Project
</Link>
</Button> </Button>
</div> </div>
@@ -386,7 +435,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue placeholder="Select status..." />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
@@ -438,6 +487,159 @@ function EditProjectContent({ projectId }: { projectId: string }) {
</CardContent> </CardContent>
</Card> </Card>
{/* Project Details */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Project Details</CardTitle>
<CardDescription>
Additional categorization and metadata for this project
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="competitionCategory"
render={({ field }) => (
<FormItem>
<FormLabel>Competition Category</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || ''}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select category..." />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="STARTUP">Startup</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="oceanIssue"
render={({ field }) => (
<FormItem>
<FormLabel>Ocean Issue</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || ''}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select ocean issue..." />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="POLLUTION_REDUCTION">Pollution Reduction</SelectItem>
<SelectItem value="CLIMATE_MITIGATION">Climate Mitigation</SelectItem>
<SelectItem value="TECHNOLOGY_INNOVATION">Technology Innovation</SelectItem>
<SelectItem value="SUSTAINABLE_SHIPPING">Sustainable Shipping</SelectItem>
<SelectItem value="BLUE_CARBON">Blue Carbon</SelectItem>
<SelectItem value="HABITAT_RESTORATION">Habitat Restoration</SelectItem>
<SelectItem value="COMMUNITY_CAPACITY">Community Capacity</SelectItem>
<SelectItem value="SUSTAINABLE_FISHING">Sustainable Fishing</SelectItem>
<SelectItem value="CONSUMER_AWARENESS">Consumer Awareness</SelectItem>
<SelectItem value="OCEAN_ACIDIFICATION">Ocean Acidification</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="institution"
render={({ field }) => (
<FormItem>
<FormLabel>Institution</FormLabel>
<FormControl>
<Input placeholder="Institution or organization" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="country"
render={({ field }) => (
<FormItem>
<FormLabel>Country</FormLabel>
<FormControl>
<CountrySelect
value={field.value || ''}
onChange={field.onChange}
placeholder="Select country..."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="geographicZone"
render={({ field }) => (
<FormItem>
<FormLabel>Geographic Zone</FormLabel>
<FormControl>
<Input placeholder="e.g. Europe, France" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="foundedAt"
render={({ field }) => (
<FormItem>
<FormLabel>Founded Date</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="wantsMentorship"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>Wants Mentorship</FormLabel>
<FormDescription>
Whether this project team is interested in mentorship
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value ?? false}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Tags */} {/* Tags */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -519,7 +721,34 @@ function EditProjectContent({ projectId }: { projectId: string }) {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{files && files.length > 0 ? ( {submissionRoundConfig && (
<div className="mb-4 space-y-3">
<div>
<p className="text-sm font-medium">Required Documents</p>
<p className="text-xs text-muted-foreground">
From {submissionRoundConfig.roundName}
</p>
</div>
<div className="space-y-2">
{submissionRoundConfig.requiredDocuments.map((doc, i) => (
<div key={i} className="flex items-center justify-between rounded-md border border-dashed p-3">
<div>
<p className="text-sm font-medium">{doc.name}</p>
{doc.description && (
<p className="text-xs text-muted-foreground">{doc.description}</p>
)}
{doc.required && (
<Badge variant="outline" className="mt-1 text-xs">Required</Badge>
)}
</div>
</div>
))}
</div>
<Separator />
</div>
)}
{files && files.length > 0 ? (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { Suspense, use, useState } from 'react' import { Suspense, use, useState } from 'react'
import Link from 'next/link' import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
@@ -46,6 +46,7 @@ interface MentorSuggestion {
} }
function MentorAssignmentContent({ projectId }: { projectId: string }) { function MentorAssignmentContent({ projectId }: { projectId: string }) {
const router = useRouter()
const [selectedMentorId, setSelectedMentorId] = useState<string | null>(null) const [selectedMentorId, setSelectedMentorId] = useState<string | null>(null)
const utils = trpc.useUtils() const utils = trpc.useUtils()
@@ -128,11 +129,9 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href={`/admin/projects/${projectId}`}> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Project
</Link>
</Button> </Button>
</div> </div>

View File

@@ -3,6 +3,7 @@
import { Suspense, use, useState } from 'react' import { Suspense, use, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { import {
Card, Card,
@@ -23,18 +24,35 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { FileViewer } from '@/components/shared/file-viewer' import { FileViewer } from '@/components/shared/file-viewer'
import { FileUpload } from '@/components/shared/file-upload' import { FileUpload } from '@/components/shared/file-upload'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url' import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
import { UserAvatar } from '@/components/shared/user-avatar' import { UserAvatar } from '@/components/shared/user-avatar'
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card' import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
import { import { EvaluationEditSheet } from '@/components/admin/evaluation-edit-sheet'
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { import {
ArrowLeft, ArrowLeft,
@@ -43,7 +61,6 @@ import {
Users, Users,
FileText, FileText,
Calendar, Calendar,
Clock,
BarChart3, BarChart3,
ThumbsUp, ThumbsUp,
ThumbsDown, ThumbsDown,
@@ -56,10 +73,13 @@ import {
Loader2, Loader2,
ScanSearch, ScanSearch,
Eye, Eye,
MessageSquare, Plus,
X,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatDate, formatDateOnly } from '@/lib/utils' import { formatDateOnly } from '@/lib/utils'
import { getCountryName, getCountryFlag } from '@/lib/countries'
import { CountryDisplay } from '@/components/shared/country-display'
interface PageProps { interface PageProps {
params: Promise<{ id: string }> params: Promise<{ id: string }>
@@ -84,6 +104,7 @@ const evalStatusColors: Record<string, 'default' | 'secondary' | 'destructive' |
} }
function ProjectDetailContent({ projectId }: { projectId: string }) { function ProjectDetailContent({ projectId }: { projectId: string }) {
const router = useRouter()
// Fetch project + assignments + stats in a single combined query // Fetch project + assignments + stats in a single combined query
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery( const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery(
{ id: projectId }, { id: projectId },
@@ -128,6 +149,52 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null) const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null)
// State for add member dialog
const [addMemberOpen, setAddMemberOpen] = useState(false)
const [addMemberForm, setAddMemberForm] = useState({
email: '',
name: '',
role: 'MEMBER' as 'LEAD' | 'MEMBER' | 'ADVISOR',
title: '',
sendInvite: true,
})
// State for remove member confirmation
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
const addTeamMember = trpc.project.addTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member added')
setAddMemberOpen(false)
setAddMemberForm({ email: '', name: '', role: 'MEMBER', title: '', sendInvite: true })
utils.project.getFullDetail.invalidate({ id: projectId })
},
onError: (err) => {
toast.error(err.message || 'Failed to add team member')
},
})
const updateTeamMemberRole = trpc.project.updateTeamMemberRole.useMutation({
onSuccess: () => {
toast.success('Role updated')
utils.project.getFullDetail.invalidate({ id: projectId })
},
onError: (err) => {
toast.error(err.message || 'Failed to update role')
},
})
const removeTeamMember = trpc.project.removeTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member removed')
setRemovingMemberId(null)
utils.project.getFullDetail.invalidate({ id: projectId })
},
onError: (err) => {
toast.error(err.message || 'Failed to remove team member')
},
})
if (isLoading) { if (isLoading) {
return <ProjectDetailSkeleton /> return <ProjectDetailSkeleton />
} }
@@ -135,19 +202,17 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
if (!project) { if (!project) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href="/admin/projects"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Projects
</Link>
</Button> </Button>
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center"> <CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" /> <AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium">Project Not Found</p> <p className="mt-2 font-medium">Project Not Found</p>
<Button asChild className="mt-4"> <Button className="mt-4" onClick={() => router.back()}>
<Link href="/admin/projects">Back to Projects</Link> Back
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -159,11 +224,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href="/admin/projects"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Projects
</Link>
</Button> </Button>
</div> </div>
@@ -173,6 +236,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
project={project} project={project}
size="lg" size="lg"
fallback="initials" fallback="initials"
clickToEnlarge
/> />
<div className="space-y-1"> <div className="space-y-1">
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
@@ -191,9 +255,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
{project.title} {project.title}
</h1> </h1>
<Badge variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}> {(() => {
{(project.status ?? 'SUBMITTED').replace('_', ' ')} const prs = (project as any).projectRoundStates ?? []
</Badge> if (!prs.length) return <Badge variant="secondary">Submitted</Badge>
if (prs.some((p: any) => p.state === 'REJECTED')) return <Badge variant="destructive">Rejected</Badge>
const latest = prs[0]
return <Badge variant={latest.state === 'PASSED' ? 'default' : 'secondary'}>{latest.round.name}</Badge>
})()}
</div> </div>
{project.teamName && ( {project.teamName && (
<p className="text-muted-foreground">{project.teamName}</p> <p className="text-muted-foreground">{project.teamName}</p>
@@ -306,7 +374,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" /> <MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
<div> <div>
<p className="text-sm font-medium text-muted-foreground">Location</p> <p className="text-sm font-medium text-muted-foreground">Location</p>
<p className="text-sm">{project.geographicZone || project.country}</p> <p className="text-sm">{project.geographicZone}{project.geographicZone && project.country ? ', ' : ''}{project.country ? <CountryDisplay country={project.country} /> : null}</p>
</div> </div>
</div> </div>
)} )}
@@ -437,53 +505,229 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</AnimatedCard> </AnimatedCard>
{/* Team Members Section */} {/* Team Members Section */}
{project.teamMembers && project.teamMembers.length > 0 && ( <AnimatedCard index={2}>
<AnimatedCard index={2}> <Card>
<Card> <CardHeader>
<CardHeader> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <CardTitle className="flex items-center gap-2.5 text-lg">
<CardTitle className="flex items-center gap-2.5 text-lg"> <div className="rounded-lg bg-violet-500/10 p-1.5">
<div className="rounded-lg bg-violet-500/10 p-1.5"> <Users className="h-4 w-4 text-violet-500" />
<Users className="h-4 w-4 text-violet-500" /> </div>
</div> Team Members ({project.teamMembers?.length ?? 0})
Team Members ({project.teamMembers.length}) </CardTitle>
</CardTitle> <Button variant="outline" size="sm" onClick={() => setAddMemberOpen(true)}>
</div> <Plus className="mr-2 h-4 w-4" />
</CardHeader> Add Member
<CardContent> </Button>
</div>
</CardHeader>
<CardContent>
{project.teamMembers && project.teamMembers.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null } }) => ( {project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null; nationality?: string | null; country?: string | null; institution?: string | null } }) => {
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border"> const isLastLead =
{member.role === 'LEAD' ? ( member.role === 'LEAD' &&
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted"> project.teamMembers.filter((m: { role: string }) => m.role === 'LEAD').length <= 1
<Crown className="h-5 w-5 text-yellow-500" /> const details = [
</div> member.user.nationality ? `${getCountryFlag(member.user.nationality)} ${getCountryName(member.user.nationality)}` : null,
) : ( member.user.institution,
<UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" /> member.user.country && member.user.country !== member.user.nationality ? `${getCountryFlag(member.user.country)} ${getCountryName(member.user.country)}` : null,
)} ].filter(Boolean)
<div className="flex-1 min-w-0"> return (
<div className="flex items-center gap-2"> <div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
<p className="font-medium text-sm truncate"> {member.role === 'LEAD' ? (
{member.user.name || 'Unnamed'} <div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
</p> <Crown className="h-5 w-5 text-yellow-500" />
<Badge variant="outline" className="text-xs"> </div>
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'} ) : (
</Badge> <UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" />
</div>
<p className="text-xs text-muted-foreground truncate">
{member.user.email}
</p>
{member.title && (
<p className="text-xs text-muted-foreground">{member.title}</p>
)} )}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Link href={`/admin/members/${member.user.id}`} className="font-medium text-sm truncate hover:underline text-primary">
{member.user.name || 'Unnamed'}
</Link>
<Select
value={member.role}
onValueChange={(value) =>
updateTeamMemberRole.mutate({
projectId: project.id,
userId: member.user.id,
role: value as 'LEAD' | 'MEMBER' | 'ADVISOR',
})
}
>
<SelectTrigger className="h-6 w-auto text-xs px-2 py-0 border-dashed gap-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LEAD">Lead</SelectItem>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground truncate">
{member.user.email}
</p>
{member.title && (
<p className="text-xs text-muted-foreground">{member.title}</p>
)}
{details.length > 0 && (
<p className="text-xs text-muted-foreground truncate">
{details.join(' · ')}
</p>
)}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
disabled={isLastLead}
onClick={() => setRemovingMemberId(member.user.id)}
>
<X className="h-4 w-4" />
</Button>
</span>
</TooltipTrigger>
{isLastLead && (
<TooltipContent>
Cannot remove the last team lead
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div> </div>
</div> )
))} })}
</div> </div>
</CardContent> ) : (
</Card> <p className="text-sm text-muted-foreground">No team members yet.</p>
</AnimatedCard> )}
)} </CardContent>
</Card>
</AnimatedCard>
{/* Add Member Dialog */}
<Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Team Member</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="member-email">Email</Label>
<Input
id="member-email"
type="email"
placeholder="member@example.com"
value={addMemberForm.email}
onChange={(e) => setAddMemberForm((f) => ({ ...f, email: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="member-name">Name</Label>
<Input
id="member-name"
placeholder="Full name"
value={addMemberForm.name}
onChange={(e) => setAddMemberForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="member-role">Role</Label>
<Select
value={addMemberForm.role}
onValueChange={(v) => setAddMemberForm((f) => ({ ...f, role: v as 'LEAD' | 'MEMBER' | 'ADVISOR' }))}
>
<SelectTrigger id="member-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LEAD">Lead</SelectItem>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="member-title">Title (optional)</Label>
<Input
id="member-title"
placeholder="e.g. CEO, Co-founder"
value={addMemberForm.title}
onChange={(e) => setAddMemberForm((f) => ({ ...f, title: e.target.value }))}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="member-invite"
checked={addMemberForm.sendInvite}
onCheckedChange={(checked) =>
setAddMemberForm((f) => ({ ...f, sendInvite: checked === true }))
}
/>
<Label htmlFor="member-invite" className="font-normal cursor-pointer">
Send invite email
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddMemberOpen(false)}>
Cancel
</Button>
<Button
onClick={() =>
addTeamMember.mutate({
projectId,
email: addMemberForm.email,
name: addMemberForm.name,
role: addMemberForm.role,
title: addMemberForm.title || undefined,
sendInvite: addMemberForm.sendInvite,
})
}
disabled={addTeamMember.isPending || !addMemberForm.email || !addMemberForm.name}
>
{addTeamMember.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Add Member
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Remove Member Confirmation Dialog */}
<Dialog open={!!removingMemberId} onOpenChange={(open) => { if (!open) setRemovingMemberId(null) }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Remove Team Member</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Are you sure you want to remove this team member? This action cannot be undone.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setRemovingMemberId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (removingMemberId) {
removeTeamMember.mutate({ projectId, userId: removingMemberId })
}
}}
disabled={removeTeamMember.isPending}
>
{removeTeamMember.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Mentor Assignment Section */} {/* Mentor Assignment Section */}
{project.wantsMentorship && ( {project.wantsMentorship && (
@@ -571,33 +815,48 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
/> />
</div> </div>
{/* All Files list */} {/* All Files list — grouped by round */}
{files && files.length > 0 && ( {files && files.length > 0 && (
<> <>
<Separator /> <Separator />
<FileViewer <FileViewer
projectId={projectId} projectId={projectId}
files={files.map((f) => ({ groupedFiles={(() => {
id: f.id, const groups = new Map<string, { roundId: string | null; roundName: string; sortOrder: number; files: typeof mappedFiles }>()
fileName: f.fileName, const mappedFiles = files.map((f) => ({
fileType: f.fileType, id: f.id,
mimeType: f.mimeType, fileName: f.fileName,
size: f.size, fileType: f.fileType as 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' | 'BUSINESS_PLAN' | 'VIDEO_PITCH' | 'SUPPORTING_DOC',
bucket: f.bucket, mimeType: f.mimeType,
objectKey: f.objectKey, size: f.size,
pageCount: f.pageCount, bucket: f.bucket,
textPreview: f.textPreview, objectKey: f.objectKey,
detectedLang: f.detectedLang, pageCount: f.pageCount,
langConfidence: f.langConfidence, textPreview: f.textPreview,
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null, detectedLang: f.detectedLang,
requirementId: f.requirementId, langConfidence: f.langConfidence,
requirement: f.requirement ? { analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
id: f.requirement.id, requirementId: f.requirementId,
name: f.requirement.name, requirement: f.requirement ? {
description: f.requirement.description, id: f.requirement.id,
isRequired: f.requirement.isRequired, name: f.requirement.name,
} : null, description: f.requirement.description,
}))} isRequired: f.requirement.isRequired,
} : null,
}))
for (const f of files) {
const roundId = f.requirement?.roundId ?? null
const roundName = f.requirement?.round?.name ?? 'General'
const sortOrder = f.requirement?.round?.sortOrder ?? -1
const key = roundId ?? '_general'
if (!groups.has(key)) {
groups.set(key, { roundId, roundName, sortOrder, files: [] })
}
const mapped = mappedFiles.find((m) => m.id === f.id)!
groups.get(key)!.files.push(mapped)
}
return Array.from(groups.values())
})()}
/> />
</> </>
)} )}
@@ -742,10 +1001,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
)} )}
{/* Evaluation Detail Sheet */} {/* Evaluation Detail Sheet */}
<EvaluationDetailSheet <EvaluationEditSheet
assignment={selectedEvalAssignment} assignment={selectedEvalAssignment}
open={!!selectedEvalAssignment} open={!!selectedEvalAssignment}
onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }} onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }}
onSaved={() => utils.project.getFullDetail.invalidate({ id: projectId })}
/> />
{/* AI Evaluation Summary */} {/* AI Evaluation Summary */}
@@ -830,173 +1090,6 @@ function AnalyzeDocumentsButton({ projectId, onComplete }: { projectId: string;
) )
} }
function EvaluationDetailSheet({
assignment,
open,
onOpenChange,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assignment: any
open: boolean
onOpenChange: (open: boolean) => void
}) {
if (!assignment?.evaluation) return null
const ev = assignment.evaluation
const criterionScores = (ev.criterionScoresJson || {}) as Record<string, number | boolean | string>
const hasScores = Object.keys(criterionScores).length > 0
// Try to get the evaluation form for labels
const roundId = assignment.roundId as string | undefined
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
{ roundId: roundId ?? '' },
{ enabled: !!roundId }
)
// Build label lookup from form criteria
const criteriaMap = new Map<string, { label: string; type: string; trueLabel?: string; falseLabel?: string }>()
if (activeForm?.criteriaJson) {
for (const c of activeForm.criteriaJson as Array<{ id: string; label: string; type?: string; trueLabel?: string; falseLabel?: string }>) {
criteriaMap.set(c.id, {
label: c.label,
type: c.type || 'numeric',
trueLabel: c.trueLabel,
falseLabel: c.falseLabel,
})
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<UserAvatar user={assignment.user} avatarUrl={assignment.user.avatarUrl} size="sm" />
{assignment.user.name || assignment.user.email}
</SheetTitle>
<SheetDescription>
{ev.submittedAt
? `Submitted ${formatDate(ev.submittedAt)}`
: 'Evaluation details'}
</SheetDescription>
</SheetHeader>
<div className="space-y-6 mt-6">
{/* Global stats */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-muted">
<p className="text-xs text-muted-foreground">Score</p>
<p className="text-2xl font-bold">
{ev.globalScore !== null ? `${ev.globalScore}/10` : '-'}
</p>
</div>
<div className="p-3 rounded-lg bg-muted">
<p className="text-xs text-muted-foreground">Decision</p>
<div className="mt-1">
{ev.binaryDecision !== null ? (
ev.binaryDecision ? (
<div className="flex items-center gap-1.5 text-emerald-600">
<ThumbsUp className="h-5 w-5" />
<span className="font-semibold">Yes</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-red-600">
<ThumbsDown className="h-5 w-5" />
<span className="font-semibold">No</span>
</div>
)
) : (
<span className="text-2xl font-bold">-</span>
)}
</div>
</div>
</div>
{/* Criterion Scores */}
{hasScores && (
<div>
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Criterion Scores
</h4>
<div className="space-y-2.5">
{Object.entries(criterionScores).map(([key, value]) => {
const meta = criteriaMap.get(key)
const label = meta?.label || key
const type = meta?.type || (typeof value === 'boolean' ? 'boolean' : typeof value === 'string' ? 'text' : 'numeric')
if (type === 'section_header') return null
if (type === 'boolean' || type === 'advance') {
return (
<div key={key} className="flex items-center justify-between p-2.5 rounded-lg border">
<span className="text-sm">{label}</span>
{value === true ? (
<Badge className="bg-emerald-100 text-emerald-700 border-emerald-200" variant="outline">
<ThumbsUp className="mr-1 h-3 w-3" />
{meta?.trueLabel || 'Yes'}
</Badge>
) : (
<Badge className="bg-red-100 text-red-700 border-red-200" variant="outline">
<ThumbsDown className="mr-1 h-3 w-3" />
{meta?.falseLabel || 'No'}
</Badge>
)}
</div>
)
}
if (type === 'text') {
return (
<div key={key} className="space-y-1">
<span className="text-sm font-medium">{label}</span>
<div className="text-sm text-muted-foreground p-2.5 rounded-lg border bg-muted/50 whitespace-pre-wrap">
{typeof value === 'string' ? value : String(value)}
</div>
</div>
)
}
// Numeric
return (
<div key={key} className="flex items-center gap-3 p-2.5 rounded-lg border">
<span className="text-sm flex-1 truncate">{label}</span>
<div className="flex items-center gap-2 shrink-0">
<div className="w-20 h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${(Number(value) / 10) * 100}%` }}
/>
</div>
<span className="text-sm font-bold tabular-nums w-8 text-right">
{typeof value === 'number' ? value : '-'}
</span>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Feedback Text */}
{ev.feedbackText && (
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Feedback
</h4>
<div className="text-sm text-muted-foreground p-3 rounded-lg border bg-muted/30 whitespace-pre-wrap leading-relaxed">
{ev.feedbackText}
</div>
</div>
)}
</div>
</SheetContent>
</Sheet>
)
}
export default function ProjectDetailPage({ params }: PageProps) { export default function ProjectDetailPage({ params }: PageProps) {
const { id } = use(params) const { id } = use(params)

View File

@@ -2,6 +2,7 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react' import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
@@ -62,6 +63,7 @@ type UploadState = {
type UploadMap = Record<string, UploadState> type UploadMap = Record<string, UploadState>
export default function BulkUploadPage() { export default function BulkUploadPage() {
const router = useRouter()
const [roundId, setRoundId] = useState('') const [roundId, setRoundId] = useState('')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('')
@@ -146,7 +148,7 @@ export default function BulkUploadPage() {
const handleViewFile = useCallback( const handleViewFile = useCallback(
async (bucket: string, objectKey: string) => { async (bucket: string, objectKey: string) => {
try { try {
const { url } = await utils.file.getDownloadUrl.fetch({ bucket, objectKey }) const { url } = await utils.file.getDownloadUrl.fetch({ bucket, objectKey, purpose: 'open' as const })
window.open(url, '_blank') window.open(url, '_blank')
} catch { } catch {
toast.error('Failed to open file. It may have been deleted from storage.') toast.error('Failed to open file. It may have been deleted from storage.')
@@ -296,10 +298,8 @@ export default function BulkUploadPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild> <Button variant="ghost" size="icon" onClick={() => router.back()}>
<Link href="/admin/projects"> <ArrowLeft className="h-4 w-4" />
<ArrowLeft className="h-4 w-4" />
</Link>
</Button> </Button>
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Bulk Document Upload</h1> <h1 className="text-2xl font-semibold tracking-tight">Bulk Document Upload</h1>

View File

@@ -59,11 +59,9 @@ function ImportPageContent() {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href="/admin/projects"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Projects
</Link>
</Button> </Button>
</div> </div>

View File

@@ -216,6 +216,7 @@ function NewProjectPageContent() {
createProject.mutate({ createProject.mutate({
programId: selectedProgramId, programId: selectedProgramId,
roundId: selectedRoundId || undefined,
title: title.trim(), title: title.trim(),
teamName: teamName.trim() || undefined, teamName: teamName.trim() || undefined,
description: description.trim() || undefined, description: description.trim() || undefined,
@@ -245,11 +246,9 @@ function NewProjectPageContent() {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href="/admin/projects"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Projects
</Link>
</Button> </Button>
</div> </div>

View File

@@ -5,6 +5,7 @@ import Link from 'next/link'
import { useSearchParams, usePathname } from 'next/navigation' import { useSearchParams, usePathname } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { import {
Card, Card,
CardContent, CardContent,
@@ -72,6 +73,7 @@ import {
ArrowRightCircle, ArrowRightCircle,
LayoutGrid, LayoutGrid,
LayoutList, LayoutList,
Bell,
} from 'lucide-react' } from 'lucide-react'
import { import {
Select, Select,
@@ -90,8 +92,10 @@ import {
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { truncate } from '@/lib/utils' import { truncate } from '@/lib/utils'
import { ProjectLogo } from '@/components/shared/project-logo' import { ProjectLogo } from '@/components/shared/project-logo'
import { StatusBadge } from '@/components/shared/status-badge' import { BulkNotificationDialog } from '@/components/admin/projects/bulk-notification-dialog'
import { Pagination } from '@/components/shared/pagination' import { Pagination } from '@/components/shared/pagination'
import { SortableHeader } from '@/components/shared/sortable-header'
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries' import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
import { CountryFlagImg } from '@/components/ui/country-select' import { CountryFlagImg } from '@/components/ui/country-select'
import { import {
@@ -113,6 +117,25 @@ const statusColors: Record<
WINNER: 'success', WINNER: 'success',
REJECTED: 'destructive', REJECTED: 'destructive',
WITHDRAWN: 'secondary', WITHDRAWN: 'secondary',
// Round-state-based statuses
PENDING: 'secondary',
IN_PROGRESS: 'default',
COMPLETED: 'default',
PASSED: 'success',
}
type ProjectRoundStateInfo = {
state: string
round: { name: string; sortOrder: number }
}
function deriveProjectStatus(prs: ProjectRoundStateInfo[]): { label: string; variant: 'default' | 'success' | 'secondary' | 'destructive' | 'warning' } {
if (!prs.length) return { label: 'Submitted', variant: 'secondary' }
if (prs.some((p) => p.state === 'REJECTED')) return { label: 'Rejected', variant: 'destructive' }
// prs is already sorted by sortOrder desc — first item is the latest round
const latest = prs[0]
if (latest.state === 'PASSED') return { label: latest.round.name, variant: 'success' }
return { label: latest.round.name, variant: 'default' }
} }
function parseFiltersFromParams( function parseFiltersFromParams(
@@ -123,6 +146,9 @@ function parseFiltersFromParams(
statuses: searchParams.get('status') statuses: searchParams.get('status')
? searchParams.get('status')!.split(',') ? searchParams.get('status')!.split(',')
: [], : [],
roundStates: searchParams.get('roundState')
? searchParams.get('roundState')!.split(',')
: [],
roundId: searchParams.get('round') || '', roundId: searchParams.get('round') || '',
competitionCategory: searchParams.get('category') || '', competitionCategory: searchParams.get('category') || '',
oceanIssue: searchParams.get('issue') || '', oceanIssue: searchParams.get('issue') || '',
@@ -157,6 +183,8 @@ function filtersToParams(
if (filters.search) params.set('q', filters.search) if (filters.search) params.set('q', filters.search)
if (filters.statuses.length > 0) if (filters.statuses.length > 0)
params.set('status', filters.statuses.join(',')) params.set('status', filters.statuses.join(','))
if (filters.roundStates.length > 0)
params.set('roundState', filters.roundStates.join(','))
if (filters.roundId) params.set('round', filters.roundId) if (filters.roundId) params.set('round', filters.roundId)
if (filters.competitionCategory) if (filters.competitionCategory)
params.set('category', filters.competitionCategory) params.set('category', filters.competitionCategory)
@@ -182,6 +210,7 @@ export default function ProjectsPage() {
const [filters, setFilters] = useState<ProjectFilters>({ const [filters, setFilters] = useState<ProjectFilters>({
search: parsed.search, search: parsed.search,
statuses: parsed.statuses, statuses: parsed.statuses,
roundStates: parsed.roundStates,
roundId: parsed.roundId, roundId: parsed.roundId,
competitionCategory: parsed.competitionCategory, competitionCategory: parsed.competitionCategory,
oceanIssue: parsed.oceanIssue, oceanIssue: parsed.oceanIssue,
@@ -194,6 +223,8 @@ export default function ProjectsPage() {
const [perPage, setPerPage] = useState(parsed.perPage || 20) const [perPage, setPerPage] = useState(parsed.perPage || 20)
const [searchInput, setSearchInput] = useState(parsed.search) const [searchInput, setSearchInput] = useState(parsed.search)
const [viewMode, setViewMode] = useState<'table' | 'card'>('table') const [viewMode, setViewMode] = useState<'table' | 'card'>('table')
const [sortBy, setSortBy] = useState<string | undefined>(undefined)
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
// Fetch display settings // Fetch display settings
const { data: displaySettings } = trpc.settings.getMultiple.useQuery({ const { data: displaySettings } = trpc.settings.getMultiple.useQuery({
@@ -239,6 +270,16 @@ export default function ProjectsPage() {
setPage(1) setPage(1)
} }
const handleSort = (column: string) => {
if (sortBy === column) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortBy(column)
setSortDir('asc')
}
setPage(1)
}
// Build tRPC query input // Build tRPC query input
const queryInput = { const queryInput = {
search: filters.search || undefined, search: filters.search || undefined,
@@ -275,8 +316,16 @@ export default function ProjectsPage() {
wantsMentorship: filters.wantsMentorship, wantsMentorship: filters.wantsMentorship,
hasFiles: filters.hasFiles, hasFiles: filters.hasFiles,
hasAssignments: filters.hasAssignments, hasAssignments: filters.hasAssignments,
roundStates:
filters.roundStates.length > 0
? (filters.roundStates as Array<
'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'PASSED' | 'REJECTED' | 'WITHDRAWN'
>)
: undefined,
page, page,
perPage, perPage,
sortBy: sortBy as 'title' | 'category' | 'program' | 'assignments' | 'status' | 'createdAt' | undefined,
sortDir: sortBy ? sortDir : undefined,
} }
const utils = trpc.useUtils() const utils = trpc.useUtils()
@@ -290,14 +339,15 @@ export default function ProjectsPage() {
const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null) const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null)
const [assignRoundId, setAssignRoundId] = useState('') const [assignRoundId, setAssignRoundId] = useState('')
const [bulkNotifyOpen, setBulkNotifyOpen] = useState(false)
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false) const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round') const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('') const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
const [selectedProgramForTagging, setSelectedProgramForTagging] = useState<string>('') const [selectedProgramForTagging, setSelectedProgramForTagging] = useState<string>('')
const [activeTaggingJobId, setActiveTaggingJobId] = useState<string | null>(null) const [activeTaggingJobId, setActiveTaggingJobId] = useState<string | null>(null)
// Fetch programs and rounds for the AI tagging dialog // Fetch programs and rounds for the AI tagging dialog + assign-to-round
const { data: programs } = trpc.program.list.useQuery() const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
// Start tagging job mutation // Start tagging job mutation
const startTaggingJob = trpc.tag.startTaggingJob.useMutation({ const startTaggingJob = trpc.tag.startTaggingJob.useMutation({
@@ -619,6 +669,13 @@ export default function ProjectsPage() {
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => setBulkNotifyOpen(true)}
>
<Bell className="mr-2 h-4 w-4" />
Send Notifications
</Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => setAiTagDialogOpen(true)} onClick={() => setAiTagDialogOpen(true)}
@@ -708,23 +765,51 @@ export default function ProjectsPage() {
/> />
{/* Stats Summary + View Toggle */} {/* Stats Summary + View Toggle */}
{data && data.projects.length > 0 && ( {data && (Object.keys(data.statusCounts ?? {}).length > 0 || data.projects.length > 0) && (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
{Object.entries(data.statusCounts ?? {}) {Object.entries(data.statusCounts ?? {})
.sort(([a], [b]) => { .sort(([a], [b]) => {
const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN'] const order = ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN']
return order.indexOf(a) - order.indexOf(b) return order.indexOf(a) - order.indexOf(b)
}) })
.map(([status, count]) => ( .map(([status, count]) => {
<Badge const isActive = filters.roundStates.includes(status)
key={status} return (
variant={statusColors[status] || 'secondary'} <button
className="text-xs font-normal" key={status}
> type="button"
{count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')} onClick={() => {
</Badge> const next = isActive
))} ? filters.roundStates.filter((s) => s !== status)
: [...filters.roundStates, status]
handleFiltersChange({ ...filters, roundStates: next })
}}
className="inline-flex items-center"
>
<Badge
variant={statusColors[status] || 'secondary'}
className={cn(
'text-xs font-normal cursor-pointer transition-all',
isActive && 'ring-2 ring-offset-1 ring-primary',
!isActive && filters.roundStates.length > 0 && 'opacity-50'
)}
>
{count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')}
</Badge>
</button>
)
})}
{filters.roundStates.length > 0 && (
<button
type="button"
onClick={() => handleFiltersChange({ ...filters, roundStates: [] })}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors ml-1"
>
<X className="h-3 w-3" />
Clear
</button>
)}
{data.total > data.projects.length && ( {data.total > data.projects.length && (
<span className="text-xs text-muted-foreground ml-1"> <span className="text-xs text-muted-foreground ml-1">
(page {data.page} of {data.totalPages}) (page {data.page} of {data.totalPages})
@@ -847,6 +932,17 @@ export default function ProjectsPage() {
</Card> </Card>
) : data ? ( ) : data ? (
<> <>
{/* Top Pagination */}
{data.totalPages > 1 && (
<Pagination
page={data.page}
totalPages={data.totalPages}
total={data.total}
perPage={perPage}
onPageChange={setPage}
/>
)}
{/* Table View */} {/* Table View */}
{viewMode === 'table' ? ( {viewMode === 'table' ? (
<> <>
@@ -862,18 +958,18 @@ export default function ProjectsPage() {
aria-label="Select all projects" aria-label="Select all projects"
/> />
</TableHead> </TableHead>
<TableHead className="min-w-[280px]">Project</TableHead> <SortableHeader label="Project" column="title" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} className="min-w-[280px]" />
<TableHead>Category</TableHead> <SortableHeader label="Category" column="category" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<TableHead>Program</TableHead> <SortableHeader label="Program" column="program" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<TableHead>Tags</TableHead> <TableHead>Tags</TableHead>
<TableHead>Assignments</TableHead> <SortableHeader label="Assignments" column="assignments" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<TableHead>Status</TableHead> <SortableHeader label="Status" column="status" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.projects.map((project) => { {data.projects.map((project) => {
const isEliminated = project.status === 'REJECTED' const isEliminated = (project.projectRoundStates ?? []).some((p: ProjectRoundStateInfo) => p.state === 'REJECTED')
return ( return (
<TableRow <TableRow
key={project.id} key={project.id}
@@ -894,6 +990,7 @@ export default function ProjectsPage() {
> >
<ProjectLogo <ProjectLogo
project={project} project={project}
logoUrl={project.logoUrl}
size="sm" size="sm"
fallback="initials" fallback="initials"
/> />
@@ -972,7 +1069,10 @@ export default function ProjectsPage() {
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<StatusBadge status={project.status ?? 'SUBMITTED'} /> {(() => {
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
return <Badge variant={derived.variant}>{derived.label}</Badge>
})()}
</TableCell> </TableCell>
<TableCell className="relative z-10 text-right"> <TableCell className="relative z-10 text-right">
<DropdownMenu> <DropdownMenu>
@@ -1042,13 +1142,16 @@ export default function ProjectsPage() {
<Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md"> <Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start gap-3 pl-8"> <div className="flex items-start gap-3 pl-8">
<ProjectLogo project={project} size="md" fallback="initials" /> <ProjectLogo project={project} logoUrl={project.logoUrl} size="md" fallback="initials" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}> <CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
{project.title} {project.title}
</CardTitle> </CardTitle>
<StatusBadge status={project.status ?? 'SUBMITTED'} className="shrink-0" /> {(() => {
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
return <Badge variant={derived.variant} className="shrink-0">{derived.label}</Badge>
})()}
</div> </div>
<CardDescription>{project.teamName}</CardDescription> <CardDescription>{project.teamName}</CardDescription>
</div> </div>
@@ -1096,7 +1199,7 @@ export default function ProjectsPage() {
/* Card View */ /* Card View */
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{data.projects.map((project) => { {data.projects.map((project) => {
const isEliminated = project.status === 'REJECTED' const isEliminated = (project.projectRoundStates ?? []).some((p: ProjectRoundStateInfo) => p.state === 'REJECTED')
return ( return (
<div key={project.id} className="relative"> <div key={project.id} className="relative">
<div className="absolute left-3 top-3 z-10"> <div className="absolute left-3 top-3 z-10">
@@ -1110,7 +1213,7 @@ export default function ProjectsPage() {
<Card className={`transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md h-full ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}> <Card className={`transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md h-full ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start gap-3 pl-7"> <div className="flex items-start gap-3 pl-7">
<ProjectLogo project={project} size="lg" fallback="initials" /> <ProjectLogo project={project} logoUrl={project.logoUrl} size="lg" fallback="initials" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}> <CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
@@ -1177,7 +1280,10 @@ export default function ProjectsPage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-3 pt-0"> <CardContent className="space-y-3 pt-0">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<StatusBadge status={project.status ?? 'SUBMITTED'} /> {(() => {
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
return <Badge variant={derived.variant}>{derived.label}</Badge>
})()}
{project.competitionCategory && ( {project.competitionCategory && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
@@ -1846,6 +1952,8 @@ export default function ProjectsPage() {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<BulkNotificationDialog open={bulkNotifyOpen} onOpenChange={setBulkNotifyOpen} />
</div> </div>
) )
} }

View File

@@ -63,6 +63,7 @@ const ISSUE_LABELS: Record<string, string> = {
export interface ProjectFilters { export interface ProjectFilters {
search: string search: string
statuses: string[] statuses: string[]
roundStates: string[]
roundId: string roundId: string
competitionCategory: string competitionCategory: string
oceanIssue: string oceanIssue: string
@@ -94,6 +95,7 @@ export function ProjectFiltersBar({
const activeFilterCount = [ const activeFilterCount = [
filters.statuses.length > 0, filters.statuses.length > 0,
filters.roundStates.length > 0,
filters.roundId !== '', filters.roundId !== '',
filters.competitionCategory !== '', filters.competitionCategory !== '',
filters.oceanIssue !== '', filters.oceanIssue !== '',
@@ -114,6 +116,7 @@ export function ProjectFiltersBar({
onChange({ onChange({
search: filters.search, search: filters.search,
statuses: [], statuses: [],
roundStates: [],
roundId: '', roundId: '',
competitionCategory: '', competitionCategory: '',
oceanIssue: '', oceanIssue: '',

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState, useMemo, useCallback, useRef, useEffect } from 'react' import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
import { useParams, useSearchParams } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
@@ -13,7 +13,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { import {
DropdownMenu, DropdownMenu,
@@ -77,6 +76,9 @@ import {
Trash2, Trash2,
ArrowRight, ArrowRight,
RotateCcw, RotateCcw,
ListChecks,
FileText,
Languages,
} from 'lucide-react' } from 'lucide-react'
import { import {
Tooltip, Tooltip,
@@ -116,7 +118,11 @@ import { AIRecommendationsDisplay } from '@/components/admin/round/ai-recommenda
import { EvaluationCriteriaEditor } from '@/components/admin/round/evaluation-criteria-editor' import { EvaluationCriteriaEditor } from '@/components/admin/round/evaluation-criteria-editor'
import { COIReviewSection } from '@/components/admin/assignment/coi-review-section' import { COIReviewSection } from '@/components/admin/assignment/coi-review-section'
import { ConfigSectionHeader } from '@/components/admin/rounds/config/config-section-header' import { ConfigSectionHeader } from '@/components/admin/rounds/config/config-section-header'
import { NotifyAdvancedButton } from '@/components/admin/round/notify-advanced-button'
import { NotifyRejectedButton } from '@/components/admin/round/notify-rejected-button'
import { BulkInviteButton } from '@/components/admin/round/bulk-invite-button'
import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card' import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card'
import { FinalizationTab } from '@/components/admin/round/finalization-tab'
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
@@ -146,8 +152,7 @@ const stateColors: Record<string, string> = Object.fromEntries(
export default function RoundDetailPage() { export default function RoundDetailPage() {
const params = useParams() const params = useParams()
const roundId = params.roundId as string const roundId = params.roundId as string
const searchParams = useSearchParams() const router = useRouter()
const backUrl = searchParams.get('from')
const [config, setConfig] = useState<Record<string, unknown>>({}) const [config, setConfig] = useState<Record<string, unknown>>({})
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
@@ -185,6 +190,7 @@ export default function RoundDetailPage() {
const [nameValue, setNameValue] = useState('') const [nameValue, setNameValue] = useState('')
const nameInputRef = useRef<HTMLInputElement>(null) const nameInputRef = useRef<HTMLInputElement>(null)
const [statusConfirmAction, setStatusConfirmAction] = useState<'activate' | 'close' | 'reopen' | 'archive' | null>(null) const [statusConfirmAction, setStatusConfirmAction] = useState<'activate' | 'close' | 'reopen' | 'archive' | null>(null)
const [statusDropdownOpen, setStatusDropdownOpen] = useState(false)
const [coverageOpen, setCoverageOpen] = useState(false) const [coverageOpen, setCoverageOpen] = useState(false)
const utils = trpc.useUtils() const utils = trpc.useUtils()
@@ -264,6 +270,20 @@ export default function RoundDetailPage() {
} }
}, [juryWorkload]) }, [juryWorkload])
// Auto-select finalization tab when round is closed and not yet finalized
const finalizationAutoSelected = useRef(false)
useEffect(() => {
if (
round &&
!finalizationAutoSelected.current &&
round.status === 'ROUND_CLOSED' &&
!round.finalizedAt
) {
finalizationAutoSelected.current = true
setActiveTab('finalization')
}
}, [round])
// ── Mutations ────────────────────────────────────────────────────────── // ── Mutations ──────────────────────────────────────────────────────────
const updateMutation = trpc.round.update.useMutation({ const updateMutation = trpc.round.update.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -290,12 +310,12 @@ export default function RoundDetailPage() {
const closeMutation = trpc.roundEngine.close.useMutation({ const closeMutation = trpc.roundEngine.close.useMutation({
onSuccess: () => { onSuccess: () => {
utils.round.getById.invalidate({ id: roundId }) utils.round.getById.invalidate({ id: roundId })
toast.success('Round closed') utils.roundEngine.getProjectStates.invalidate({ roundId })
if (closeAndAdvance) { utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
setCloseAndAdvance(false) toast.success('Round closed — use the Finalization tab to review and advance projects')
// Small delay to let cache invalidation complete before opening dialog setCloseAndAdvance(false)
setTimeout(() => setAdvanceDialogOpen(true), 300) // Auto-switch to finalization tab
} setActiveTab('finalization')
}, },
onError: (err) => { onError: (err) => {
setCloseAndAdvance(false) setCloseAndAdvance(false)
@@ -307,6 +327,7 @@ export default function RoundDetailPage() {
onSuccess: (data) => { onSuccess: (data) => {
utils.round.getById.invalidate({ id: roundId }) utils.round.getById.invalidate({ id: roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId }) utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
const msg = data.pausedRounds?.length const msg = data.pausedRounds?.length
? `Round reopened. Paused: ${data.pausedRounds.join(', ')}` ? `Round reopened. Paused: ${data.pausedRounds.join(', ')}`
: 'Round reopened' : 'Round reopened'
@@ -318,6 +339,8 @@ export default function RoundDetailPage() {
const archiveMutation = trpc.roundEngine.archive.useMutation({ const archiveMutation = trpc.roundEngine.archive.useMutation({
onSuccess: () => { onSuccess: () => {
utils.round.getById.invalidate({ id: roundId }) utils.round.getById.invalidate({ id: roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
toast.success('Round archived') toast.success('Round archived')
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
@@ -492,8 +515,9 @@ export default function RoundDetailPage() {
const isFiltering = round?.roundType === 'FILTERING' const isFiltering = round?.roundType === 'FILTERING'
const isEvaluation = round?.roundType === 'EVALUATION' const isEvaluation = round?.roundType === 'EVALUATION'
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '') const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
const hasAwards = hasJury const hasAwards = roundAwards.length > 0
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '') const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
const showFinalization = ['ROUND_CLOSED', 'ROUND_ARCHIVED'].includes(round?.status ?? '')
const poolLink = `/admin/projects?hasAssign=false&round=${roundId}` as Route const poolLink = `/admin/projects?hasAssign=false&round=${roundId}` as Route
@@ -521,11 +545,9 @@ export default function RoundDetailPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href={'/admin/rounds' as Route}> <Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back" onClick={() => router.back()}>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back"> <ArrowLeft className="h-4 w-4" />
<ArrowLeft className="h-4 w-4" /> </Button>
</Button>
</Link>
<div> <div>
<h1 className="text-xl font-bold">Round Not Found</h1> <h1 className="text-xl font-bold">Round Not Found</h1>
<p className="text-sm text-muted-foreground">This round does not exist.</p> <p className="text-sm text-muted-foreground">This round does not exist.</p>
@@ -597,12 +619,10 @@ export default function RoundDetailPage() {
> >
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3 min-w-0"> <div className="flex items-start gap-3 min-w-0">
<Link href={(backUrl ?? (round.specialAwardId ? `/admin/awards/${round.specialAwardId}` : '/admin/rounds')) as Route} className="mt-0.5 shrink-0"> <Button variant="ghost" size="sm" className="mt-0.5 shrink-0 h-8 text-white/80 hover:text-white hover:bg-white/10 gap-1.5" aria-label="Back" onClick={() => router.back()}>
<Button variant="ghost" size="sm" className="h-8 text-white/80 hover:text-white hover:bg-white/10 gap-1.5" aria-label={round.specialAwardId ? 'Back to Award' : 'Back to rounds'}> <ArrowLeft className="h-4 w-4" />
<ArrowLeft className="h-4 w-4" /> <span className="text-xs hidden sm:inline">Back</span>
<span className="text-xs hidden sm:inline">{round.specialAwardId ? 'Back to Award' : 'Back to Rounds'}</span> </Button>
</Button>
</Link>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2.5">
{/* 4.6 Inline-editable round name */} {/* 4.6 Inline-editable round name */}
@@ -650,10 +670,10 @@ export default function RoundDetailPage() {
{/* Status dropdown with confirmation dialogs (4.1) */} {/* Status dropdown with confirmation dialogs (4.1) */}
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip open={statusDropdownOpen ? false : undefined}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span> <span>
<DropdownMenu> <DropdownMenu onOpenChange={setStatusDropdownOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
className={cn( className={cn(
@@ -845,6 +865,7 @@ export default function RoundDetailPage() {
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []), ...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []),
...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []), ...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []),
...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []), ...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
...(showFinalization ? [{ value: 'finalization', label: 'Finalization', icon: ListChecks }] : []),
{ value: 'config', label: 'Config', icon: Settings }, { value: 'config', label: 'Config', icon: Settings },
...(hasAwards ? [{ value: 'awards', label: 'Awards', icon: Trophy }] : []), ...(hasAwards ? [{ value: 'awards', label: 'Awards', icon: Trophy }] : []),
].map((tab) => ( ].map((tab) => (
@@ -1165,49 +1186,54 @@ export default function RoundDetailPage() {
</div> </div>
</button> </button>
{/* Advance projects (always visible when projects exist) */} {/* Advance projects — closed rounds go to Finalization tab, active rounds use old dialog */}
{projectCount > 0 && ( {projectCount > 0 && (
<button <button
onClick={() => (isSimpleAdvance || passedCount > 0) onClick={() => {
? setAdvanceDialogOpen(true) if (showFinalization) {
: toast.info('Mark projects as "Passed" first in the Projects tab')} setActiveTab('finalization')
} else if (isSimpleAdvance || passedCount > 0) {
setAdvanceDialogOpen(true)
} else {
toast.info('Mark projects as "Passed" first in the Projects tab')
}
}}
className={cn( className={cn(
'flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left', 'flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left',
(isSimpleAdvance || passedCount > 0) (showFinalization || isSimpleAdvance || passedCount > 0)
? 'border-l-4 border-l-emerald-500 bg-emerald-50/30' ? 'border-l-4 border-l-emerald-500 bg-emerald-50/30'
: 'border-dashed opacity-60', : 'border-dashed opacity-60',
)} )}
> >
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', (isSimpleAdvance || passedCount > 0) ? 'text-emerald-600' : 'text-muted-foreground')} /> <ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', (showFinalization || isSimpleAdvance || passedCount > 0) ? 'text-emerald-600' : 'text-muted-foreground')} />
<div> <div>
<p className="text-sm font-medium">Advance Projects</p> <p className="text-sm font-medium">Advance Projects</p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{isSimpleAdvance {showFinalization
? `Advance all ${projectCount} project(s) to the next round` ? 'Review and confirm advancement in the Finalization tab'
: passedCount > 0 : isSimpleAdvance
? `Move ${passedCount} passed project(s) to the next round` ? `Advance all ${projectCount} project(s) to the next round`
: 'Mark projects as "Passed" first, then advance'} : passedCount > 0
? `Move ${passedCount} passed project(s) to the next round`
: 'Mark projects as "Passed" first, then advance'}
</p> </p>
</div> </div>
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{isSimpleAdvance ? projectCount : passedCount}</Badge> <Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{isSimpleAdvance ? projectCount : passedCount}</Badge>
</button> </button>
)} )}
{/* Close & Advance (active rounds with passed projects) */} {/* Close & Finalize (active rounds — closes round and opens finalization tab) */}
{status === 'ROUND_ACTIVE' && passedCount > 0 && ( {status === 'ROUND_ACTIVE' && projectCount > 0 && (
<button <button
onClick={() => { onClick={() => closeMutation.mutate({ roundId })}
setCloseAndAdvance(true)
closeMutation.mutate({ roundId })
}}
disabled={isTransitioning} disabled={isTransitioning}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-purple-500 bg-purple-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left" className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-purple-500 bg-purple-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
> >
<Square className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" /> <Square className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<div> <div>
<p className="text-sm font-medium">Close & Advance</p> <p className="text-sm font-medium">Close & Finalize</p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
Close this round and advance {passedCount} passed project(s) to the next round Close this round and review outcomes in the Finalization tab
</p> </p>
</div> </div>
</button> </button>
@@ -1288,12 +1314,24 @@ export default function RoundDetailPage() {
</div> </div>
</div> </div>
)} )}
{/* Notifications Group */}
{projectCount > 0 && (
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Notifications</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<NotifyAdvancedButton roundId={roundId} />
<NotifyRejectedButton roundId={roundId} />
<BulkInviteButton roundId={roundId} />
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard> </AnimatedCard>
{/* Advance Projects Dialog */} {/* Advance Projects Dialog — only for active rounds; closed rounds use Finalization tab */}
<AdvanceProjectsDialog {!showFinalization && <AdvanceProjectsDialog
open={advanceDialogOpen} open={advanceDialogOpen}
onOpenChange={setAdvanceDialogOpen} onOpenChange={setAdvanceDialogOpen}
roundId={roundId} roundId={roundId}
@@ -1308,7 +1346,7 @@ export default function RoundDetailPage() {
roundType: r.roundType, roundType: r.roundType,
}))} }))}
currentSortOrder={round?.sortOrder} currentSortOrder={round?.sortOrder}
/> />}
{/* AI Shortlist Confirmation Dialog */} {/* AI Shortlist Confirmation Dialog */}
<AlertDialog open={shortlistDialogOpen} onOpenChange={setShortlistDialogOpen}> <AlertDialog open={shortlistDialogOpen} onOpenChange={setShortlistDialogOpen}>
@@ -1430,11 +1468,24 @@ export default function RoundDetailPage() {
</Card> </Card>
</AnimatedCard> </AnimatedCard>
</div> </div>
{/* Document Language Summary */}
<DocumentLanguageSummary roundId={roundId as string} />
</TabsContent> </TabsContent>
{/* ═══════════ PROJECTS TAB ═══════════ */} {/* ═══════════ PROJECTS TAB ═══════════ */}
<TabsContent value="projects" className="space-y-4"> <TabsContent value="projects" className="space-y-4">
<ProjectStatesTable competitionId={competitionId} roundId={roundId} /> <ProjectStatesTable
competitionId={competitionId}
roundId={roundId}
roundStatus={round?.status}
competitionRounds={competition?.rounds}
currentSortOrder={round?.sortOrder}
onAssignProjects={() => {
setActiveTab('assignments')
setTimeout(() => setPreviewSheetOpen(true), 100)
}}
/>
</TabsContent> </TabsContent>
{/* ═══════════ FILTERING TAB ═══════════ */} {/* ═══════════ FILTERING TAB ═══════════ */}
@@ -1797,7 +1848,7 @@ export default function RoundDetailPage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<COIReviewSection roundId={roundId} /> <COIReviewSection roundId={roundId} />
<RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} /> <RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} onAssignUnassigned={() => setPreviewSheetOpen(true)} />
</CardContent> </CardContent>
</Card> </Card>
@@ -2055,6 +2106,13 @@ export default function RoundDetailPage() {
</TabsContent> </TabsContent>
)} )}
{/* ═══════════ FINALIZATION TAB ═══════════ */}
{showFinalization && (
<TabsContent value="finalization" className="space-y-4">
<FinalizationTab roundId={roundId} roundStatus={round.status} />
</TabsContent>
)}
{/* ═══════════ CONFIG TAB ═══════════ */} {/* ═══════════ CONFIG TAB ═══════════ */}
<TabsContent value="config" className="space-y-6"> <TabsContent value="config" className="space-y-6">
{/* Round Dates */} {/* Round Dates */}
@@ -2119,89 +2177,108 @@ export default function RoundDetailPage() {
/> />
</CardHeader> </CardHeader>
<CardContent className="space-y-0 pt-0"> <CardContent className="space-y-0 pt-0">
<div className="flex items-center justify-between p-4 rounded-md"> {(isEvaluation || isFiltering) && (
<div className="space-y-0.5">
<Label htmlFor="notify-on-entry" className="text-sm font-medium">
Notify on round entry
</Label>
<p className="text-xs text-muted-foreground">
Send an automated email to project applicants when their project enters this round
</p>
</div>
<Switch
id="notify-on-entry"
checked={!!config.notifyOnEntry}
onCheckedChange={(checked) => {
handleConfigChange({ ...config, notifyOnEntry: checked })
}}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div className="space-y-0.5">
<Label htmlFor="notify-on-advance" className="text-sm font-medium">
Notify on advance
</Label>
<p className="text-xs text-muted-foreground">
Send an email to project applicants when their project advances from this round to the next
</p>
</div>
<Switch
id="notify-on-advance"
checked={!!config.notifyOnAdvance}
onCheckedChange={(checked) => {
handleConfigChange({ ...config, notifyOnAdvance: checked })
}}
/>
</div>
<div className="border-t mt-2 pt-4 px-4 pb-2 bg-[#053d57]/[0.03] rounded-b-lg -mx-6 -mb-6 p-6"> <div className="border-t mt-2 pt-4 px-4 pb-2 bg-[#053d57]/[0.03] rounded-b-lg -mx-6 -mb-6 p-6">
<Label className="text-sm font-medium">Advancement Targets</Label> <Label className="text-sm font-medium">Advancement Targets</Label>
{isEvaluation && !(config.startupAdvanceCount as number) && !(config.conceptAdvanceCount as number) && (
<div className="mt-2 mb-1 rounded-md border border-amber-200 bg-amber-50 px-3 py-2">
<p className="text-xs text-amber-700">Advancement targets not configured all passed projects will be eligible to advance.</p>
</div>
)}
<p className="text-xs text-muted-foreground mb-3"> <p className="text-xs text-muted-foreground mb-3">
Target number of projects per category to advance from this round How to determine which projects advance from this round
</p> </p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"> {/* Mode toggle */}
<Label htmlFor="startup-advance-count" className="text-xs text-muted-foreground"> <div className="flex gap-2 mb-4">
Startup Projects <Button
</Label> type="button"
<Input size="sm"
id="startup-advance-count" variant={(config.advanceMode as string) === 'threshold' ? 'outline' : 'default'}
type="number" className="h-8 text-xs"
min={0} onClick={() => handleConfigChange({ ...config, advanceMode: 'count' })}
className="h-9" >
placeholder="No limit" Fixed Count
value={(config.startupAdvanceCount as number) ?? ''} </Button>
onChange={(e) => { <Button
const val = e.target.value ? parseInt(e.target.value, 10) : undefined type="button"
handleConfigChange({ ...config, startupAdvanceCount: val }) size="sm"
}} variant={(config.advanceMode as string) === 'threshold' ? 'default' : 'outline'}
/> className="h-8 text-xs"
</div> onClick={() => handleConfigChange({ ...config, advanceMode: 'threshold' })}
<div className="space-y-1.5"> >
<Label htmlFor="concept-advance-count" className="text-xs text-muted-foreground"> Score Threshold
Concept Projects </Button>
</Label>
<Input
id="concept-advance-count"
type="number"
min={0}
className="h-9"
placeholder="No limit"
value={(config.conceptAdvanceCount as number) ?? ''}
onChange={(e) => {
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
handleConfigChange({ ...config, conceptAdvanceCount: val })
}}
/>
</div>
</div> </div>
{(config.advanceMode as string) === 'threshold' ? (
<div className="space-y-2">
<Label htmlFor="advance-threshold" className="text-xs text-muted-foreground">
Minimum Average Score to Advance
</Label>
<p className="text-xs text-muted-foreground">
All projects scoring at or above this threshold will advance (both categories)
</p>
<Input
id="advance-threshold"
type="number"
min={0}
max={10}
step={0.1}
className="h-9 w-32"
placeholder="e.g. 6.5"
value={(config.advanceScoreThreshold as number) ?? ''}
onChange={(e) => {
const val = e.target.value ? parseFloat(e.target.value) : undefined
handleConfigChange({ ...config, advanceScoreThreshold: val })
}}
/>
</div>
) : (
<>
{isEvaluation && !(config.startupAdvanceCount as number) && !(config.conceptAdvanceCount as number) && (
<div className="mb-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2">
<p className="text-xs text-amber-700">Advancement targets not configured all passed projects will be eligible to advance.</p>
</div>
)}
<p className="text-xs text-muted-foreground mb-2">
Target number of projects per category to advance
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="startup-advance-count" className="text-xs text-muted-foreground">
Startup Projects
</Label>
<Input
id="startup-advance-count"
type="number"
min={0}
className="h-9"
placeholder="No limit"
value={(config.startupAdvanceCount as number) ?? ''}
onChange={(e) => {
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
handleConfigChange({ ...config, startupAdvanceCount: val })
}}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="concept-advance-count" className="text-xs text-muted-foreground">
Concept Projects
</Label>
<Input
id="concept-advance-count"
type="number"
min={0}
className="h-9"
placeholder="No limit"
value={(config.conceptAdvanceCount as number) ?? ''}
onChange={(e) => {
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
handleConfigChange({ ...config, conceptAdvanceCount: val })
}}
/>
</div>
</div>
</>
)}
</div> </div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -2405,3 +2482,75 @@ export default function RoundDetailPage() {
</div> </div>
) )
} }
// =============================================================================
// Document Language Summary — flags non-English docs
// =============================================================================
const LANG_NAMES: Record<string, string> = {
eng: 'English', fra: 'French', deu: 'German', spa: 'Spanish', ita: 'Italian',
por: 'Portuguese', nld: 'Dutch', rus: 'Russian', ara: 'Arabic', zho: 'Chinese',
jpn: 'Japanese', kor: 'Korean', tur: 'Turkish', pol: 'Polish', ron: 'Romanian',
ces: 'Czech', ell: 'Greek', hun: 'Hungarian', swe: 'Swedish', dan: 'Danish',
fin: 'Finnish', nor: 'Norwegian', und: 'Unknown',
}
function DocumentLanguageSummary({ roundId }: { roundId: string }) {
const { data, isLoading } = trpc.file.roundLanguageSummary.useQuery(
{ roundId },
{ refetchInterval: 60_000 }
)
if (isLoading || !data) return null
if (data.totalFiles === 0) return null
const allGood = data.nonEnglishCount === 0 && data.unanalyzedCount === 0
return (
<Card className={allGood ? 'border-green-200 bg-green-50/30' : data.nonEnglishCount > 0 ? 'border-amber-300 bg-amber-50/30' : ''}>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Languages className={cn('h-4 w-4', allGood ? 'text-green-600' : data.nonEnglishCount > 0 ? 'text-amber-600' : 'text-muted-foreground')} />
Document Languages
{data.nonEnglishCount > 0 && (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
{data.nonEnglishCount} flagged
</Badge>
)}
</CardTitle>
<CardDescription className="text-xs">
{data.analyzedCount} of {data.totalFiles} documents analyzed
{data.unanalyzedCount > 0 && `${data.unanalyzedCount} pending`}
</CardDescription>
</CardHeader>
{data.nonEnglishCount > 0 && (
<CardContent className="space-y-2">
{data.flaggedProjects.map((project) => (
<div key={project.projectId} className="rounded-md border bg-white p-3">
<Link
href={`/admin/projects/${project.projectId}` as Route}
className="text-sm font-medium text-primary hover:underline"
>
{project.projectTitle}
</Link>
<div className="mt-1.5 space-y-1">
{project.files.map((file) => (
<div key={file.id} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5 min-w-0">
<FileText className="h-3 w-3 text-muted-foreground shrink-0" />
<span className="truncate">{file.fileName}</span>
</div>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 shrink-0 ml-2 border-amber-300 text-amber-700">
{LANG_NAMES[file.detectedLang ?? ''] || file.detectedLang}
{file.langConfidence != null && ` (${Math.round(file.langConfidence * 100)}%)`}
</Badge>
</div>
))}
</div>
</div>
))}
</CardContent>
)}
</Card>
)
}

View File

@@ -41,7 +41,6 @@ import {
FileBox, FileBox,
Save, Save,
Loader2, Loader2,
Award,
Trophy, Trophy,
ArrowRight, ArrowRight,
} from 'lucide-react' } from 'lucide-react'
@@ -151,27 +150,42 @@ export default function RoundsPage() {
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
const rounds = useMemo(() => { // Split rounds into main pipeline (no specialAwardId) and award tracks
const mainRounds = useMemo(() => {
const all = (compDetail?.rounds ?? []) as RoundWithStats[] const all = (compDetail?.rounds ?? []) as RoundWithStats[]
return filterType === 'all' ? all : all.filter((r) => r.roundType === filterType) const main = all.filter((r) => !r.specialAwardId)
return filterType === 'all' ? main : main.filter((r) => r.roundType === filterType)
}, [compDetail?.rounds, filterType]) }, [compDetail?.rounds, filterType])
// Group awards by their evaluationRoundId // Group award-track rounds by their specialAwardId, paired with the award metadata
const awardsByRound = useMemo(() => { const awardTrackGroups = useMemo(() => {
const map = new Map<string, SpecialAwardItem[]>() const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[]
for (const award of (awards ?? []) as SpecialAwardItem[]) { const awardRounds = allRounds.filter((r) => r.specialAwardId)
if (award.evaluationRoundId) { const groups = new Map<string, { award: SpecialAwardItem; rounds: RoundWithStats[] }>()
const existing = map.get(award.evaluationRoundId) ?? []
existing.push(award) for (const round of awardRounds) {
map.set(award.evaluationRoundId, existing) const awardId = round.specialAwardId!
if (!groups.has(awardId)) {
const award = ((awards ?? []) as SpecialAwardItem[]).find((a) => a.id === awardId)
if (!award) continue
groups.set(awardId, { award, rounds: [] })
} }
groups.get(awardId)!.rounds.push(round)
} }
return map return Array.from(groups.values())
}, [awards]) }, [compDetail?.rounds, awards])
const floatingAwards = useMemo(() => { const floatingAwards = useMemo(() => {
return ((awards ?? []) as SpecialAwardItem[]).filter((a) => !a.evaluationRoundId) // Awards that have no evaluationRoundId AND no rounds linked via specialAwardId
}, [awards]) const awardIdsWithRounds = new Set(
((compDetail?.rounds ?? []) as RoundWithStats[])
.filter((r) => r.specialAwardId)
.map((r) => r.specialAwardId!)
)
return ((awards ?? []) as SpecialAwardItem[]).filter(
(a) => !a.evaluationRoundId && !awardIdsWithRounds.has(a.id)
)
}, [awards, compDetail?.rounds])
const handleCreateRound = () => { const handleCreateRound = () => {
if (!roundForm.name.trim() || !roundForm.roundType || !comp) { if (!roundForm.name.trim() || !roundForm.roundType || !comp) {
@@ -271,8 +285,10 @@ export default function RoundsPage() {
const activeFilter = filterType !== 'all' const activeFilter = filterType !== 'all'
const totalProjects = (compDetail as any)?.distinctProjectCount ?? 0 const totalProjects = (compDetail as any)?.distinctProjectCount ?? 0
const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[] const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[]
const allMainRounds = allRounds.filter((r) => !r.specialAwardId)
const awardRoundCount = allRounds.length - allMainRounds.length
const totalAssignments = allRounds.reduce((s, r) => s + r._count.assignments, 0) const totalAssignments = allRounds.reduce((s, r) => s + r._count.assignments, 0)
const activeRound = allRounds.find((r) => r.status === 'ROUND_ACTIVE') const activeRound = allMainRounds.find((r) => r.status === 'ROUND_ACTIVE')
return ( return (
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
@@ -313,7 +329,7 @@ export default function RoundsPage() {
</Tooltip> </Tooltip>
</div> </div>
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground"> <div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
<span>{allRounds.filter((r) => !r.specialAwardId).length} rounds</span> <span>{allMainRounds.length} rounds{awardRoundCount > 0 ? ` + ${awardRoundCount} award` : ''}</span>
<span className="text-muted-foreground/30">|</span> <span className="text-muted-foreground/30">|</span>
<span>{totalProjects} projects</span> <span>{totalProjects} projects</span>
<span className="text-muted-foreground/30">|</span> <span className="text-muted-foreground/30">|</span>
@@ -330,12 +346,12 @@ export default function RoundsPage() {
</span> </span>
</> </>
)} )}
{awards && awards.length > 0 && ( {awardTrackGroups.length > 0 && (
<> <>
<span className="text-muted-foreground/30">|</span> <span className="text-muted-foreground/30">|</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Award className="h-3.5 w-3.5" /> <Trophy className="h-3.5 w-3.5" />
{awards.length} awards {awardTrackGroups.length} award {awardTrackGroups.length === 1 ? 'track' : 'tracks'}
</span> </span>
</> </>
)} )}
@@ -389,7 +405,7 @@ export default function RoundsPage() {
</div> </div>
{/* ── Pipeline View ───────────────────────────────────────────── */} {/* ── Pipeline View ───────────────────────────────────────────── */}
{rounds.length === 0 ? ( {mainRounds.length === 0 && awardTrackGroups.length === 0 ? (
<div className="py-16 text-center border-2 border-dashed rounded-lg"> <div className="py-16 text-center border-2 border-dashed rounded-lg">
<FileBox className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" /> <FileBox className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -397,142 +413,79 @@ export default function RoundsPage() {
</p> </p>
</div> </div>
) : ( ) : (
<div className="relative"> <div className="space-y-6">
{/* Main pipeline track */} {/* ── Main Competition Pipeline ───────────────────────── */}
{rounds.map((round, index) => { {mainRounds.length > 0 && (
const isLast = index === rounds.length - 1 <div className="relative">
const typeColors = ROUND_TYPE_COLORS[round.roundType] ?? ROUND_TYPE_COLORS.INTAKE {mainRounds.map((round, index) => (
const statusStyle = ROUND_STATUS_STYLES[round.status] ?? ROUND_STATUS_STYLES.ROUND_DRAFT <RoundRow
const projectCount = round._count.projectRoundStates key={round.id}
const assignmentCount = round._count.assignments round={round}
const roundAwards = awardsByRound.get(round.id) ?? [] isLast={index === mainRounds.length - 1}
/>
))}
</div>
)}
{/* ── Award Track Sections ────────────────────────────── */}
{awardTrackGroups.map(({ award, rounds: awardRounds }) => {
const isExclusive = award.eligibilityMode === 'SEPARATE_POOL'
const eligible = award._count.eligibilities
const statusColor = AWARD_STATUS_COLORS[award.status] ?? 'text-gray-500'
return ( return (
<div key={round.id} className="relative"> <div
{/* Round row with pipeline connector */} key={award.id}
<div className="flex"> className="rounded-lg border border-amber-200/80 bg-amber-50/30 overflow-hidden"
{/* Left: pipeline track */} >
<div className="flex flex-col items-center shrink-0 w-10"> {/* Award track header */}
{/* Status dot */} <Link href={`/admin/awards/${award.id}` as Route}>
<Tooltip> <div className="group flex items-center gap-3 px-4 py-3 border-b border-amber-200/60 hover:bg-amber-50/60 transition-colors cursor-pointer">
<TooltipTrigger asChild> <div className="flex items-center justify-center h-8 w-8 rounded-full bg-amber-100 shrink-0">
<div className="relative z-10 flex items-center justify-center"> <Trophy className="h-4 w-4 text-amber-600" />
<div
className="h-3.5 w-3.5 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: statusStyle.color }}
/>
{statusStyle.pulse && (
<div
className="absolute h-3.5 w-3.5 rounded-full animate-ping opacity-40"
style={{ backgroundColor: statusStyle.color }}
/>
)}
</div>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{statusStyle.label}
</TooltipContent>
</Tooltip>
{/* Connector line */}
{!isLast && (
<div className="w-px flex-1 min-h-[8px] bg-border" />
)}
</div>
{/* Right: round content + awards */}
<div className="flex-1 min-w-0 pb-2">
<div className="flex items-stretch gap-3">
{/* Round row */}
<Link
href={`/admin/rounds/${round.id}` as Route}
className="flex-1 min-w-0"
>
<div
className={cn(
'group flex items-center gap-3 px-3 py-2.5 rounded-md border-l-[3px] cursor-pointer transition-all',
'bg-white hover:bg-gray-50/80 hover:shadow-sm',
)}
style={{ borderLeftColor: typeColors.dot }}
>
{/* Round type indicator */}
<span className={cn(
'text-[10px] font-semibold uppercase tracking-wider shrink-0 w-[70px]',
typeColors.text
)}>
{round.roundType.replace('_', ' ')}
</span>
{/* Round name */}
<span className="text-sm font-semibold text-[#053d57] truncate group-hover:text-[#de0f1e] transition-colors min-w-0 flex-1">
{round.name}
</span>
{/* Stats cluster */}
<div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground shrink-0">
{round.juryGroup && (
<span className="flex items-center gap-1 max-w-[120px]">
<Users className="h-3 w-3 shrink-0" />
<span className="truncate">{round.juryGroup.name}</span>
</span>
)}
<span className="flex items-center gap-1">
<FileBox className="h-3 w-3 shrink-0" />
{projectCount}
</span>
{assignmentCount > 0 && (
<span className="tabular-nums">{assignmentCount} asgn</span>
)}
{(round.windowOpenAt || round.windowCloseAt) && (
<span className="flex items-center gap-1 tabular-nums">
<Calendar className="h-3 w-3 shrink-0" />
{round.windowOpenAt
? new Date(round.windowOpenAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })
: ''}
{round.windowOpenAt && round.windowCloseAt ? ' \u2013 ' : ''}
{round.windowCloseAt
? new Date(round.windowCloseAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })
: ''}
</span>
)}
</div>
{/* Status badge (compact) */}
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-5 font-medium shrink-0 hidden md:inline-flex"
style={{ color: statusStyle.color, borderColor: statusStyle.color + '40' }}
>
{statusStyle.label}
</Badge>
{/* Arrow */}
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/30 group-hover:text-[#de0f1e]/60 transition-colors shrink-0" />
</div>
</Link>
{/* Awards branching off this round */}
{roundAwards.length > 0 && (
<div className="flex items-center gap-2 shrink-0">
{/* Connector dash */}
<div className="w-4 h-px bg-amber-300" />
{/* Award nodes */}
<div className="flex flex-col gap-1">
{roundAwards.map((award) => (
<AwardNode key={award.id} award={award} />
))}
</div>
</div>
)}
</div> </div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-[#053d57] group-hover:text-[#de0f1e] transition-colors truncate">
{award.name}
</h3>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
<span>{eligible} projects</span>
<span className="text-muted-foreground/30">&middot;</span>
<span className={cn(
'text-[10px] font-semibold uppercase tracking-wide px-1.5 py-px rounded',
isExclusive
? 'bg-red-100 text-red-600'
: 'bg-blue-100 text-blue-600'
)}>
{isExclusive ? 'Exclusive pool' : 'Parallel'}
</span>
<span className="text-muted-foreground/30">&middot;</span>
<span className={statusColor}>
{award.status.replace('_', ' ')}
</span>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/30 group-hover:text-[#de0f1e]/60 transition-colors shrink-0" />
</div> </div>
</Link>
{/* Award track rounds */}
<div className="px-4 py-2">
{awardRounds.map((round, index) => (
<RoundRow
key={round.id}
round={round}
isLast={index === awardRounds.length - 1}
/>
))}
</div> </div>
</div> </div>
) )
})} })}
{/* Floating awards (no evaluationRoundId) */} {/* Floating awards (no linked rounds) */}
{floatingAwards.length > 0 && ( {floatingAwards.length > 0 && (
<div className="mt-4 pt-4 border-t border-dashed"> <div className="pt-2 border-t border-dashed">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-2 pl-10"> <p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-2 pl-10">
Unlinked Awards Unlinked Awards
</p> </p>
@@ -685,6 +638,112 @@ export default function RoundsPage() {
) )
} }
// ─── Round Row ───────────────────────────────────────────────────────────────
function RoundRow({ round, isLast }: { round: RoundWithStats; isLast: boolean }) {
const typeColors = ROUND_TYPE_COLORS[round.roundType] ?? ROUND_TYPE_COLORS.INTAKE
const statusStyle = ROUND_STATUS_STYLES[round.status] ?? ROUND_STATUS_STYLES.ROUND_DRAFT
const projectCount = round._count.projectRoundStates
const assignmentCount = round._count.assignments
return (
<div className="flex">
{/* Left: pipeline track */}
<div className="flex flex-col items-center shrink-0 w-10">
{/* Spacer to vertically center dot with the round card (py-2.5 + half line height) */}
<div className="h-[18px] shrink-0" />
<Tooltip>
<TooltipTrigger asChild>
<div className="relative z-10 flex items-center justify-center">
<div
className="h-3.5 w-3.5 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: statusStyle.color }}
/>
{statusStyle.pulse && (
<div
className="absolute h-3.5 w-3.5 rounded-full animate-ping opacity-40"
style={{ backgroundColor: statusStyle.color }}
/>
)}
</div>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{statusStyle.label}
</TooltipContent>
</Tooltip>
{!isLast && (
<div className="w-px flex-1 min-h-[8px] bg-border" />
)}
</div>
{/* Right: round content */}
<div className="flex-1 min-w-0 pb-2">
<Link
href={`/admin/rounds/${round.id}` as Route}
className="block"
>
<div
className={cn(
'group flex items-center gap-3 px-3 py-2.5 rounded-md border-l-[3px] cursor-pointer transition-all',
'bg-white hover:bg-gray-50/80 hover:shadow-sm',
)}
style={{ borderLeftColor: typeColors.dot }}
>
<span className={cn(
'text-[10px] font-semibold uppercase tracking-wider shrink-0 w-[88px]',
typeColors.text
)}>
{round.roundType.replace('_', ' ')}
</span>
<span className="text-sm font-semibold text-[#053d57] truncate group-hover:text-[#de0f1e] transition-colors min-w-0 flex-1">
{round.name}
</span>
<div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground shrink-0">
{round.juryGroup && (
<span className="flex items-center gap-1 max-w-[120px]">
<Users className="h-3 w-3 shrink-0" />
<span className="truncate">{round.juryGroup.name}</span>
</span>
)}
<span className="flex items-center gap-1">
<FileBox className="h-3 w-3 shrink-0" />
{projectCount}
</span>
{assignmentCount > 0 && (
<span className="tabular-nums">{assignmentCount} asgn</span>
)}
{(round.windowOpenAt || round.windowCloseAt) && (
<span className="flex items-center gap-1 tabular-nums">
<Calendar className="h-3 w-3 shrink-0" />
{round.windowOpenAt
? new Date(round.windowOpenAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })
: ''}
{round.windowOpenAt && round.windowCloseAt ? ' \u2013 ' : ''}
{round.windowCloseAt
? new Date(round.windowCloseAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })
: ''}
</span>
)}
</div>
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-5 font-medium shrink-0 hidden md:inline-flex"
style={{ color: statusStyle.color, borderColor: statusStyle.color + '40' }}
>
{statusStyle.label}
</Badge>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/30 group-hover:text-[#de0f1e]/60 transition-colors shrink-0" />
</div>
</Link>
</div>
</div>
)
}
// ─── Award Node ────────────────────────────────────────────────────────────── // ─── Award Node ──────────────────────────────────────────────────────────────
function AwardNode({ award }: { award: SpecialAwardItem }) { function AwardNode({ award }: { award: SpecialAwardItem }) {

View File

@@ -0,0 +1,42 @@
import type { Metadata } from 'next'
import { prisma } from '@/lib/prisma'
import { SemiFinalistsContent } from '@/components/admin/semi-finalists-content'
export const metadata: Metadata = { title: 'Semi-Finalists' }
export const dynamic = 'force-dynamic'
type PageProps = {
searchParams: Promise<{ editionId?: string }>
}
export default async function SemiFinalistsPage({ searchParams }: PageProps) {
const params = await searchParams
let editionId = params.editionId || null
if (!editionId) {
const defaultEdition = await prisma.program.findFirst({
where: { status: 'ACTIVE' },
orderBy: { year: 'desc' },
select: { id: true },
})
editionId = defaultEdition?.id || null
if (!editionId) {
const anyEdition = await prisma.program.findFirst({
orderBy: { year: 'desc' },
select: { id: true },
})
editionId = anyEdition?.id || null
}
}
if (!editionId) {
return (
<div className="py-12 text-center text-muted-foreground">
No edition found.
</div>
)
}
return <SemiFinalistsContent editionId={editionId} />
}

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import Link from 'next/link' import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -212,6 +212,7 @@ function SortableTagRow({
} }
export default function TagsSettingsPage() { export default function TagsSettingsPage() {
const router = useRouter()
const utils = trpc.useUtils() const utils = trpc.useUtils()
const [isCreateOpen, setIsCreateOpen] = useState(false) const [isCreateOpen, setIsCreateOpen] = useState(false)
const [editingTag, setEditingTag] = useState<Tag | null>(null) const [editingTag, setEditingTag] = useState<Tag | null>(null)
@@ -384,11 +385,9 @@ export default function TagsSettingsPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href="/admin/settings"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Settings
</Link>
</Button> </Button>
</div> </div>

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import Link from 'next/link' import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { import {
Card, Card,
@@ -86,6 +86,7 @@ const defaultForm: WebhookFormData = {
} }
export default function WebhooksPage() { export default function WebhooksPage() {
const router = useRouter()
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = useState<string | null>(null)
@@ -209,11 +210,16 @@ export default function WebhooksPage() {
return return
} }
const filteredHeaders = formData.headers.filter((h) => h.key)
const headersRecord = filteredHeaders.length > 0
? Object.fromEntries(filteredHeaders.map((h) => [h.key, h.value]))
: undefined
const payload = { const payload = {
name: formData.name, name: formData.name,
url: formData.url, url: formData.url,
events: formData.events, events: formData.events,
headers: formData.headers.filter((h) => h.key) as Record<string, string>[] | undefined, headers: headersRecord,
maxRetries: formData.maxRetries, maxRetries: formData.maxRetries,
} }
@@ -254,11 +260,9 @@ export default function WebhooksPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href="/admin/settings"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Settings
</Link>
</Button> </Button>
</div> </div>

View File

@@ -1,16 +1,16 @@
'use client' 'use client'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import Link from 'next/link' import { useRouter } from 'next/navigation'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline' import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline'
import { ArrowLeft, FileText, Calendar } from 'lucide-react' import { ArrowLeft, FileText } from 'lucide-react'
export default function ApplicantCompetitionPage() { export default function ApplicantCompetitionPage() {
const router = useRouter()
const { data: session } = useSession() const { data: session } = useSession()
const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, { const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
enabled: !!session, enabled: !!session,
@@ -36,11 +36,9 @@ export default function ApplicantCompetitionPage() {
Track your progress through competition rounds Track your progress through competition rounds
</p> </p>
</div> </div>
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" onClick={() => router.back()}>
<Link href={'/applicant' as Route} aria-label="Back to applicant dashboard"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Dashboard
</Link>
</Button> </Button>
</div> </div>
@@ -61,29 +59,6 @@ export default function ApplicantCompetitionPage() {
<ApplicantCompetitionTimeline /> <ApplicantCompetitionTimeline />
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Quick Actions
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Button variant="outline" className="w-full justify-start" asChild>
<Link href={'/applicant/documents' as Route}>
<FileText className="mr-2 h-4 w-4" />
View Documents
</Link>
</Button>
{myProject?.openRounds && myProject.openRounds.length > 0 && (
<p className="text-sm text-muted-foreground px-3 py-2 bg-muted/50 rounded-md">
{myProject.openRounds.length} submission window
{myProject.openRounds.length !== 1 ? 's' : ''} currently open
</p>
)}
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Timeline Info</CardTitle> <CardTitle>Timeline Info</CardTitle>

View File

@@ -1,8 +1,10 @@
'use client' 'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { import {
Card, Card,
CardContent, CardContent,
@@ -12,6 +14,7 @@ import {
} from '@/components/ui/card' } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot' import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer'
import { import {
FileText, FileText,
Upload, Upload,
@@ -20,7 +23,11 @@ import {
Video, Video,
File, File,
Download, Download,
Eye,
X,
Loader2,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner'
const fileTypeIcons: Record<string, typeof FileText> = { const fileTypeIcons: Record<string, typeof FileText> = {
EXEC_SUMMARY: FileText, EXEC_SUMMARY: FileText,
@@ -42,6 +49,114 @@ const fileTypeLabels: Record<string, string> = {
SUPPORTING_DOC: 'Supporting Document', SUPPORTING_DOC: 'Supporting Document',
} }
function FileRow({ file }: { file: { id: string; fileName: string; fileType: string; createdAt: string | Date; isLate?: boolean; bucket?: string; objectKey?: string; mimeType?: string } }) {
const [showPreview, setShowPreview] = useState(false)
const Icon = fileTypeIcons[file.fileType] || File
const mimeType = file.mimeType || ''
const canPreview =
mimeType.startsWith('video/') ||
mimeType === 'application/pdf' ||
mimeType.startsWith('image/') ||
isOfficeFile(mimeType, file.fileName)
const { data: previewData, isLoading: isLoadingPreview } = trpc.file.getDownloadUrl.useQuery(
{ bucket: file.bucket!, objectKey: file.objectKey!, purpose: 'preview' as const },
{ enabled: showPreview && !!file.bucket && !!file.objectKey, staleTime: 10 * 60 * 1000 }
)
return (
<div className="rounded-lg border overflow-hidden">
<div className="flex items-center justify-between p-3">
<div className="flex items-center gap-3 min-w-0">
<Icon className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-sm truncate">{file.fileName}</p>
{file.isLate && (
<Badge variant="warning" className="text-xs gap-1">
<AlertTriangle className="h-3 w-3" />
Late
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{fileTypeLabels[file.fileType] || file.fileType}
{' - '}
{new Date(file.createdAt).toLocaleDateString()}
</p>
</div>
</div>
{file.bucket && file.objectKey && (
<div className="flex items-center gap-1 shrink-0">
{canPreview && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1"
onClick={() => setShowPreview(!showPreview)}
>
{showPreview ? (
<><X className="h-3 w-3" /> Close</>
) : (
<><Eye className="h-3 w-3" /> View</>
)}
</Button>
)}
<DownloadButton bucket={file.bucket} objectKey={file.objectKey} fileName={file.fileName} />
</div>
)}
</div>
{showPreview && (
<div className="border-t bg-muted/50">
{isLoadingPreview ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : previewData?.url ? (
<FilePreview file={{ mimeType, fileName: file.fileName }} url={previewData.url} />
) : (
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
Failed to load preview
</div>
)}
</div>
)}
</div>
)
}
function DownloadButton({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) {
const [downloading, setDownloading] = useState(false)
const { refetch } = trpc.file.getDownloadUrl.useQuery(
{ bucket, objectKey, forDownload: true, fileName, purpose: 'download' as const },
{ enabled: false }
)
const handleDownload = async () => {
setDownloading(true)
try {
const result = await refetch()
if (result.data?.url) {
window.location.href = result.data.url
}
} catch {
toast.error('Failed to download file')
} finally {
setTimeout(() => setDownloading(false), 1000)
}
}
return (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleDownload} disabled={downloading}>
{downloading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
Download
</Button>
)
}
export default function ApplicantDocumentsPage() { export default function ApplicantDocumentsPage() {
const { status: sessionStatus } = useSession() const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated' const isAuthenticated = sessionStatus === 'authenticated'
@@ -82,8 +197,7 @@ export default function ApplicantDocumentsPage() {
) )
} }
const { project, openRounds } = data const { project, openRounds, isRejected } = data
const isDraft = !project.submittedAt
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -98,8 +212,20 @@ export default function ApplicantDocumentsPage() {
</p> </p>
</div> </div>
{/* Rejected banner */}
{isRejected && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-4">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0" />
<p className="text-sm text-destructive">
Your project was not selected to advance. Documents are view-only.
</p>
</CardContent>
</Card>
)}
{/* Per-round upload sections */} {/* Per-round upload sections */}
{openRounds.length > 0 && ( {!isRejected && openRounds.length > 0 && (
<div className="space-y-6"> <div className="space-y-6">
{openRounds.map((round: { id: string; name: string; windowCloseAt?: string | Date | null }) => { {openRounds.map((round: { id: string; name: string; windowCloseAt?: string | Date | null }) => {
const now = new Date() const now = new Date()
@@ -162,34 +288,9 @@ export default function ApplicantDocumentsPage() {
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{project.files.map((file) => { {project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File const fileRecord = file as typeof file & { isLate?: boolean; bucket?: string; objectKey?: string; mimeType?: string }
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null }
return ( return (
<div <FileRow key={file.id} file={fileRecord} />
key={file.id}
className="flex items-center justify-between p-3 rounded-lg border"
>
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<p className="font-medium text-sm">{file.fileName}</p>
{fileRecord.isLate && (
<Badge variant="warning" className="text-xs gap-1">
<AlertTriangle className="h-3 w-3" />
Late
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{fileTypeLabels[file.fileType] || file.fileType}
{' - '}
{new Date(file.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</div>
) )
})} })}
</div> </div>

View File

@@ -8,8 +8,82 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Star, MessageSquare } from 'lucide-react' import { AnimatedCard } from '@/components/shared/animated-container'
import {
Star,
MessageSquare,
Trophy,
Vote,
TrendingUp,
BarChart3,
Award,
ShieldCheck,
} from 'lucide-react'
import { cn } from '@/lib/utils'
type EvaluationRound = {
roundId: string
roundName: string
roundType: string
evaluationCount: number
evaluations: Array<{
id: string
submittedAt: Date | null
globalScore: number | null
criterionScores: unknown
feedbackText: string | null
criteria: unknown
}>
}
function computeRoundStats(round: EvaluationRound) {
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100
const scores = round.evaluations
.map((ev) => ev.globalScore)
.filter((s): s is number => s !== null)
const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null
const highest = scores.length > 0 ? Math.max(...scores) : null
const lowest = scores.length > 0 ? Math.min(...scores) : null
return { maxScore, avg, highest, lowest, scores }
}
function ScoreBar({ score, maxScore, color }: { score: number; maxScore: number; color: string }) {
const pct = (score / maxScore) * 100
return (
<div className="flex items-center gap-2 flex-1">
<div className="flex-1 overflow-hidden rounded-full bg-muted" style={{ height: 10 }}>
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: color }}
/>
</div>
<span className="text-sm font-semibold tabular-nums w-8 text-right">{score}</span>
</div>
)
}
function getScoreColor(score: number, maxScore: number): string {
const pct = score / maxScore
if (pct >= 0.8) return '#053d57'
if (pct >= 0.6) return '#1e7a8a'
if (pct >= 0.4) return '#557f8c'
if (pct >= 0.2) return '#c4453a'
return '#de0f1e'
}
function RoundIcon({ roundType, className }: { roundType: string; className?: string }) {
if (roundType === 'LIVE_FINAL') return <Trophy className={cn('h-4 w-4 text-amber-500', className)} />
if (roundType === 'DELIBERATION') return <Vote className={cn('h-4 w-4 text-violet-500', className)} />
return <Star className={cn('h-4 w-4 text-yellow-500', className)} />
}
function roundIconBg(roundType: string) {
if (roundType === 'LIVE_FINAL') return 'bg-amber-500/10'
if (roundType === 'DELIBERATION') return 'bg-violet-500/10'
return 'bg-yellow-500/10'
}
export default function ApplicantEvaluationsPage() { export default function ApplicantEvaluationsPage() {
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery() const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
@@ -21,6 +95,14 @@ export default function ApplicantEvaluationsPage() {
<h1 className="text-2xl font-bold">Jury Feedback</h1> <h1 className="text-2xl font-bold">Jury Feedback</h1>
<p className="text-muted-foreground">Anonymous evaluations from jury members</p> <p className="text-muted-foreground">Anonymous evaluations from jury members</p>
</div> </div>
<div className="grid grid-cols-3 gap-px overflow-hidden rounded-lg border bg-border">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-card p-4">
<Skeleton className="h-5 w-20 mb-2" />
<Skeleton className="h-8 w-16" />
</div>
))}
</div>
<div className="space-y-4"> <div className="space-y-4">
{[1, 2].map((i) => ( {[1, 2].map((i) => (
<Card key={i}> <Card key={i}>
@@ -37,6 +119,28 @@ export default function ApplicantEvaluationsPage() {
const hasEvaluations = rounds && rounds.length > 0 const hasEvaluations = rounds && rounds.length > 0
// Compute global stats
const allScores: number[] = []
let totalEvaluations = 0
if (rounds) {
for (const round of rounds) {
totalEvaluations += round.evaluationCount
for (const ev of round.evaluations) {
if (ev.globalScore !== null && round.roundType !== 'DELIBERATION') {
// Normalize to 0-100 for live final scores
const normalized = round.roundType === 'LIVE_FINAL'
? ev.globalScore * 10
: ev.globalScore
allScores.push(normalized)
}
}
}
}
const globalAvg = allScores.length > 0
? allScores.reduce((a, b) => a + b, 0) / allScores.length
: null
const globalHighest = allScores.length > 0 ? Math.max(...allScores) : null
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
@@ -49,7 +153,9 @@ export default function ApplicantEvaluationsPage() {
{!hasEvaluations ? ( {!hasEvaluations ? (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<Star className="h-12 w-12 text-muted-foreground/50 mb-4" /> <div className="rounded-2xl bg-muted/60 p-4 mb-4">
<Star className="h-8 w-8 text-muted-foreground/50" />
</div>
<h3 className="text-lg font-medium mb-2">No Evaluations Available</h3> <h3 className="text-lg font-medium mb-2">No Evaluations Available</h3>
<p className="text-muted-foreground text-center max-w-md"> <p className="text-muted-foreground text-center max-w-md">
Evaluations will appear here once jury review is complete and results are published. Evaluations will appear here once jury review is complete and results are published.
@@ -58,88 +164,188 @@ export default function ApplicantEvaluationsPage() {
</Card> </Card>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
{rounds.map((round) => ( {/* Stats Summary Strip */}
<Card key={round.roundId}> <AnimatedCard index={0}>
<CardHeader> <Card className="p-0 overflow-hidden">
<div className="flex items-center justify-between"> <div className="grid grid-cols-3 divide-x divide-border">
<CardTitle>{round.roundName}</CardTitle> <div className="p-4 text-center">
<Badge variant="secondary"> <div className="flex items-center justify-center gap-1.5 mb-1">
{round.evaluationCount} evaluation{round.evaluationCount !== 1 ? 's' : ''} <BarChart3 className="h-3.5 w-3.5 text-blue-500" />
</Badge> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Reviews</span>
</div>
</CardHeader>
<CardContent className="space-y-4">
{round.evaluations.map((ev, idx) => (
<div
key={ev.id}
className="rounded-lg border p-4 space-y-3"
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">
Evaluator #{idx + 1}
</span>
{ev.submittedAt && (
<span className="text-xs text-muted-foreground">
{new Date(ev.submittedAt).toLocaleDateString()}
</span>
)}
</div>
{ev.globalScore !== null && (
<div className="flex items-center gap-2">
<Star className="h-4 w-4 text-yellow-500" />
<span className="text-lg font-semibold">{ev.globalScore}</span>
<span className="text-sm text-muted-foreground">/ 100</span>
</div>
)}
{ev.criterionScores && ev.criteria && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Criterion Scores</p>
<div className="grid gap-2">
{(() => {
const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
const scores = ev.criterionScores as Record<string, number>
return criteria
.filter((c) => c.id || c.label || c.name)
.map((c, ci) => {
const key = c.id || String(ci)
const score = scores[key]
return (
<div key={ci} className="flex items-center justify-between text-sm">
<span>{c.label || c.name || `Criterion ${ci + 1}`}</span>
<span className="font-medium">
{score !== undefined ? score : '—'}
{c.maxScore ? ` / ${c.maxScore}` : ''}
</span>
</div>
)
})
})()}
</div>
</div>
)}
{ev.feedbackText && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
Written Feedback
</div>
<blockquote className="border-l-2 border-muted pl-4 text-sm italic text-muted-foreground">
{ev.feedbackText}
</blockquote>
</div>
)}
</div> </div>
))} <p className="text-2xl font-bold tabular-nums">{totalEvaluations}</p>
</CardContent> </div>
<div className="p-4 text-center">
<div className="flex items-center justify-center gap-1.5 mb-1">
<TrendingUp className="h-3.5 w-3.5 text-emerald-500" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Avg Score</span>
</div>
<p className="text-2xl font-bold tabular-nums">
{globalAvg !== null ? globalAvg.toFixed(1) : '—'}
{globalAvg !== null && <span className="text-sm font-normal text-muted-foreground"> / 100</span>}
</p>
</div>
<div className="p-4 text-center">
<div className="flex items-center justify-center gap-1.5 mb-1">
<Award className="h-3.5 w-3.5 text-amber-500" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Highest</span>
</div>
<p className="text-2xl font-bold tabular-nums">
{globalHighest !== null ? globalHighest : '—'}
{globalHighest !== null && <span className="text-sm font-normal text-muted-foreground"> / 100</span>}
</p>
</div>
</div>
</Card> </Card>
))} </AnimatedCard>
<p className="text-xs text-muted-foreground text-center"> {/* Per-Round Cards */}
Evaluator identities are kept confidential. {rounds.map((round, roundIdx) => {
</p> const { maxScore, avg, highest, lowest } = computeRoundStats(round)
return (
<AnimatedCard key={round.roundId} index={roundIdx + 1}>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2.5">
<div className={cn('rounded-lg p-1.5', roundIconBg(round.roundType))}>
<RoundIcon roundType={round.roundType} />
</div>
<div>
<span>{round.roundName}</span>
{avg !== null && round.roundType !== 'DELIBERATION' && (
<p className="text-sm font-normal text-muted-foreground mt-0.5">
Average: <span className="font-semibold text-foreground">{avg.toFixed(1)}</span> / {maxScore}
{highest !== null && lowest !== null && highest !== lowest && (
<span className="ml-2">
Range: {lowest}{highest}
</span>
)}
</p>
)}
</div>
</CardTitle>
<Badge variant="secondary">
{round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'evaluation'}{round.evaluationCount !== 1 ? 's' : ''}
</Badge>
</div>
</CardHeader>
{/* Score Overview Bar — visual comparison across evaluators */}
{round.roundType !== 'DELIBERATION' && round.evaluations.some((ev) => ev.globalScore !== null) && (
<div className="px-6 pb-3">
<div className="rounded-lg bg-muted/40 p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Score Comparison</p>
{round.evaluations.map((ev, idx) => {
if (ev.globalScore === null) return null
return (
<div key={ev.id} className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-6 text-right shrink-0 tabular-nums">
#{idx + 1}
</span>
<ScoreBar
score={ev.globalScore}
maxScore={maxScore}
color={getScoreColor(ev.globalScore, maxScore)}
/>
</div>
)
})}
</div>
</div>
)}
<CardContent className="p-0">
<div className="divide-y">
{round.evaluations.map((ev, idx) => (
<div
key={ev.id}
className="px-6 py-4 space-y-3"
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">
{round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`}
</span>
<div className="flex items-center gap-3">
{ev.globalScore !== null && round.roundType !== 'DELIBERATION' && (
<span className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 text-yellow-500" />
<span className="text-sm font-bold tabular-nums">{ev.globalScore}</span>
<span className="text-xs text-muted-foreground">/ {maxScore}</span>
</span>
)}
{ev.submittedAt && (
<span className="text-xs text-muted-foreground">
{new Date(ev.submittedAt).toLocaleDateString()}
</span>
)}
</div>
</div>
{ev.criterionScores && ev.criteria && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Criteria Breakdown</p>
<div className="grid gap-2">
{(() => {
const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
const scores = ev.criterionScores as Record<string, number>
return criteria
.filter((c) => c.id || c.label || c.name)
.map((c, ci) => {
const key = c.id || String(ci)
const score = scores[key]
const cMax = c.maxScore || 10
const pct = score !== undefined ? (score / cMax) * 100 : 0
return (
<div key={ci} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{c.label || c.name || `Criterion ${ci + 1}`}</span>
<span className="font-semibold tabular-nums">
{score !== undefined ? score : '—'}
<span className="text-muted-foreground font-normal text-xs"> / {cMax}</span>
</span>
</div>
{score !== undefined && (
<Progress value={pct} className="h-1.5" />
)}
</div>
)
})
})()}
</div>
</div>
)}
{ev.feedbackText && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">
<MessageSquare className="h-3.5 w-3.5" />
{round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'}
</div>
<div className="rounded-lg bg-muted/40 px-4 py-3 border-l-3 border-brand-teal">
<p className="text-sm italic text-muted-foreground leading-relaxed">
{ev.feedbackText}
</p>
</div>
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
</AnimatedCard>
)
})}
{/* Confidentiality Footer */}
<div className="flex items-center justify-center gap-2 py-2">
<ShieldCheck className="h-4 w-4 text-muted-foreground/60" />
<p className="text-xs text-muted-foreground">
Evaluator identities are kept confidential.
</p>
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import { useState, useEffect } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
@@ -13,14 +14,15 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Textarea } from '@/components/ui/textarea'
import { StatusTracker } from '@/components/shared/status-tracker'
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline' import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
import { Progress } from '@/components/ui/progress'
import { import {
FileText, FileText,
Calendar, Calendar,
Clock,
CheckCircle, CheckCircle,
Users, Users,
Crown, Crown,
@@ -29,7 +31,28 @@ import {
ArrowRight, ArrowRight,
Star, Star,
AlertCircle, AlertCircle,
Pencil,
Loader2,
Check,
X,
UserCircle,
Trophy,
Vote,
Clock,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner'
function formatCountdown(ms: number): string {
if (ms <= 0) return 'Closed'
const days = Math.floor(ms / (1000 * 60 * 60 * 24))
const hours = Math.floor((ms % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60))
const parts: string[] = []
if (days > 0) parts.push(`${days}d`)
if (hours > 0) parts.push(`${hours}h`)
parts.push(`${minutes}m`)
return parts.join(' ')
}
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = { const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
DRAFT: 'secondary', DRAFT: 'secondary',
@@ -42,9 +65,13 @@ const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destru
REJECTED: 'destructive', REJECTED: 'destructive',
} }
// Keys to hide from the metadata display (shown elsewhere or internal)
const HIDDEN_METADATA_KEYS = new Set(['TeamMembers', 'teammembers', 'team_members'])
export default function ApplicantDashboardPage() { export default function ApplicantDashboardPage() {
const { status: sessionStatus } = useSession() const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated' const isAuthenticated = sessionStatus === 'authenticated'
const utils = trpc.useUtils()
const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, { const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
enabled: isAuthenticated, enabled: isAuthenticated,
@@ -62,7 +89,18 @@ export default function ApplicantDashboardPage() {
enabled: isAuthenticated, enabled: isAuthenticated,
}) })
if (sessionStatus === 'loading' || isLoading) { const { data: flags } = trpc.settings.getFeatureFlags.useQuery(undefined, {
enabled: isAuthenticated,
})
// Live countdown timer for open rounds
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), 60_000)
return () => clearInterval(interval)
}, [])
if (sessionStatus === 'loading' || (isAuthenticated && isLoading)) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
@@ -111,41 +149,99 @@ export default function ApplicantDashboardPage() {
) )
} }
const { project, timeline, currentStatus, openRounds, hasPassedIntake } = data const { project, timeline, currentStatus, openRounds, hasPassedIntake, isRejected } = data
const isDraft = !project.submittedAt
const programYear = project.program?.year const programYear = project.program?.year
const programName = project.program?.name const programName = project.program?.name
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0 const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
const canEditDescription = flags?.applicantAllowDescriptionEdit && !isRejected
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header — no withdraw button here */}
<div className="flex items-start justify-between flex-wrap gap-4"> <div className="flex items-start justify-between flex-wrap gap-4">
<div> <div className="flex items-center gap-4">
<div className="flex items-center gap-3"> {/* Project logo — clickable for any team member to change */}
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1> <ProjectLogoUpload
{currentStatus && ( projectId={project.id}
<Badge variant={statusColors[currentStatus] || 'secondary'}> currentLogoUrl={data.logoUrl}
{currentStatus.replace('_', ' ')} onUploadComplete={() => utils.applicant.getMyDashboard.invalidate()}
</Badge> >
)} <button
type="button"
className="group relative shrink-0 flex flex-col items-center gap-1 cursor-pointer"
>
<div className="relative h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden hover:ring-2 hover:ring-primary/30 transition-all">
{data.logoUrl ? (
<img src={data.logoUrl} alt={project.title} className="h-full w-full object-cover" />
) : (
<FileText className="h-7 w-7 text-muted-foreground/60" />
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<Pencil className="h-4 w-4 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
<span className="text-[10px] text-primary/70 group-hover:text-primary transition-colors">
{data.logoUrl ? 'Change' : 'Add logo'}
</span>
</button>
</ProjectLogoUpload>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
{currentStatus && (
<Badge variant={statusColors[currentStatus] || 'secondary'}>
{currentStatus.replace('_', ' ')}
</Badge>
)}
</div>
<p className="text-muted-foreground">
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
</p>
</div> </div>
<p className="text-muted-foreground">
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
</p>
</div> </div>
</div> </div>
{/* Draft warning */} {/* Active round deadline banner */}
{isDraft && ( {!isRejected && openRounds.length > 0 && (() => {
<Alert> const submissionTypes = new Set(['INTAKE', 'SUBMISSION', 'MENTORING'])
<Clock className="h-4 w-4" /> const roundsWithDeadline = openRounds.filter((r) => r.windowCloseAt && submissionTypes.has(r.roundType))
<AlertTitle>Draft Submission</AlertTitle> if (roundsWithDeadline.length === 0) return null
<AlertDescription> return roundsWithDeadline.map((round) => {
This submission has not been submitted yet. You can continue editing and submit when ready. const closeAt = new Date(round.windowCloseAt!).getTime()
</AlertDescription> const remaining = closeAt - now
</Alert> const isUrgent = remaining > 0 && remaining < 1000 * 60 * 60 * 24 * 3 // < 3 days
)} return (
<div
key={round.id}
className={`flex flex-col sm:flex-row items-start sm:items-center gap-3 rounded-lg border px-4 py-3 ${
isUrgent
? 'border-amber-500/50 bg-amber-50 dark:bg-amber-950/20'
: 'border-primary/20 bg-primary/5'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<Clock className={`h-4 w-4 shrink-0 ${isUrgent ? 'text-amber-600 dark:text-amber-400' : 'text-primary'}`} />
<span className="font-medium text-sm truncate">{round.name}</span>
<Badge variant={isUrgent ? 'warning' : 'default'} className="shrink-0">
{remaining > 0 ? formatCountdown(remaining) + ' left' : 'Closed'}
</Badge>
</div>
<span className="text-xs text-muted-foreground sm:ml-auto shrink-0">
Closes {new Date(round.windowCloseAt!).toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
})}{' '}
at {new Date(round.windowCloseAt!).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
)
})
})()}
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
{/* Main content */} {/* Main content */}
@@ -163,12 +259,19 @@ export default function ApplicantDashboardPage() {
<p>{project.teamName}</p> <p>{project.teamName}</p>
</div> </div>
)} )}
{project.description && ( {/* Description — editable if admin allows */}
{project.description && !canEditDescription && (
<div> <div>
<p className="text-sm font-medium text-muted-foreground">Description</p> <p className="text-sm font-medium text-muted-foreground">Description</p>
<p className="whitespace-pre-wrap">{project.description}</p> <p className="whitespace-pre-wrap">{project.description}</p>
</div> </div>
)} )}
{canEditDescription && (
<EditableDescription
projectId={project.id}
initialDescription={project.description || ''}
/>
)}
{project.tags && project.tags.length > 0 && ( {project.tags && project.tags.length > 0 && (
<div> <div>
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p> <p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
@@ -182,22 +285,27 @@ export default function ApplicantDashboardPage() {
</div> </div>
)} )}
{/* Metadata */} {/* Metadata — filter out team members (shown in sidebar) */}
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && ( {project.metadataJson && (() => {
<div className="border-t pt-4 mt-4"> const entries = Object.entries(project.metadataJson as Record<string, unknown>)
<p className="text-sm font-medium text-muted-foreground mb-3">Additional Information</p> .filter(([key]) => !HIDDEN_METADATA_KEYS.has(key))
<dl className="space-y-2"> if (entries.length === 0) return null
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => ( return (
<div key={key} className="flex justify-between"> <div className="border-t pt-4 mt-4">
<dt className="text-sm text-muted-foreground capitalize"> <p className="text-sm font-medium text-muted-foreground mb-3">Additional Information</p>
{key.replace(/_/g, ' ')} <dl className="space-y-2">
</dt> {entries.map(([key, value]) => (
<dd className="text-sm font-medium">{String(value)}</dd> <div key={key} className="flex justify-between gap-4">
</div> <dt className="text-sm text-muted-foreground capitalize shrink-0">
))} {key.replace(/_/g, ' ')}
</dl> </dt>
</div> <dd className="text-sm font-medium text-right">{String(value)}</dd>
)} </div>
))}
</dl>
</div>
)
})()}
{/* Meta info row */} {/* Meta info row */}
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground border-t pt-4 mt-4"> <div className="flex flex-wrap gap-4 text-sm text-muted-foreground border-t pt-4 mt-4">
@@ -205,16 +313,11 @@ export default function ApplicantDashboardPage() {
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
Created {new Date(project.createdAt).toLocaleDateString()} Created {new Date(project.createdAt).toLocaleDateString()}
</div> </div>
{project.submittedAt ? ( {project.submittedAt && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-green-500" /> <CheckCircle className="h-4 w-4 text-green-500" />
Submitted {new Date(project.submittedAt).toLocaleDateString()} Submitted {new Date(project.submittedAt).toLocaleDateString()}
</div> </div>
) : (
<div className="flex items-center gap-1">
<Clock className="h-4 w-4 text-orange-500" />
Draft
</div>
)} )}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
@@ -225,51 +328,20 @@ export default function ApplicantDashboardPage() {
</Card> </Card>
</AnimatedCard> </AnimatedCard>
{/* Quick actions */} {/* Rejected banner */}
<AnimatedCard index={1}> {isRejected && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <AnimatedCard index={1}>
<Link href={"/applicant/documents" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5"> <Card className="border-destructive/50 bg-destructive/5">
<div className="rounded-xl bg-blue-500/10 p-2.5 transition-colors group-hover:bg-blue-500/20"> <CardContent className="flex items-center gap-3 py-4">
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" /> <AlertCircle className="h-5 w-5 text-destructive shrink-0" />
</div> <p className="text-sm text-destructive">
<div className="flex-1 min-w-0"> Your project was not selected to advance. Your project space is now read-only.
<p className="text-sm font-medium">Documents</p>
<p className="text-xs text-muted-foreground">
{openRounds.length > 0 ? `${openRounds.length} round(s) open` : 'View uploads'}
</p> </p>
</div> </CardContent>
<ArrowRight className="h-4 w-4 text-muted-foreground" /> </Card>
</Link> </AnimatedCard>
)}
<Link href={"/applicant/team" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-purple-500/30 hover:bg-purple-500/5">
<div className="rounded-xl bg-purple-500/10 p-2.5 transition-colors group-hover:bg-purple-500/20">
<Users className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Team</p>
<p className="text-xs text-muted-foreground">
{project.teamMembers.length} member(s)
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
{project.mentorAssignment && (
<Link href={"/applicant/mentor" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-green-500/30 hover:bg-green-500/5">
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Mentor</p>
<p className="text-xs text-muted-foreground">
{project.mentorAssignment.mentor?.name || 'Assigned'}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
)}
</div>
</AnimatedCard>
{/* Document Completeness */} {/* Document Completeness */}
{docCompleteness && docCompleteness.length > 0 && ( {docCompleteness && docCompleteness.length > 0 && (
@@ -306,27 +378,29 @@ export default function ApplicantDashboardPage() {
{/* Sidebar */} {/* Sidebar */}
<div className="space-y-6"> <div className="space-y-6">
{/* Competition timeline or status tracker */} {/* Competition timeline */}
<AnimatedCard index={3}> <AnimatedCard index={3}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>Status Timeline</CardTitle>
{hasPassedIntake ? 'Competition Progress' : 'Status Timeline'}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{hasPassedIntake ? ( <CompetitionTimelineSidebar />
<CompetitionTimelineSidebar />
) : (
<StatusTracker
timeline={timeline}
currentStatus={currentStatus || 'SUBMITTED'}
/>
)}
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard> </AnimatedCard>
{/* Mentoring Request Card */}
{project.isTeamLead && openRounds.filter((r) => r.roundType === 'MENTORING').map((mentoringRound) => (
<AnimatedCard key={mentoringRound.id} index={4}>
<MentoringRequestCard
projectId={project.id}
roundId={mentoringRound.id}
roundName={mentoringRound.name}
/>
</AnimatedCard>
))}
{/* Jury Feedback Card */} {/* Jury Feedback Card */}
{totalEvaluations > 0 && ( {totalEvaluations > 0 && (
<AnimatedCard index={4}> <AnimatedCard index={4}>
@@ -334,7 +408,9 @@ export default function ApplicantDashboardPage() {
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Star className="h-5 w-5" /> <div className="rounded-lg bg-yellow-500/10 p-1.5">
<Star className="h-4 w-4 text-yellow-500" />
</div>
Jury Feedback Jury Feedback
</CardTitle> </CardTitle>
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" asChild>
@@ -344,17 +420,53 @@ export default function ApplicantDashboardPage() {
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-3">
<p className="text-sm text-muted-foreground"> {evaluations?.map((round) => {
{totalEvaluations} evaluation{totalEvaluations !== 1 ? 's' : ''} available from{' '} const scores = round.evaluations
{evaluations?.length ?? 0} round{(evaluations?.length ?? 0) !== 1 ? 's' : ''}. .map((ev) => ev.globalScore)
</p> .filter((s): s is number => s !== null)
const avgScore = scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length
: null
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100
const pct = avgScore !== null ? (avgScore / maxScore) * 100 : 0
const roundIcon = round.roundType === 'LIVE_FINAL'
? <Trophy className="h-3.5 w-3.5 text-amber-500" />
: round.roundType === 'DELIBERATION'
? <Vote className="h-3.5 w-3.5 text-violet-500" />
: <Star className="h-3.5 w-3.5 text-yellow-500" />
return (
<div key={round.roundId} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium">
{roundIcon}
{round.roundName}
</span>
<Badge variant="secondary" className="text-xs">
{round.evaluationCount} review{round.evaluationCount !== 1 ? 's' : ''}
</Badge>
</div>
{avgScore !== null && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Avg Score</span>
<span className="font-semibold text-foreground tabular-nums">
{avgScore.toFixed(1)}<span className="text-muted-foreground font-normal"> / {maxScore}</span>
</span>
</div>
<Progress value={pct} className="h-1.5" />
</div>
)}
</div>
)
})}
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard> </AnimatedCard>
)} )}
{/* Team overview */} {/* Team overview — proper cards */}
<AnimatedCard index={5}> <AnimatedCard index={5}>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -370,27 +482,25 @@ export default function ApplicantDashboardPage() {
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-2">
{project.teamMembers.length > 0 ? ( {project.teamMembers.length > 0 ? (
project.teamMembers.slice(0, 5).map((member) => ( project.teamMembers.slice(0, 5).map((member) => (
<div key={member.id} className="flex items-center gap-3"> <div key={member.id} className="flex items-center gap-3 rounded-lg border p-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted shrink-0">
{member.role === 'LEAD' ? ( {member.role === 'LEAD' ? (
<Crown className="h-4 w-4 text-yellow-500" /> <Crown className="h-4 w-4 text-amber-500" />
) : ( ) : (
<span className="text-xs font-medium"> <UserCircle className="h-4 w-4 text-muted-foreground" />
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate"> <p className="text-sm font-medium truncate">
{member.user.name || member.user.email} {member.user.name || member.user.email}
</p> </p>
<p className="text-xs text-muted-foreground">
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</p>
</div> </div>
<Badge variant="outline" className="shrink-0 text-[10px] px-1.5 py-0">
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</Badge>
</div> </div>
)) ))
) : ( ) : (
@@ -460,3 +570,69 @@ export default function ApplicantDashboardPage() {
</div> </div>
) )
} }
function EditableDescription({ projectId, initialDescription }: { projectId: string; initialDescription: string }) {
const [isEditing, setIsEditing] = useState(false)
const [description, setDescription] = useState(initialDescription)
const utils = trpc.useUtils()
const mutation = trpc.applicant.updateDescription.useMutation({
onSuccess: () => {
utils.applicant.getMyDashboard.invalidate()
setIsEditing(false)
toast.success('Description updated')
},
onError: (e) => toast.error(e.message),
})
const handleSave = () => {
mutation.mutate({ projectId, description })
}
const handleCancel = () => {
setDescription(initialDescription)
setIsEditing(false)
}
if (!isEditing) {
return (
<div>
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium text-muted-foreground">Description</p>
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs gap-1" onClick={() => setIsEditing(true)}>
<Pencil className="h-3 w-3" />
Edit
</Button>
</div>
<p className="whitespace-pre-wrap">{initialDescription || <span className="text-muted-foreground italic">No description yet. Click Edit to add one.</span>}</p>
</div>
)
}
return (
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">Description</p>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={6}
className="mb-2"
disabled={mutation.isPending}
/>
<div className="flex items-center gap-2 justify-end">
<Button variant="ghost" size="sm" onClick={handleCancel} disabled={mutation.isPending}>
<X className="h-3.5 w-3.5 mr-1" />
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={mutation.isPending}>
{mutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Check className="h-3.5 w-3.5 mr-1" />
)}
Save
</Button>
</div>
</div>
)
}

View File

@@ -1,8 +1,7 @@
'use client' 'use client'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useParams } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -27,6 +26,7 @@ const ResourceRenderer = dynamic(
export default function ApplicantResourceDetailPage() { export default function ApplicantResourceDetailPage() {
const params = useParams() const params = useParams()
const router = useRouter()
const resourceId = params.id as string const resourceId = params.id as string
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId }) const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
@@ -73,11 +73,9 @@ export default function ApplicantResourceDetailPage() {
This resource may have been removed or you don&apos;t have access. This resource may have been removed or you don&apos;t have access.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Button asChild> <Button onClick={() => router.back()}>
<Link href="/applicant/resources"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Resources
</Link>
</Button> </Button>
</div> </div>
) )
@@ -87,11 +85,9 @@ export default function ApplicantResourceDetailPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" onClick={() => router.back()} className="-ml-4">
<Link href="/applicant/resources"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Resources
</Link>
</Button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{resource.externalUrl && ( {resource.externalUrl && (

View File

@@ -46,7 +46,12 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { CountrySelect } from '@/components/ui/country-select'
import { Checkbox as CheckboxPrimitive } from '@/components/ui/checkbox'
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
import { UserAvatar } from '@/components/shared/user-avatar'
import { import {
FolderOpen,
Users, Users,
UserPlus, UserPlus,
Crown, Crown,
@@ -57,13 +62,26 @@ import {
CheckCircle, CheckCircle,
Clock, Clock,
FileText, FileText,
ImageIcon,
MapPin,
Waves,
GraduationCap,
Heart,
Calendar,
Pencil,
} from 'lucide-react' } from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import { CountryDisplay } from '@/components/shared/country-display'
const inviteSchema = z.object({ const inviteSchema = z.object({
name: z.string().min(1, 'Name is required'), name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'), email: z.string().email('Invalid email address'),
role: z.enum(['MEMBER', 'ADVISOR']), role: z.enum(['MEMBER', 'ADVISOR']),
title: z.string().optional(), title: z.string().optional(),
nationality: z.string().optional(),
country: z.string().optional(),
institution: z.string().optional(),
sendInvite: z.boolean().default(true),
}) })
type InviteFormData = z.infer<typeof inviteSchema> type InviteFormData = z.infer<typeof inviteSchema>
@@ -80,7 +98,21 @@ const statusLabels: Record<string, { label: string; icon: React.ComponentType<{
SUSPENDED: { label: 'Suspended', icon: AlertCircle }, SUSPENDED: { label: 'Suspended', icon: AlertCircle },
} }
export default function ApplicantTeamPage() { const OCEAN_ISSUE_LABELS: Record<string, string> = {
POLLUTION_REDUCTION: 'Pollution Reduction',
CLIMATE_MITIGATION: 'Climate Mitigation',
TECHNOLOGY_INNOVATION: 'Technology Innovation',
SUSTAINABLE_SHIPPING: 'Sustainable Shipping',
BLUE_CARBON: 'Blue Carbon',
HABITAT_RESTORATION: 'Habitat Restoration',
COMMUNITY_CAPACITY: 'Community Capacity',
SUSTAINABLE_FISHING: 'Sustainable Fishing',
CONSUMER_AWARENESS: 'Consumer Awareness',
OCEAN_ACIDIFICATION: 'Ocean Acidification',
OTHER: 'Other',
}
export default function ApplicantProjectPage() {
const { data: session, status: sessionStatus } = useSession() const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated' const isAuthenticated = sessionStatus === 'authenticated'
const [isInviteOpen, setIsInviteOpen] = useState(false) const [isInviteOpen, setIsInviteOpen] = useState(false)
@@ -90,13 +122,21 @@ export default function ApplicantTeamPage() {
{ enabled: isAuthenticated } { enabled: isAuthenticated }
) )
const projectId = dashboardData?.project?.id const project = dashboardData?.project
const projectId = project?.id
const isIntakeOpen = dashboardData?.isIntakeOpen ?? false
const isRejected = dashboardData?.isRejected ?? false
const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery( const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
{ projectId: projectId! }, { projectId: projectId! },
{ enabled: !!projectId } { enabled: !!projectId }
) )
const { data: logoUrl, refetch: refetchLogo } = trpc.applicant.getProjectLogoUrl.useQuery(
{ projectId: projectId! },
{ enabled: !!projectId }
)
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({ const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
onSuccess: (result) => { onSuccess: (result) => {
if (result.requiresAccountSetup) { if (result.requiresAccountSetup) {
@@ -129,6 +169,10 @@ export default function ApplicantTeamPage() {
email: '', email: '',
role: 'MEMBER', role: 'MEMBER',
title: '', title: '',
nationality: '',
country: '',
institution: '',
sendInvite: true,
}, },
}) })
@@ -170,18 +214,18 @@ export default function ApplicantTeamPage() {
) )
} }
if (!projectId) { if (!projectId || !project) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Team</h1> <h1 className="text-2xl font-semibold tracking-tight">Project</h1>
</div> </div>
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" /> <FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project</h2> <h2 className="text-xl font-semibold mb-2">No Project</h2>
<p className="text-muted-foreground text-center"> <p className="text-muted-foreground text-center">
Submit a project first to manage your team. Submit a project first to view details.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -200,123 +244,314 @@ export default function ApplicantTeamPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center gap-4">
{/* Project logo — clickable for any team member to change */}
<ProjectLogoUpload
projectId={projectId}
currentLogoUrl={logoUrl}
onUploadComplete={() => refetchLogo()}
>
<button
type="button"
className="group relative shrink-0 flex flex-col items-center gap-1 cursor-pointer"
>
<div className="relative h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden hover:ring-2 hover:ring-primary/30 transition-all">
{logoUrl ? (
<img src={logoUrl} alt={project.title} className="h-full w-full object-cover" />
) : (
<FolderOpen className="h-7 w-7 text-muted-foreground/60" />
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<Pencil className="h-4 w-4 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
<span className="text-[10px] text-primary/70 group-hover:text-primary transition-colors">
{logoUrl ? 'Change' : 'Add logo'}
</span>
</button>
</ProjectLogoUpload>
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2"> <h1 className="text-2xl font-semibold tracking-tight">
<Users className="h-6 w-6" /> {project.title}
Team Members
</h1> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Manage your project team {project.teamName ? `Team: ${project.teamName}` : 'Project details and team management'}
</p> </p>
</div> </div>
{isTeamLead && (
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="mr-2 h-4 w-4" />
Invite Member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>
Send an invitation to join your project team. They will receive an email
with instructions to create their account.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
placeholder="Jane Doe"
{...form.register('name')}
/>
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="jane@example.com"
{...form.register('email')}
/>
{form.formState.errors.email && (
<p className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={form.watch('role')}
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Team Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title (optional)</Label>
<Input
id="title"
placeholder="CTO, Designer..."
{...form.register('title')}
/>
</div>
</div>
<div className="rounded-lg bg-muted/50 border p-3 text-sm">
<p className="font-medium mb-1">What invited members can do:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Upload documents for submission rounds</li>
<li>View project status and competition progress</li>
<li>Receive email notifications about round updates</li>
</ul>
<p className="mt-2 text-muted-foreground">Only the Team Lead can invite or remove members.</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsInviteOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={inviteMutation.isPending}>
{inviteMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Send Invitation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div> </div>
{/* Project Details Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Project Information
</CardTitle>
{isIntakeOpen && (
<Badge variant="outline" className="text-amber-600 border-amber-200 bg-amber-50">
Editable during intake
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Category & Ocean Issue badges */}
<div className="flex flex-wrap gap-2">
{project.competitionCategory && (
<Badge variant="outline" className="gap-1">
<GraduationCap className="h-3 w-3" />
{project.competitionCategory === 'STARTUP' ? 'Start-up' : 'Business Concept'}
</Badge>
)}
{project.oceanIssue && (
<Badge variant="outline" className="gap-1">
<Waves className="h-3 w-3" />
{OCEAN_ISSUE_LABELS[project.oceanIssue] || project.oceanIssue.replace(/_/g, ' ')}
</Badge>
)}
{project.wantsMentorship && (
<Badge variant="outline" className="gap-1 text-pink-600 border-pink-200 bg-pink-50">
<Heart className="h-3 w-3" />
Wants Mentorship
</Badge>
)}
</div>
{/* Description */}
{project.description && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">Description</p>
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
</div>
)}
{/* Location, Institution, Founded */}
<div className="grid gap-4 sm:grid-cols-2">
{(project.country || project.geographicZone) && (
<div className="flex items-start gap-2">
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Location</p>
<p className="text-sm">{project.geographicZone}{project.geographicZone && project.country ? ', ' : ''}{project.country ? <CountryDisplay country={project.country} /> : null}</p>
</div>
</div>
)}
{project.institution && (
<div className="flex items-start gap-2">
<GraduationCap className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Institution</p>
<p className="text-sm">{project.institution}</p>
</div>
</div>
)}
{project.foundedAt && (
<div className="flex items-start gap-2">
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Founded</p>
<p className="text-sm">{formatDateOnly(project.foundedAt)}</p>
</div>
</div>
)}
</div>
{/* Mentor info */}
{project.mentorAssignment?.mentor && (
<div className="rounded-lg border p-3 bg-muted/50">
<p className="text-sm font-medium mb-1">Assigned Mentor</p>
<p className="text-sm text-muted-foreground">
{project.mentorAssignment.mentor.name} ({project.mentorAssignment.mentor.email})
</p>
</div>
)}
{/* Tags */}
{project.tags && project.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">Tags</p>
<div className="flex flex-wrap gap-1">
{project.tags.map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Project Logo */}
{projectId && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ImageIcon className="h-5 w-5" />
Project Logo
</CardTitle>
<CardDescription>
Click the image to upload or change your project logo.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<ProjectLogoUpload
projectId={projectId}
currentLogoUrl={logoUrl}
onUploadComplete={() => refetchLogo()}
/>
</CardContent>
</Card>
)}
{/* Team Members List */} {/* Team Members List */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Team ({teamData?.teamMembers.length || 0} members)</CardTitle> <div className="flex items-center justify-between">
<CardDescription> <div>
Everyone on this list can view and collaborate on this project. <CardTitle className="flex items-center gap-2">
</CardDescription> <Users className="h-5 w-5" />
Team ({teamData?.teamMembers.length || 0} members)
</CardTitle>
<CardDescription>
Everyone on this list can view and collaborate on this project.
</CardDescription>
</div>
{isTeamLead && !isRejected && (
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogTrigger asChild>
<Button size="sm">
<UserPlus className="mr-2 h-4 w-4" />
Invite
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>
Send an invitation to join your project team. They will receive an email
with instructions to create their account.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
placeholder="Jane Doe"
{...form.register('name')}
/>
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="jane@example.com"
{...form.register('email')}
/>
{form.formState.errors.email && (
<p className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={form.watch('role')}
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Team Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title (optional)</Label>
<Input
id="title"
placeholder="CTO, Designer..."
{...form.register('title')}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Nationality</Label>
<CountrySelect
value={form.watch('nationality') || ''}
onChange={(v) => form.setValue('nationality', v)}
placeholder="Select nationality"
/>
</div>
<div className="space-y-2">
<Label>Country of Residence</Label>
<CountrySelect
value={form.watch('country') || ''}
onChange={(v) => form.setValue('country', v)}
placeholder="Select country"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="institution">Institution (optional)</Label>
<Input
id="institution"
placeholder="e.g., Ocean Research Institute"
{...form.register('institution')}
/>
</div>
<div className="flex items-center gap-2">
<CheckboxPrimitive
id="sendInvite"
checked={form.watch('sendInvite')}
onCheckedChange={(checked) => form.setValue('sendInvite', !!checked)}
/>
<Label htmlFor="sendInvite" className="text-sm font-normal cursor-pointer">
Send platform invite email
</Label>
</div>
<div className="rounded-lg bg-muted/50 border p-3 text-sm">
<p className="font-medium mb-1">What invited members can do:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Upload documents for submission rounds</li>
<li>View project status and competition progress</li>
<li>Receive email notifications about round updates</li>
</ul>
<p className="mt-2 text-muted-foreground">Only the Team Lead can invite or remove members.</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsInviteOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={inviteMutation.isPending}>
{inviteMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Send Invitation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{teamData?.teamMembers.map((member) => { {teamData?.teamMembers.map((member) => {
@@ -328,13 +563,16 @@ export default function ApplicantTeamPage() {
className="flex items-center justify-between rounded-lg border p-4" className="flex items-center justify-between rounded-lg border p-4"
> >
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted"> <div className="relative">
{member.role === 'LEAD' ? ( <UserAvatar
<Crown className="h-5 w-5 text-yellow-500" /> user={member.user}
) : ( avatarUrl={teamData?.avatarUrls?.[member.userId] || null}
<span className="text-sm font-medium"> size="md"
{member.user.name?.charAt(0).toUpperCase() || '?'} />
</span> {member.role === 'LEAD' && (
<div className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-yellow-100 ring-2 ring-white">
<Crown className="h-2.5 w-2.5 text-yellow-600" />
</div>
)} )}
</div> </div>
<div> <div>
@@ -360,7 +598,7 @@ export default function ApplicantTeamPage() {
</div> </div>
</div> </div>
{isTeamLead && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && ( {isTeamLead && !isRejected && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-destructive"> <Button variant="ghost" size="icon" className="text-destructive">
@@ -409,25 +647,6 @@ export default function ApplicantTeamPage() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Team Documents - visible via applicant documents page */}
{/* Info Card */}
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">About Team Access</p>
<p className="mt-1">
All team members can view project details and status updates.
Only the team lead can invite or remove team members.
Invited members will receive an email to set up their account.
</p>
</div>
</div>
</CardContent>
</Card>
</div> </div>
) )
} }

View File

@@ -1,5 +1,6 @@
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth' import { prisma } from '@/lib/prisma'
import { requireRole } from '@/lib/auth-redirect'
import { ApplicantNav } from '@/components/layouts/applicant-nav' import { ApplicantNav } from '@/components/layouts/applicant-nav'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -9,14 +10,23 @@ export default async function ApplicantLayout({
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const session = await auth() const session = await requireRole('APPLICANT')
const isImpersonating = !!session.user.impersonating
if (!session?.user) { // Check if user has completed onboarding (skip during impersonation)
redirect('/login') if (!isImpersonating) {
} const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { onboardingCompletedAt: true },
})
if (session.user.role !== 'APPLICANT') { if (!user) {
redirect('/login') redirect('/login')
}
if (!user.onboardingCompletedAt) {
redirect('/onboarding')
}
} }
return ( return (

View File

@@ -12,11 +12,58 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock } from 'lucide-react' import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock } from 'lucide-react'
import Image from 'next/image'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
type InviteState = 'loading' | 'valid' | 'accepting' | 'error' type InviteState = 'loading' | 'valid' | 'accepting' | 'error'
function ErrorRedirectCard({
errorContent,
redirectTarget,
}: {
errorContent: { icon: React.ReactNode; title: string; description: string; redirect?: string }
redirectTarget: string
}) {
const router = useRouter()
useEffect(() => {
const timer = setTimeout(() => {
router.push(redirectTarget)
}, 4000)
return () => clearTimeout(timer)
}, [redirectTarget, router])
return (
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100">
{errorContent.icon}
</div>
<CardTitle className="text-xl">{errorContent.title}</CardTitle>
<CardDescription className="text-base">
{errorContent.description}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button
variant="outline"
className="w-full"
onClick={() => router.push(redirectTarget)}
>
Go to Login
</Button>
<p className="text-xs text-center text-muted-foreground">
Redirecting to login in a few seconds...
</p>
</CardContent>
</Card>
</AnimatedCard>
)
}
function AcceptInviteContent() { function AcceptInviteContent() {
const [state, setState] = useState<InviteState>('loading') const [state, setState] = useState<InviteState>('loading')
const [errorType, setErrorType] = useState<string | null>(null) const [errorType, setErrorType] = useState<string | null>(null)
@@ -104,18 +151,21 @@ function AcceptInviteContent() {
icon: <XCircle className="h-6 w-6 text-red-600" />, icon: <XCircle className="h-6 w-6 text-red-600" />,
title: 'Invalid Invitation', title: 'Invalid Invitation',
description: 'This invitation link is not valid. It may have already been used or the link is incorrect.', description: 'This invitation link is not valid. It may have already been used or the link is incorrect.',
redirect: '/login?expired=1',
} }
case 'EXPIRED_TOKEN': case 'EXPIRED_TOKEN':
return { return {
icon: <Clock className="h-6 w-6 text-amber-600" />, icon: <Clock className="h-6 w-6 text-amber-600" />,
title: 'Invitation Expired', title: 'Invitation Expired',
description: 'This invitation has expired. Please contact your administrator to receive a new invitation.', description: 'This invitation has expired. Please contact your administrator to receive a new invitation.',
redirect: '/login?expired=1',
} }
case 'ALREADY_ACCEPTED': case 'ALREADY_ACCEPTED':
return { return {
icon: <CheckCircle2 className="h-6 w-6 text-blue-600" />, icon: <CheckCircle2 className="h-6 w-6 text-blue-600" />,
title: 'Already Accepted', title: 'Already Accepted',
description: 'This invitation has already been accepted. You can sign in with your credentials.', description: 'This invitation has already been accepted. You can sign in with your credentials.',
redirect: '/login',
} }
case 'AUTH_FAILED': case 'AUTH_FAILED':
return { return {
@@ -147,45 +197,24 @@ function AcceptInviteContent() {
) )
} }
// Error state // Error state — auto-redirect to login after 4 seconds for known errors
if (state === 'error') { if (state === 'error') {
const errorContent = getErrorContent() const errorContent = getErrorContent()
return ( const redirectTarget = errorContent.redirect || '/login'
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden"> return <ErrorRedirectCard errorContent={errorContent} redirectTarget={redirectTarget} />
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100">
{errorContent.icon}
</div>
<CardTitle className="text-xl">{errorContent.title}</CardTitle>
<CardDescription className="text-base">
{errorContent.description}
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="outline"
className="w-full"
onClick={() => router.push('/login')}
>
Go to Login
</Button>
</CardContent>
</Card>
</AnimatedCard>
)
} }
// Valid invitation - show welcome // Valid invitation - show welcome
const user = data?.user const user = data?.user
const team = data?.team
return ( return (
<AnimatedCard> <AnimatedCard>
<Card className="w-full max-w-md overflow-hidden"> <Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" /> <div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center"> <CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50"> <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-white shadow-sm border">
<CheckCircle2 className="h-6 w-6 text-green-600" /> <Image src="/images/MOPC-blue-small.png" alt="MOPC" width={32} height={32} className="object-contain" />
</div> </div>
<CardTitle className="text-xl"> <CardTitle className="text-xl">
{user?.name ? `Welcome, ${user.name}!` : 'Welcome!'} {user?.name ? `Welcome, ${user.name}!` : 'Welcome!'}
@@ -196,6 +225,14 @@ function AcceptInviteContent() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{team?.projectTitle && (
<div className="rounded-md border border-blue-200 bg-blue-50 p-3 text-center">
<p className="text-sm text-blue-700">
You&apos;ve been invited to join the team for
</p>
<p className="font-semibold text-blue-900">&ldquo;{team.projectTitle}&rdquo;</p>
</div>
)}
{user?.email && ( {user?.email && (
<div className="rounded-md bg-muted/50 p-3 text-center"> <div className="rounded-md bg-muted/50 p-3 text-center">
<p className="text-sm text-muted-foreground">Signing in as</p> <p className="text-sm text-muted-foreground">Signing in as</p>

View File

@@ -1,50 +1,96 @@
'use client' 'use client'
import { useSearchParams } from 'next/navigation' import { useSearchParams, useRouter } from 'next/navigation'
import { useEffect, Suspense } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Logo } from '@/components/shared/logo' import { Logo } from '@/components/shared/logo'
import { AlertCircle } from 'lucide-react' import { AlertCircle, Clock, Loader2 } from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
const errorMessages: Record<string, string> = { const errorMessages: Record<string, string> = {
Configuration: 'There is a problem with the server configuration.', Configuration: 'There is a problem with the server configuration.',
AccessDenied: 'You do not have access to this resource.', AccessDenied: 'You do not have access to this resource.',
Verification: 'The verification link has expired or already been used.', Verification: 'This sign-in link has expired or has already been used. Please request a new one.',
Default: 'An error occurred during authentication.', Default: 'An error occurred during authentication.',
} }
export default function AuthErrorPage() { function AuthErrorContent() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const router = useRouter()
const error = searchParams.get('error') || 'Default' const error = searchParams.get('error') || 'Default'
const message = errorMessages[error] || errorMessages.Default const message = errorMessages[error] || errorMessages.Default
const isExpired = error === 'Verification'
useEffect(() => {
if (isExpired) {
const timer = setTimeout(() => {
router.push('/login?expired=1')
}, 5000)
return () => clearTimeout(timer)
}
}, [isExpired, router])
return ( return (
<AnimatedCard> <AnimatedCard>
<Card className="w-full max-w-md overflow-hidden"> <Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" /> <div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center"> <CardHeader className="text-center">
<div className="mx-auto mb-4"> <div className="mx-auto mb-4">
<Logo variant="small" /> <Logo variant="small" />
</div> </div>
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10"> <div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10">
<AlertCircle className="h-6 w-6 text-destructive" /> {isExpired ? (
</div> <Clock className="h-6 w-6 text-amber-600" />
<CardTitle className="text-xl">Authentication Error</CardTitle> ) : (
</CardHeader> <AlertCircle className="h-6 w-6 text-destructive" />
<CardContent className="space-y-4 text-center"> )}
<p className="text-muted-foreground">{message}</p> </div>
<div className="flex gap-3 justify-center border-t pt-4"> <CardTitle className="text-xl">
<Button asChild> {isExpired ? 'Link Expired' : 'Authentication Error'}
<Link href="/login">Return to Login</Link> </CardTitle>
</Button> </CardHeader>
<Button variant="outline" asChild> <CardContent className="space-y-4 text-center">
<Link href="/">Home</Link> <p className="text-muted-foreground">{message}</p>
</Button> {isExpired && (
</div> <p className="text-xs text-muted-foreground">
</CardContent> Redirecting to login in 5 seconds...
</Card> </p>
)}
<div className="flex gap-3 justify-center border-t pt-4">
<Button asChild>
<Link href="/login">
{isExpired ? 'Sign In Again' : 'Return to Login'}
</Link>
</Button>
{!isExpired && (
<Button variant="outline" asChild>
<Link href="/">Home</Link>
</Button>
)}
</div>
</CardContent>
</Card>
</AnimatedCard> </AnimatedCard>
) )
} }
export default function AuthErrorPage() {
return (
<Suspense
fallback={
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</CardContent>
</Card>
</AnimatedCard>
}
>
<AuthErrorContent />
</Suspense>
)
}

View File

@@ -0,0 +1,144 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Mail, Loader2, CheckCircle2, AlertCircle, ArrowLeft } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { AnimatedCard } from '@/components/shared/animated-container'
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [isSent, setIsSent] = useState(false)
const [error, setError] = useState<string | null>(null)
const requestReset = trpc.user.requestPasswordReset.useMutation({
onSuccess: () => {
setIsSent(true)
},
onError: (err) => {
setError(err.message || 'Something went wrong. Please try again.')
},
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
requestReset.mutate({ email: email.trim() })
}
if (isSent) {
return (
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-50 animate-in zoom-in-50 duration-300">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
<CardTitle className="text-xl">Check your email</CardTitle>
<CardDescription className="text-base">
If an account exists for <strong>{email}</strong>, we&apos;ve sent a password reset link.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border bg-muted/50 p-4 text-sm text-muted-foreground space-y-2">
<p>Click the link in the email to reset your password. The link will expire in 30 minutes.</p>
<p>If you don&apos;t see it, check your spam folder.</p>
</div>
<div className="border-t pt-4 space-y-2">
<Button
variant="outline"
className="w-full"
onClick={() => {
setIsSent(false)
setError(null)
}}
>
Try a different email
</Button>
<div className="text-center">
<Link href="/login" className="text-sm text-muted-foreground hover:text-primary transition-colors">
<ArrowLeft className="inline h-3.5 w-3.5 mr-1" />
Back to login
</Link>
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
)
}
return (
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-muted">
<Mail className="h-6 w-6 text-muted-foreground" />
</div>
<CardTitle className="text-xl">Reset your password</CardTitle>
<CardDescription>
Enter your email address and we&apos;ll send you a link to reset your password.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<p>{error}</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={requestReset.isPending}
autoComplete="email"
autoFocus
/>
</div>
<Button type="submit" className="w-full" disabled={requestReset.isPending || !email.trim()}>
{requestReset.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<Mail className="mr-2 h-4 w-4" />
Send reset link
</>
)}
</Button>
<div className="text-center pt-2">
<Link href="/login" className="text-sm text-muted-foreground hover:text-primary transition-colors">
<ArrowLeft className="inline h-3.5 w-3.5 mr-1" />
Back to login
</Link>
</div>
</form>
</CardContent>
</Card>
</AnimatedCard>
)
}

View File

@@ -19,22 +19,30 @@ export default async function AuthLayout({
// Redirect logged-in users to their dashboard // Redirect logged-in users to their dashboard
// But NOT if they still need to set their password // But NOT if they still need to set their password
if (session?.user && !session.user.mustSetPassword) { if (session?.user && !session.user.mustSetPassword) {
// Verify user still exists in DB (handles deleted accounts with stale sessions) // Verify user still exists in DB and check onboarding status
const dbUser = await prisma.user.findUnique({ const dbUser = await prisma.user.findUnique({
where: { id: session.user.id }, where: { id: session.user.id },
select: { id: true }, select: { id: true, onboardingCompletedAt: true },
}) })
if (dbUser) { if (dbUser) {
const role = session.user.role // If user hasn't completed onboarding, don't redirect away from auth pages.
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') { // The /onboarding page lives in this (auth) layout, so they need to stay here.
redirect('/admin') if (!dbUser.onboardingCompletedAt) {
} else if (role === 'JURY_MEMBER') { // Fall through — let them access /onboarding (and other auth pages)
redirect('/jury') } else {
} else if (role === 'OBSERVER') { const role = session.user.role
redirect('/observer') if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
} else if (role === 'MENTOR') { redirect('/admin')
redirect('/mentor') } else if (role === 'JURY_MEMBER') {
redirect('/jury')
} else if (role === 'OBSERVER') {
redirect('/observer')
} else if (role === 'MENTOR') {
redirect('/mentor')
} else if (role === 'APPLICANT') {
redirect('/applicant')
}
} }
} }
// If user doesn't exist in DB, fall through and show auth page // If user doesn't exist in DB, fall through and show auth page

View File

@@ -1,8 +1,11 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import type { Route } from 'next'
import { useSearchParams, useRouter } from 'next/navigation' import { useSearchParams, useRouter } from 'next/navigation'
import { signIn } from 'next-auth/react' import { signIn } from 'next-auth/react'
import Image from 'next/image'
import Link from 'next/link'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@@ -13,7 +16,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Mail, Loader2, CheckCircle2, AlertCircle, Lock, KeyRound } from 'lucide-react' import { Mail, Loader2, CheckCircle2, AlertCircle, Lock, KeyRound, Clock } from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
type LoginMode = 'password' | 'magic-link' type LoginMode = 'password' | 'magic-link'
@@ -30,6 +33,7 @@ export default function LoginPage() {
const router = useRouter() const router = useRouter()
const callbackUrl = searchParams.get('callbackUrl') || '/' const callbackUrl = searchParams.get('callbackUrl') || '/'
const errorParam = searchParams.get('error') const errorParam = searchParams.get('error')
const isExpiredLink = searchParams.get('expired') === '1'
const handlePasswordLogin = async (e: React.FormEvent) => { const handlePasswordLogin = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -67,6 +71,19 @@ export default function LoginPage() {
setError(null) setError(null)
try { try {
// Pre-check: does this email exist?
const checkRes = await fetch('/api/auth/check-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
const checkData = await checkRes.json()
if (!checkData.exists) {
setError('No account found with this email address. Please check the email you used to sign up, or contact the administrator.')
setIsLoading(false)
return
}
// Get CSRF token first // Get CSRF token first
const csrfRes = await fetch('/api/auth/csrf') const csrfRes = await fetch('/api/auth/csrf')
const { csrfToken } = await csrfRes.json() const { csrfToken } = await csrfRes.json()
@@ -149,6 +166,15 @@ export default function LoginPage() {
<Card className="w-full max-w-md overflow-hidden"> <Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" /> <div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center"> <CardHeader className="text-center">
<div className="flex justify-center mb-2">
<Image
src="/images/MOPC-blue-small.png"
alt="MOPC"
width={48}
height={48}
className="h-12 w-auto"
/>
</div>
<CardTitle className="text-2xl">Welcome back</CardTitle> <CardTitle className="text-2xl">Welcome back</CardTitle>
<CardDescription> <CardDescription>
{mode === 'password' {mode === 'password'
@@ -157,6 +183,17 @@ export default function LoginPage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isExpiredLink && (
<div className="mb-4 flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm">
<Clock className="h-4 w-4 mt-0.5 text-amber-600 shrink-0" />
<div>
<p className="font-medium text-amber-900">Your link has expired</p>
<p className="text-amber-700 mt-0.5">
Sign-in links expire after 15 minutes for security. Please sign in below or request a new magic link.
</p>
</div>
</div>
)}
{mode === 'password' ? ( {mode === 'password' ? (
// Password login form // Password login form
<form onSubmit={handlePasswordLogin} className="space-y-4"> <form onSubmit={handlePasswordLogin} className="space-y-4">
@@ -192,16 +229,12 @@ export default function LoginPage() {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">Password</Label>
<button <Link
type="button" href={'/forgot-password' as Route}
className="text-sm text-muted-foreground hover:text-primary transition-colors" className="text-sm text-muted-foreground hover:text-primary transition-colors"
onClick={() => {
setMode('magic-link')
setError(null)
}}
> >
Forgot password? Forgot password?
</button> </Link>
</div> </div>
<Input <Input
id="password" id="password"
@@ -302,6 +335,12 @@ export default function LoginPage() {
</> </>
)} )}
</button> </button>
<p className="mt-3 text-xs text-muted-foreground/70 text-center">
Don&apos;t remember which email you used?{' '}
<a href="mailto:contact@monaco-opc.com" className="underline hover:text-primary transition-colors">
Contact the MOPC team
</a>
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -0,0 +1,691 @@
'use client'
import { useState, useMemo, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { CountrySelect } from '@/components/ui/country-select'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { AvatarUpload } from '@/components/shared/avatar-upload'
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
import { Textarea } from '@/components/ui/textarea'
import {
User,
Bell,
CheckCircle,
Loader2,
ArrowRight,
ArrowLeft,
Camera,
Globe,
FileText,
Building2,
Flag,
ImageIcon,
Compass,
LayoutDashboard,
Upload,
ClipboardList,
Users,
Trophy,
BookOpen,
GraduationCap,
} from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
import { UserAvatar } from '@/components/shared/user-avatar'
type Step =
| 'name'
| 'photo'
| 'nationality'
| 'country'
| 'institution'
| 'bio'
| 'logo'
| 'preferences'
| 'guide'
| 'complete'
type ApplicantWizardProps = {
userData: {
id: string
name: string | null
email: string
role: string
country: string | null
nationality: string | null
institution: string | null
bio: string | null
profileImageKey: string | null
notificationPreference: string
}
avatarUrl: string | null | undefined
refetchUser: () => void
}
export function ApplicantOnboardingWizard({
userData,
avatarUrl,
refetchUser,
}: ApplicantWizardProps) {
const router = useRouter()
const [step, setStep] = useState<Step>('name')
const [initialized, setInitialized] = useState(false)
// Form state
const [name, setName] = useState('')
const [nationality, setNationality] = useState('')
const [country, setCountry] = useState('')
const [institution, setInstitution] = useState('')
const [bio, setBio] = useState('')
const [notificationPreference, setNotificationPreference] = useState<
'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE'
>('EMAIL')
// Fetch onboarding context (project info)
const { data: onboardingCtx } = trpc.applicant.getOnboardingContext.useQuery()
const { data: logoUrl, refetch: refetchLogo } = trpc.applicant.getProjectLogoUrl.useQuery(
{ projectId: onboardingCtx?.projectId ?? '' },
{ enabled: !!onboardingCtx?.projectId }
)
// Initialize form with user data
useEffect(() => {
if (userData && !initialized) {
if (userData.name) setName(userData.name)
if (userData.country) setCountry(userData.country)
if (userData.nationality) setNationality(userData.nationality)
if (userData.institution) setInstitution(userData.institution)
if (userData.bio) setBio(userData.bio)
if (userData.notificationPreference) {
setNotificationPreference(userData.notificationPreference as typeof notificationPreference)
}
setInitialized(true)
}
}, [userData, initialized])
// Prefill institution from project if user hasn't set one
useEffect(() => {
if (onboardingCtx?.institution && !institution && initialized) {
setInstitution(onboardingCtx.institution)
}
}, [onboardingCtx, institution, initialized])
const utils = trpc.useUtils()
const completeOnboarding = trpc.user.completeOnboarding.useMutation({
onSuccess: () => utils.user.me.invalidate(),
})
const steps: Step[] = useMemo(() => {
const base: Step[] = [
'name',
'photo',
'nationality',
'country',
'institution',
'bio',
]
// Only show logo step if applicant has a project
if (onboardingCtx?.projectId) {
base.push('logo')
}
base.push('preferences', 'guide', 'complete')
return base
}, [onboardingCtx?.projectId])
const currentIndex = steps.indexOf(step)
const totalVisibleSteps = steps.length - 1
const goNext = () => {
if (step === 'name' && !name.trim()) {
toast.error('Please enter your name')
return
}
const nextIndex = currentIndex + 1
if (nextIndex < steps.length) {
setStep(steps[nextIndex])
}
}
const goBack = () => {
const prevIndex = currentIndex - 1
if (prevIndex >= 0) {
setStep(steps[prevIndex])
}
}
const handleComplete = async () => {
try {
await completeOnboarding.mutateAsync({
name,
country: country || undefined,
nationality: nationality || undefined,
institution: institution || undefined,
bio: bio || undefined,
notificationPreference,
})
setStep('complete')
toast.success('Welcome to MOPC!')
setTimeout(() => {
router.push('/applicant')
}, 2000)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to complete onboarding')
}
}
const stepLabels: Record<Step, string> = {
name: 'Name',
photo: 'Photo',
nationality: 'Nationality',
country: 'Residence',
institution: 'Institution',
bio: 'About',
logo: 'Logo',
preferences: 'Settings',
guide: 'Guide',
complete: 'Done',
}
return (
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-[#053d57] bg-[url('https://s3.monaco-opc.com/public/ocean.png')] bg-cover bg-center bg-no-repeat">
<AnimatedCard>
<Card className="w-full max-w-lg max-h-[85vh] overflow-y-auto overflow-x-hidden shadow-2xl">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
{/* Progress indicator */}
{step !== 'complete' && (
<div className="px-6 pt-6">
<div className="flex items-center gap-1">
{steps.slice(0, -1).map((s, i) => (
<div key={s} className="flex-1 flex flex-col items-center gap-1">
<div
className={`h-2 w-full rounded-full transition-colors ${
i < currentIndex
? 'bg-primary'
: i === currentIndex
? 'bg-primary/60'
: 'bg-muted'
}`}
/>
<span
className={cn(
'text-[10px] leading-none',
i <= currentIndex ? 'text-primary font-medium' : 'text-muted-foreground'
)}
>
{stepLabels[s]}
</span>
</div>
))}
</div>
<p className="text-sm text-muted-foreground mt-2">
Step {currentIndex + 1} of {totalVisibleSteps}
</p>
</div>
)}
{/* Step: Name */}
{step === 'name' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5 text-primary" />
Welcome to MOPC
</CardTitle>
<CardDescription>
Let&apos;s get your profile set up. What should we call you?
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your full name"
autoFocus
/>
</div>
<Button onClick={goNext} className="w-full" disabled={!name.trim()}>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</>
)}
{/* Step: Profile Photo */}
{step === 'photo' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Camera className="h-5 w-5 text-primary" />
Profile Photo
</CardTitle>
<CardDescription>
Add a profile photo so others can recognize you. This step is optional.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-center">
<AvatarUpload
user={{
name: userData?.name,
email: userData?.email,
profileImageKey: userData?.profileImageKey,
}}
currentAvatarUrl={avatarUrl}
onUploadComplete={() => refetchUser()}
>
<div className="cursor-pointer">
<UserAvatar
user={{ name: userData?.name, email: userData?.email }}
avatarUrl={avatarUrl}
size="2xl"
showEditOverlay
/>
</div>
</AvatarUpload>
</div>
<p className="text-sm text-muted-foreground text-center">
Click the avatar to upload a new photo.
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
{avatarUrl ? 'Continue' : 'Skip for now'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step: Nationality */}
{step === 'nationality' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Flag className="h-5 w-5 text-primary" />
Nationality
</CardTitle>
<CardDescription>
Select your nationality.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="nationality">Nationality</Label>
<CountrySelect
value={nationality}
onChange={setNationality}
placeholder="Select your nationality"
/>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
{nationality ? 'Continue' : 'Skip for now'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step: Country of Residence */}
{step === 'country' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5 text-primary" />
Country of Residence
</CardTitle>
<CardDescription>
Where are you currently based?
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="country">Country</Label>
<CountrySelect
value={country}
onChange={setCountry}
placeholder="Select your country"
/>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
{country ? 'Continue' : 'Skip for now'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step: Institution */}
{step === 'institution' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-primary" />
Institution
</CardTitle>
<CardDescription>
Your organization or institution name.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="institution">Institution / Organization</Label>
<Input
id="institution"
value={institution}
onChange={(e) => setInstitution(e.target.value)}
placeholder="e.g., Ocean Research Institute"
/>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
{institution ? 'Continue' : 'Skip for now'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step: Bio */}
{step === 'bio' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" />
About You
</CardTitle>
<CardDescription>
Tell us a bit about yourself and your work. (Optional)
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bio">Bio</Label>
<Textarea
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder="e.g., Marine biologist working on coral reef conservation..."
rows={4}
maxLength={500}
/>
<p className="text-xs text-muted-foreground text-right">
{bio.length}/500 characters
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
{bio ? 'Continue' : 'Skip for now'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step: Project Logo */}
{step === 'logo' && onboardingCtx?.projectId && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ImageIcon className="h-5 w-5 text-primary" />
Project Logo
</CardTitle>
<CardDescription>
Upload a logo for &quot;{onboardingCtx.projectTitle}&quot;. This step is optional.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-center">
<ProjectLogoUpload
projectId={onboardingCtx.projectId}
currentLogoUrl={logoUrl}
onUploadComplete={() => refetchLogo()}
/>
</div>
<p className="text-sm text-muted-foreground text-center">
Click the image area to upload a logo.
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
{logoUrl ? 'Continue' : 'Skip for now'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step: Notification Preferences */}
{step === 'preferences' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5 text-primary" />
Notification Preferences
</CardTitle>
<CardDescription>
How would you like to receive notifications?
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="notifications">Notification Channel</Label>
<Select
value={notificationPreference}
onValueChange={(v) =>
setNotificationPreference(v as typeof notificationPreference)
}
>
<SelectTrigger id="notifications">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EMAIL">Email only</SelectItem>
<SelectItem value="NONE">No notifications</SelectItem>
</SelectContent>
</Select>
</div>
<div className="rounded-lg border p-4 bg-muted/50">
<h4 className="font-medium mb-2">Summary</h4>
<div className="space-y-1 text-sm">
<p>
<span className="text-muted-foreground">Name:</span> {name}
</p>
{nationality && (
<p>
<span className="text-muted-foreground">Nationality:</span> {nationality}
</p>
)}
{country && (
<p>
<span className="text-muted-foreground">Country:</span> {country}
</p>
)}
{institution && (
<p>
<span className="text-muted-foreground">Institution:</span> {institution}
</p>
)}
{bio && (
<p>
<span className="text-muted-foreground">Bio:</span>{' '}
{bio.length > 50 ? `${bio.substring(0, 50)}...` : bio}
</p>
)}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step: Portal Guide */}
{step === 'guide' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Compass className="h-5 w-5 text-primary" />
Your Applicant Portal
</CardTitle>
<CardDescription>
Here&apos;s what you can do through the MOPC Applicant Portal.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
{[
{
icon: LayoutDashboard,
title: 'Dashboard',
desc: 'Overview of your project status, team, and upcoming deadlines.',
},
{
icon: Upload,
title: 'Documents',
desc: 'Upload required files for each round and track submission progress.',
},
{
icon: ClipboardList,
title: 'Evaluations',
desc: 'View anonymized jury feedback and scores for your project.',
},
{
icon: Users,
title: 'Team',
desc: 'Manage your team members, invite collaborators, and update your project logo.',
},
{
icon: Trophy,
title: 'Competition',
desc: 'Track your progress through competition rounds and milestones.',
},
{
icon: GraduationCap,
title: 'Mentorship',
desc: 'Connect with your assigned mentor for guidance and support.',
},
{
icon: BookOpen,
title: 'Resources',
desc: 'Access helpful materials, guides, and competition resources.',
},
].map(({ icon: Icon, title, desc }) => (
<div key={title} className="flex items-start gap-3 rounded-lg border p-3">
<div className="rounded-md bg-primary/10 p-2 shrink-0">
<Icon className="h-4 w-4 text-primary" />
</div>
<div>
<p className="font-medium text-sm">{title}</p>
<p className="text-xs text-muted-foreground">{desc}</p>
</div>
</div>
))}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button
onClick={handleComplete}
className="flex-1"
disabled={completeOnboarding.isPending}
>
{completeOnboarding.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle className="mr-2 h-4 w-4" />
)}
Complete Setup
</Button>
</div>
</CardContent>
</>
)}
{/* Step: Complete */}
{step === 'complete' && (
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="mb-4 animate-in zoom-in-50 duration-500">
<Image src="/images/MOPC-blue-small.png" alt="MOPC Logo" width={64} height={64} className="h-16 w-16" />
</div>
<h2 className="text-xl font-semibold mb-2 animate-in fade-in slide-in-from-bottom-2 duration-500 delay-200">
Welcome, {name}!
</h2>
<p className="text-muted-foreground text-center mb-4">
Your profile is all set up. You&apos;ll be redirected to your dashboard shortly.
</p>
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</CardContent>
)}
</Card>
</AnimatedCard>
</div>
)
}

View File

@@ -44,6 +44,7 @@ import {
Scale, Scale,
} from 'lucide-react' } from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { ApplicantOnboardingWizard } from './applicant-wizard'
type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'jury' | 'preferences' | 'complete' type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'jury' | 'preferences' | 'complete'
@@ -208,6 +209,8 @@ export default function OnboardingPage() {
router.push('/mentor') router.push('/mentor')
} else if (role === 'OBSERVER') { } else if (role === 'OBSERVER') {
router.push('/observer') router.push('/observer')
} else if (role === 'APPLICANT') {
router.push('/applicant')
} else { } else {
router.push('/jury') router.push('/jury')
} }
@@ -234,6 +237,17 @@ export default function OnboardingPage() {
) )
} }
// Applicant users get a dedicated onboarding wizard
if (userData?.role === 'APPLICANT') {
return (
<ApplicantOnboardingWizard
userData={userData as unknown as Parameters<typeof ApplicantOnboardingWizard>[0]['userData']}
avatarUrl={avatarUrl}
refetchUser={refetchUser}
/>
)
}
return ( return (
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-[#053d57] bg-[url('https://s3.monaco-opc.com/public/ocean.png')] bg-cover bg-center bg-no-repeat"> <div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-[#053d57] bg-[url('https://s3.monaco-opc.com/public/ocean.png')] bg-cover bg-center bg-no-repeat">
<AnimatedCard> <AnimatedCard>

View File

@@ -0,0 +1,278 @@
'use client'
import { useState } from 'react'
import type { Route } from 'next'
import { useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Lock, Loader2, CheckCircle2, AlertCircle, Eye, EyeOff, ArrowLeft } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { AnimatedCard } from '@/components/shared/animated-container'
export default function ResetPasswordPage() {
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isSuccess, setIsSuccess] = useState(false)
const resetPassword = trpc.user.resetPassword.useMutation({
onSuccess: () => {
setIsSuccess(true)
},
onError: (err) => {
setError(err.message || 'Failed to reset password. Please try again.')
},
})
// Password validation
const validatePassword = (pwd: string) => {
const errors: string[] = []
if (pwd.length < 8) errors.push('At least 8 characters')
if (!/[A-Z]/.test(pwd)) errors.push('One uppercase letter')
if (!/[a-z]/.test(pwd)) errors.push('One lowercase letter')
if (!/[0-9]/.test(pwd)) errors.push('One number')
return errors
}
const passwordErrors = validatePassword(password)
const isPasswordValid = passwordErrors.length === 0
const doPasswordsMatch = password === confirmPassword && password.length > 0
const getPasswordStrength = (pwd: string) => {
let score = 0
if (pwd.length >= 8) score++
if (pwd.length >= 12) score++
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++
if (/[0-9]/.test(pwd)) score++
if (/[^a-zA-Z0-9]/.test(pwd)) score++
const normalizedScore = Math.min(4, score)
const labels = ['Very Weak', 'Weak', 'Fair', 'Strong', 'Very Strong']
const colors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-green-500', 'bg-green-600']
return { score: normalizedScore, label: labels[normalizedScore], color: colors[normalizedScore] }
}
const strength = getPasswordStrength(password)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!isPasswordValid) {
setError('Password does not meet requirements.')
return
}
if (!doPasswordsMatch) {
setError('Passwords do not match.')
return
}
if (!token) {
setError('Invalid reset link. Please request a new one.')
return
}
resetPassword.mutate({ token, password, confirmPassword })
}
// No token in URL
if (!token) {
return (
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10">
<AlertCircle className="h-6 w-6 text-destructive" />
</div>
<CardTitle className="text-xl">Invalid Reset Link</CardTitle>
<CardDescription>
This password reset link is invalid or has expired.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button asChild className="w-full">
<Link href={'/forgot-password' as Route}>Request a new reset link</Link>
</Button>
<div className="text-center">
<Link href="/login" className="text-sm text-muted-foreground hover:text-primary transition-colors">
<ArrowLeft className="inline h-3.5 w-3.5 mr-1" />
Back to login
</Link>
</div>
</CardContent>
</Card>
</AnimatedCard>
)
}
// Success state
if (isSuccess) {
return (
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<CardTitle className="text-xl">Password Reset Successfully</CardTitle>
<CardDescription>
Your password has been updated. You can now sign in with your new password.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild className="w-full">
<Link href="/login">Sign in</Link>
</Button>
</CardContent>
</Card>
</AnimatedCard>
)
}
return (
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<CardTitle className="text-xl">Choose a new password</CardTitle>
<CardDescription>
Create a secure password for your account.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<p>{error}</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="Enter a secure password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={resetPassword.isPending}
autoComplete="new-password"
autoFocus
className="pr-10"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{password.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Progress value={(strength.score / 4) * 100} className={`h-2 ${strength.color}`} />
<span className="text-xs text-muted-foreground whitespace-nowrap">{strength.label}</span>
</div>
<div className="grid grid-cols-2 gap-1 text-xs">
{[
{ label: '8+ characters', met: password.length >= 8 },
{ label: 'Uppercase', met: /[A-Z]/.test(password) },
{ label: 'Lowercase', met: /[a-z]/.test(password) },
{ label: 'Number', met: /[0-9]/.test(password) },
].map((req) => (
<div
key={req.label}
className={`flex items-center gap-1 ${req.met ? 'text-green-600' : 'text-muted-foreground'}`}
>
{req.met ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<div className="h-3 w-3 rounded-full border border-current" />
)}
{req.label}
</div>
))}
</div>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={resetPassword.isPending}
autoComplete="new-password"
className="pr-10"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{confirmPassword.length > 0 && (
<p className={`text-xs ${doPasswordsMatch ? 'text-green-600' : 'text-destructive'}`}>
{doPasswordsMatch ? 'Passwords match' : 'Passwords do not match'}
</p>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={resetPassword.isPending || !isPasswordValid || !doPasswordsMatch}
>
{resetPassword.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Resetting...
</>
) : (
<>
<Lock className="mr-2 h-4 w-4" />
Reset Password
</>
)}
</Button>
<div className="text-center pt-2">
<Link href="/login" className="text-sm text-muted-foreground hover:text-primary transition-colors">
<ArrowLeft className="inline h-3.5 w-3.5 mr-1" />
Back to login
</Link>
</div>
</form>
</CardContent>
</Card>
</AnimatedCard>
)
}

View File

@@ -36,15 +36,9 @@ export default function SetPasswordPage() {
setIsSuccess(true) setIsSuccess(true)
// Update the session to reflect the password has been set // Update the session to reflect the password has been set
await updateSession() await updateSession()
// Redirect after a short delay // Redirect after a short delay — all roles go to onboarding first
setTimeout(() => { setTimeout(() => {
if (session?.user?.role === 'JURY_MEMBER') { router.push('/onboarding')
router.push('/jury')
} else if (session?.user?.role === 'SUPER_ADMIN' || session?.user?.role === 'PROGRAM_ADMIN') {
router.push('/admin')
} else {
router.push('/')
}
}, 2000) }, 2000)
}, },
onError: (err) => { onError: (err) => {
@@ -146,7 +140,7 @@ export default function SetPasswordPage() {
</CardHeader> </CardHeader>
<CardContent className="text-center"> <CardContent className="text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Redirecting you to the dashboard... Redirecting you to onboarding...
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { use, useState } from 'react' import { use, useState } from 'react'
import Link from 'next/link' import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@@ -22,6 +22,7 @@ import {
GripVertical, GripVertical,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { CountryDisplay } from '@/components/shared/country-display'
export default function JuryAwardVotingPage({ export default function JuryAwardVotingPage({
params, params,
@@ -29,6 +30,7 @@ export default function JuryAwardVotingPage({
params: Promise<{ id: string }> params: Promise<{ id: string }>
}) { }) {
const { id: awardId } = use(params) const { id: awardId } = use(params)
const router = useRouter()
const utils = trpc.useUtils() const utils = trpc.useUtils()
const { data, isLoading, refetch } = const { data, isLoading, refetch } =
@@ -120,11 +122,9 @@ export default function JuryAwardVotingPage({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" onClick={() => router.back()} className="-ml-4">
<Link href="/jury/awards"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Awards
</Link>
</Button> </Button>
</div> </div>
@@ -192,7 +192,7 @@ export default function JuryAwardVotingPage({
)} )}
{project.country && ( {project.country && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{project.country} <CountryDisplay country={project.country} />
</Badge> </Badge>
)} )}
</div> </div>
@@ -286,7 +286,7 @@ export default function JuryAwardVotingPage({
)} )}
{project.country && ( {project.country && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{project.country} <CountryDisplay country={project.country} />
</Badge> </Badge>
)} )}
</div> </div>

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useParams } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
@@ -8,12 +8,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, CheckCircle2, Clock, Circle } from 'lucide-react' import { ArrowLeft, CheckCircle2, Clock, Circle, Eye } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { JurorProgressDashboard } from '@/components/jury/juror-progress-dashboard' import { JurorProgressDashboard } from '@/components/jury/juror-progress-dashboard'
export default function JuryRoundDetailPage() { export default function JuryRoundDetailPage() {
const params = useParams() const params = useParams()
const router = useRouter()
const roundId = params.roundId as string const roundId = params.roundId as string
const { data: assignments, isLoading } = trpc.roundAssignment.getMyAssignments.useQuery( const { data: assignments, isLoading } = trpc.roundAssignment.getMyAssignments.useQuery(
@@ -38,11 +39,9 @@ export default function JuryRoundDetailPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" onClick={() => router.back()}>
<Link href={'/jury/competitions' as Route} aria-label="Back to competitions list"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back
</Link>
</Button> </Button>
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground"> <h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
@@ -82,10 +81,13 @@ export default function JuryRoundDetailPage() {
const isDraft = assignment.evaluation?.status === 'DRAFT' const isDraft = assignment.evaluation?.status === 'DRAFT'
return ( return (
<Link <div
key={assignment.id} key={assignment.id}
href={`/jury/competitions/${roundId}/projects/${assignment.projectId}` as Route} role="button"
className="flex items-center justify-between p-4 rounded-lg border border-border/60 hover:border-brand-blue/30 hover:bg-brand-blue/5 transition-all" tabIndex={0}
onClick={() => router.push(`/jury/competitions/${roundId}/projects/${assignment.projectId}`)}
onKeyDown={(e) => { if (e.key === 'Enter') router.push(`/jury/competitions/${roundId}/projects/${assignment.projectId}`) }}
className="flex items-center justify-between p-4 rounded-lg border border-border/60 hover:border-brand-blue/30 hover:bg-brand-blue/5 transition-all cursor-pointer"
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium truncate">{assignment.project.title}</p> <p className="font-medium truncate">{assignment.project.title}</p>
@@ -97,12 +99,26 @@ export default function JuryRoundDetailPage() {
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-3 ml-4"> <div className="flex items-center gap-2 ml-4">
{isCompleted ? ( {isCompleted ? (
<Badge variant="default" className="bg-emerald-50 text-emerald-700 border-emerald-200"> <>
<CheckCircle2 className="mr-1 h-3 w-3" /> <Badge variant="default" className="bg-emerald-50 text-emerald-700 border-emerald-200">
Completed <CheckCircle2 className="mr-1 h-3 w-3" />
</Badge> Completed
</Badge>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
asChild
onClick={(e) => e.stopPropagation()}
>
<Link href={`/jury/competitions/${roundId}/projects/${assignment.projectId}/evaluate` as Route}>
<Eye className="mr-1 h-3 w-3" />
View
</Link>
</Button>
</>
) : isDraft ? ( ) : isDraft ? (
<Badge variant="secondary" className="bg-amber-50 text-amber-700 border-amber-200"> <Badge variant="secondary" className="bg-amber-50 text-amber-700 border-amber-200">
<Clock className="mr-1 h-3 w-3" /> <Clock className="mr-1 h-3 w-3" />
@@ -115,7 +131,7 @@ export default function JuryRoundDetailPage() {
</Badge> </Badge>
)} )}
</div> </div>
</Link> </div>
) )
})} })}
</div> </div>

View File

@@ -16,7 +16,7 @@ import { cn } from '@/lib/utils'
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer' import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { COIDeclarationDialog } from '@/components/forms/coi-declaration-dialog' import { COIDeclarationDialog } from '@/components/forms/coi-declaration-dialog'
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert } from 'lucide-react' import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert, Lock } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { EvaluationConfig } from '@/types/competition-configs' import type { EvaluationConfig } from '@/types/competition-configs'
@@ -468,8 +468,10 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
// Check if round is active // Check if round is active
const isRoundActive = round.status === 'ROUND_ACTIVE' const isRoundActive = round.status === 'ROUND_ACTIVE'
const isSubmittedEvaluation = existingEvaluation?.status === 'SUBMITTED'
if (!isRoundActive) { // If round is not active and no submitted evaluation to view, block access
if (!isRoundActive && !isSubmittedEvaluation) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -502,8 +504,11 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
) )
} }
// COI gate: if COI is required, not yet declared, and we have an assignment // Read-only view for submitted evaluations in closed rounds
if (coiRequired && myAssignment && !coiLoading && !coiDeclared) { const isReadOnly = !isRoundActive && isSubmittedEvaluation
// COI gate: if COI is required, not yet declared, and we have an assignment (skip for read-only views)
if (coiRequired && !isReadOnly && myAssignment && !coiLoading && !coiDeclared) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -533,8 +538,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
) )
} }
// COI conflict declared — block evaluation // COI conflict declared — block evaluation (skip for read-only views)
if (coiRequired && coiConflict) { if (coiRequired && !isReadOnly && coiConflict) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -578,15 +583,22 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild> {isReadOnly ? (
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}> <Button variant="ghost" size="sm" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Back to Project Back
</Link> </Button>
</Button> ) : (
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
)}
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground"> <h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
Evaluate Project {isReadOnly ? 'Submitted Evaluation' : 'Evaluate Project'}
</h1> </h1>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<p className="text-muted-foreground">{project.title}</p> <p className="text-muted-foreground">{project.title}</p>
@@ -606,21 +618,37 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</div> </div>
</div> </div>
{isReadOnly && (
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50 dark:bg-blue-950/20">
<CardContent className="flex items-start gap-3 p-4">
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-sm">View-Only</p>
<p className="text-sm text-muted-foreground mt-1">
This evaluation has been submitted and the round is now closed. You are viewing a read-only copy of your submission.
</p>
</div>
</CardContent>
</Card>
)}
{/* Project Documents */} {/* Project Documents */}
<MultiWindowDocViewer roundId={roundId} projectId={projectId} /> <MultiWindowDocViewer roundId={roundId} projectId={projectId} />
<Card className="border-l-4 border-l-amber-500"> {!isReadOnly && (
<CardContent className="flex items-start gap-3 p-4"> <Card className="border-l-4 border-l-amber-500">
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" /> <CardContent className="flex items-start gap-3 p-4">
<div className="flex-1"> <AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
<p className="font-medium text-sm">Important Reminder</p> <div className="flex-1">
<p className="text-sm text-muted-foreground mt-1"> <p className="font-medium text-sm">Important Reminder</p>
Your evaluation will be used to assess this project. Please provide thoughtful and <p className="text-sm text-muted-foreground mt-1">
constructive feedback. Your progress is automatically saved as a draft. Your evaluation will be used to assess this project. Please provide thoughtful and
</p> constructive feedback. Your progress is automatically saved as a draft.
</div> </p>
</CardContent> </div>
</Card> </CardContent>
</Card>
)}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -673,12 +701,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
type="button" type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, true)} onClick={() => handleCriterionChange(criterion.id, true)}
className={cn( className={cn(
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all', 'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
currentValue === true currentValue === true
? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200' ? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200'
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50' : 'border-border hover:border-emerald-300 hover:bg-emerald-50/50',
isReadOnly && 'opacity-60 cursor-default'
)} )}
> >
<ThumbsUp className="mr-2 h-5 w-5" /> <ThumbsUp className="mr-2 h-5 w-5" />
@@ -686,12 +716,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</button> </button>
<button <button
type="button" type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, false)} onClick={() => handleCriterionChange(criterion.id, false)}
className={cn( className={cn(
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all', 'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
currentValue === false currentValue === false
? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200' ? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200'
: 'border-border hover:border-red-300 hover:bg-red-50/50' : 'border-border hover:border-red-300 hover:bg-red-50/50',
isReadOnly && 'opacity-60 cursor-default'
)} )}
> >
<ThumbsDown className="mr-2 h-5 w-5" /> <ThumbsDown className="mr-2 h-5 w-5" />
@@ -718,12 +750,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
type="button" type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, true)} onClick={() => handleCriterionChange(criterion.id, true)}
className={cn( className={cn(
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all', 'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
currentValue === true currentValue === true
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400' ? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50' : 'border-border hover:border-emerald-300 hover:bg-emerald-50/50',
isReadOnly && 'opacity-60 cursor-default'
)} )}
> >
<ThumbsUp className="mr-2 h-4 w-4" /> <ThumbsUp className="mr-2 h-4 w-4" />
@@ -731,12 +765,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</button> </button>
<button <button
type="button" type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, false)} onClick={() => handleCriterionChange(criterion.id, false)}
className={cn( className={cn(
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all', 'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
currentValue === false currentValue === false
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400' ? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
: 'border-border hover:border-red-300 hover:bg-red-50/50' : 'border-border hover:border-red-300 hover:bg-red-50/50',
isReadOnly && 'opacity-60 cursor-default'
)} )}
> >
<ThumbsDown className="mr-2 h-4 w-4" /> <ThumbsDown className="mr-2 h-4 w-4" />
@@ -766,6 +802,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
placeholder={criterion.placeholder || 'Enter your response...'} placeholder={criterion.placeholder || 'Enter your response...'}
rows={4} rows={4}
maxLength={criterion.maxLength} maxLength={criterion.maxLength}
disabled={isReadOnly}
/> />
<p className="text-xs text-muted-foreground text-right"> <p className="text-xs text-muted-foreground text-right">
{currentValue.length}/{criterion.maxLength} {currentValue.length}/{criterion.maxLength}
@@ -807,6 +844,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
value={[sliderValue]} value={[sliderValue]}
onValueChange={(v) => handleCriterionChange(criterion.id, v[0])} onValueChange={(v) => handleCriterionChange(criterion.id, v[0])}
className="flex-1" className="flex-1"
disabled={isReadOnly}
/> />
<span className="text-xs text-muted-foreground w-4">{max}</span> <span className="text-xs text-muted-foreground w-4">{max}</span>
</div> </div>
@@ -816,6 +854,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<button <button
key={num} key={num}
type="button" type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, num)} onClick={() => handleCriterionChange(criterion.id, num)}
className={cn( className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors', 'w-9 h-9 rounded-md text-sm font-medium transition-colors',
@@ -823,7 +862,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: displayValue !== undefined && displayValue > num : displayValue !== undefined && displayValue > num
? 'bg-primary/20 text-primary' ? 'bg-primary/20 text-primary'
: 'bg-muted hover:bg-muted/80' : 'bg-muted hover:bg-muted/80',
isReadOnly && 'cursor-default'
)} )}
> >
{num} {num}
@@ -856,6 +896,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
value={[globalScore ? parseInt(globalScore, 10) : 5]} value={[globalScore ? parseInt(globalScore, 10) : 5]}
onValueChange={(v) => handleGlobalScoreChange(v[0].toString())} onValueChange={(v) => handleGlobalScoreChange(v[0].toString())}
className="flex-1" className="flex-1"
disabled={isReadOnly}
/> />
<span className="text-xs text-muted-foreground">10</span> <span className="text-xs text-muted-foreground">10</span>
</div> </div>
@@ -866,6 +907,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<button <button
key={num} key={num}
type="button" type="button"
disabled={isReadOnly}
onClick={() => handleGlobalScoreChange(num.toString())} onClick={() => handleGlobalScoreChange(num.toString())}
className={cn( className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors', 'w-9 h-9 rounded-md text-sm font-medium transition-colors',
@@ -873,7 +915,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: current > num : current > num
? 'bg-primary/20 text-primary' ? 'bg-primary/20 text-primary'
: 'bg-muted hover:bg-muted/80' : 'bg-muted hover:bg-muted/80',
isReadOnly && 'cursor-default'
)} )}
> >
{num} {num}
@@ -890,7 +933,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<Label> <Label>
Decision <span className="text-destructive">*</span> Decision <span className="text-destructive">*</span>
</Label> </Label>
<RadioGroup value={binaryDecision} onValueChange={(v) => handleBinaryChange(v as 'accept' | 'reject')}> <RadioGroup value={binaryDecision} onValueChange={(v) => handleBinaryChange(v as 'accept' | 'reject')} disabled={isReadOnly}>
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50"> <div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50">
<RadioGroupItem value="accept" id="accept" /> <RadioGroupItem value="accept" id="accept" />
<Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1"> <Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1">
@@ -921,6 +964,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
onChange={(e) => handleFeedbackChange(e.target.value)} onChange={(e) => handleFeedbackChange(e.target.value)}
placeholder="Provide your feedback on the project..." placeholder="Provide your feedback on the project..."
rows={8} rows={8}
disabled={isReadOnly}
/> />
{requireFeedback && ( {requireFeedback && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -931,32 +975,44 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</CardContent> </CardContent>
</Card> </Card>
<div className="flex items-center justify-between flex-wrap gap-4"> {isReadOnly ? (
<Button <div className="flex items-center">
variant="outline"
onClick={() => router.push(`/jury/competitions/${roundId}/projects/${projectId}` as Route)}
>
Cancel
</Button>
<div className="flex gap-3">
<Button <Button
variant="outline" variant="outline"
onClick={handleSaveDraft} onClick={() => router.back()}
disabled={autosaveMutation.isPending || submitMutation.isPending}
> >
<Save className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
{autosaveMutation.isPending ? 'Saving...' : 'Save Draft'} Back
</Button>
<Button
onClick={handleSubmit}
disabled={submitMutation.isPending || isSubmitting}
className="bg-brand-blue hover:bg-brand-blue-light"
>
<Send className="mr-2 h-4 w-4" />
{submitMutation.isPending ? 'Submitting...' : 'Submit Evaluation'}
</Button> </Button>
</div> </div>
</div> ) : (
<div className="flex items-center justify-between flex-wrap gap-4">
<Button
variant="outline"
onClick={() => router.push(`/jury/competitions/${roundId}/projects/${projectId}` as Route)}
>
Cancel
</Button>
<div className="flex gap-3">
<Button
variant="outline"
onClick={handleSaveDraft}
disabled={autosaveMutation.isPending || submitMutation.isPending}
>
<Save className="mr-2 h-4 w-4" />
{autosaveMutation.isPending ? 'Saving...' : 'Save Draft'}
</Button>
<Button
onClick={handleSubmit}
disabled={submitMutation.isPending || isSubmitting}
className="bg-brand-blue hover:bg-brand-blue-light"
>
<Send className="mr-2 h-4 w-4" />
{submitMutation.isPending ? 'Submitting...' : 'Submit Evaluation'}
</Button>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useParams } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
@@ -10,9 +10,11 @@ import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer' import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
import { ArrowLeft, FileText, Users, MapPin, Target, Tag } from 'lucide-react' import { ArrowLeft, FileText, Users, MapPin, Target, Tag } from 'lucide-react'
import { CountryDisplay } from '@/components/shared/country-display'
export default function JuryProjectDetailPage() { export default function JuryProjectDetailPage() {
const params = useParams() const params = useParams()
const router = useRouter()
const roundId = params.roundId as string const roundId = params.roundId as string
const projectId = params.projectId as string const projectId = params.projectId as string
@@ -42,11 +44,9 @@ export default function JuryProjectDetailPage() {
if (!project) { if (!project) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" onClick={() => router.back()}>
<Link href={`/jury/competitions/${roundId}` as Route}> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back
</Link>
</Button> </Button>
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
@@ -61,11 +61,9 @@ export default function JuryProjectDetailPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" onClick={() => router.back()}>
<Link href={`/jury/competitions/${roundId}` as Route}> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back
</Link>
</Button> </Button>
</div> </div>
@@ -110,7 +108,7 @@ export default function JuryProjectDetailPage() {
{project.country && ( {project.country && (
<Badge variant="outline" className="gap-1"> <Badge variant="outline" className="gap-1">
<MapPin className="h-3 w-3" /> <MapPin className="h-3 w-3" />
{project.country} <CountryDisplay country={project.country} />
</Badge> </Badge>
)} )}
</div> </div>

View File

@@ -2,6 +2,7 @@
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -18,8 +19,10 @@ import {
FileEdit, FileEdit,
} from 'lucide-react' } from 'lucide-react'
import { formatDateOnly, formatEnumLabel } from '@/lib/utils' import { formatDateOnly, formatEnumLabel } from '@/lib/utils'
import { CountryDisplay } from '@/components/shared/country-display'
export default function JuryAssignmentsPage() { export default function JuryAssignmentsPage() {
const router = useRouter()
const { data: assignments, isLoading } = trpc.assignment.myAssignments.useQuery({}) const { data: assignments, isLoading } = trpc.assignment.myAssignments.useQuery({})
if (isLoading) { if (isLoading) {
@@ -58,11 +61,9 @@ export default function JuryAssignmentsPage() {
Projects assigned to you for evaluation Projects assigned to you for evaluation
</p> </p>
</div> </div>
<Button variant="ghost" size="sm" asChild className="hidden md:inline-flex"> <Button variant="ghost" size="sm" onClick={() => router.back()} className="hidden md:inline-flex">
<Link href={'/jury' as Route}> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Dashboard
</Link>
</Button> </Button>
</div> </div>
@@ -135,7 +136,7 @@ export default function JuryAssignmentsPage() {
{project.title} {project.title}
</p> </p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{[project.teamName, project.country].filter(Boolean).join(' \u00b7 ')} {project.teamName}{project.teamName && project.country ? ' · ' : ''}{project.country ? <CountryDisplay country={project.country} /> : ''}
</p> </p>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">

View File

@@ -1,8 +1,7 @@
'use client' 'use client'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useParams } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -27,6 +26,7 @@ const ResourceRenderer = dynamic(
export default function JuryResourceDetailPage() { export default function JuryResourceDetailPage() {
const params = useParams() const params = useParams()
const router = useRouter()
const resourceId = params.id as string const resourceId = params.id as string
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId }) const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
@@ -73,11 +73,9 @@ export default function JuryResourceDetailPage() {
This resource may have been removed or you don&apos;t have access. This resource may have been removed or you don&apos;t have access.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Button asChild> <Button onClick={() => router.back()}>
<Link href="/jury/learning"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Learning Hub
</Link>
</Button> </Button>
</div> </div>
) )
@@ -87,11 +85,9 @@ export default function JuryResourceDetailPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" onClick={() => router.back()} className="-ml-4">
<Link href="/jury/learning"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Learning Hub
</Link>
</Button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{resource.externalUrl && ( {resource.externalUrl && (

View File

@@ -623,7 +623,7 @@ async function JuryDashboardContent() {
<div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20"> <div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20">
<BarChart3 className="h-4 w-4 text-brand-teal" /> <BarChart3 className="h-4 w-4 text-brand-teal" />
</div> </div>
<CardTitle className="text-lg">Stage Summary</CardTitle> <CardTitle className="text-lg">Round Summary</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">

View File

@@ -11,20 +11,22 @@ export default async function JuryLayout({
children: React.ReactNode children: React.ReactNode
}) { }) {
const session = await requireRole('JURY_MEMBER') const session = await requireRole('JURY_MEMBER')
const isImpersonating = !!session.user.impersonating
// Check if user has completed onboarding // Check if user has completed onboarding (skip during impersonation)
const user = await prisma.user.findUnique({ if (!isImpersonating) {
where: { id: session.user.id }, const user = await prisma.user.findUnique({
select: { onboardingCompletedAt: true }, where: { id: session.user.id },
}) select: { onboardingCompletedAt: true },
})
if (!user) { if (!user) {
// User was deleted — session is stale, send to login redirect('/login')
redirect('/login') }
}
if (!user.onboardingCompletedAt) { if (!user.onboardingCompletedAt) {
redirect('/onboarding') redirect('/onboarding')
}
} }
return ( return (

View File

@@ -12,16 +12,16 @@ export default async function MentorLayout({
}) { }) {
const session = await requireRole('MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN') const session = await requireRole('MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
// Check if user has completed onboarding (for mentors) // Check if user has completed onboarding (for mentors, skip during impersonation)
const isImpersonating = !!session.user.impersonating
const userRoles = session.user.roles?.length ? session.user.roles : [session.user.role] const userRoles = session.user.roles?.length ? session.user.roles : [session.user.role]
if (userRoles.includes('MENTOR') && !userRoles.some(r => r === 'SUPER_ADMIN' || r === 'PROGRAM_ADMIN')) { if (!isImpersonating && userRoles.includes('MENTOR') && !userRoles.some(r => r === 'SUPER_ADMIN' || r === 'PROGRAM_ADMIN')) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: session.user.id }, where: { id: session.user.id },
select: { onboardingCompletedAt: true }, select: { onboardingCompletedAt: true },
}) })
if (!user) { if (!user) {
// User was deleted — session is stale, send to login
redirect('/login') redirect('/login')
} }

View File

@@ -40,6 +40,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { formatDateOnly } from '@/lib/utils' import { formatDateOnly } from '@/lib/utils'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { CountryDisplay } from '@/components/shared/country-display'
// Status badge colors // Status badge colors
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = { const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
@@ -336,7 +337,7 @@ export default function MentorDashboard() {
{project.country && ( {project.country && (
<Badge variant="outline" className="gap-1"> <Badge variant="outline" className="gap-1">
<MapPin className="h-3 w-3" /> <MapPin className="h-3 w-3" />
{project.country} <CountryDisplay country={project.country} />
</Badge> </Badge>
)} )}
</div> </div>

View File

@@ -1,8 +1,7 @@
'use client' 'use client'
import { Suspense, use, useState, useEffect } from 'react' import { Suspense, use, useState, useEffect } from 'react'
import Link from 'next/link' import { useRouter } from 'next/navigation'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { import {
Card, Card,
@@ -58,6 +57,7 @@ import {
EyeOff, EyeOff,
} from 'lucide-react' } from 'lucide-react'
import { formatDateOnly, getInitials } from '@/lib/utils' import { formatDateOnly, getInitials } from '@/lib/utils'
import { CountryDisplay } from '@/components/shared/country-display'
import { toast } from 'sonner' import { toast } from 'sonner'
interface PageProps { interface PageProps {
@@ -75,6 +75,7 @@ const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'ou
} }
function ProjectDetailContent({ projectId }: { projectId: string }) { function ProjectDetailContent({ projectId }: { projectId: string }) {
const router = useRouter()
const { data: project, isLoading, error } = trpc.mentor.getProjectDetail.useQuery({ const { data: project, isLoading, error } = trpc.mentor.getProjectDetail.useQuery({
projectId, projectId,
}) })
@@ -106,11 +107,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
if (error || !project) { if (error || !project) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href={'/mentor' as Route}> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Dashboard
</Link>
</Button> </Button>
<Card> <Card>
@@ -122,8 +121,8 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
You may not have access to view this project. You may not have access to view this project.
</p> </p>
<Button asChild className="mt-4"> <Button className="mt-4" onClick={() => router.back()}>
<Link href={'/mentor' as Route}>Back to Dashboard</Link> Back
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -140,11 +139,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href={'/mentor' as Route}> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Dashboard
</Link>
</Button> </Button>
</div> </div>
@@ -255,7 +252,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<div> <div>
<p className="text-sm font-medium text-muted-foreground">Location</p> <p className="text-sm font-medium text-muted-foreground">Location</p>
<p className="text-sm"> <p className="text-sm">
{[project.geographicZone, project.country].filter(Boolean).join(', ')} {project.geographicZone}{project.geographicZone && project.country ? ', ' : ''}{project.country ? <CountryDisplay country={project.country} /> : null}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -23,6 +23,7 @@ import {
Crown, Crown,
} from 'lucide-react' } from 'lucide-react'
import { formatDateOnly } from '@/lib/utils' import { formatDateOnly } from '@/lib/utils'
import { CountryDisplay } from '@/components/shared/country-display'
// Status badge colors // Status badge colors
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = { const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
@@ -143,7 +144,7 @@ export default function MentorProjectsPage() {
{project.country && ( {project.country && (
<Badge variant="outline" className="gap-1"> <Badge variant="outline" className="gap-1">
<MapPin className="h-3 w-3" /> <MapPin className="h-3 w-3" />
{project.country} <CountryDisplay country={project.country} />
</Badge> </Badge>
)} )}
</div> </div>

View File

@@ -1,8 +1,7 @@
'use client' 'use client'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useParams } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -27,6 +26,7 @@ const ResourceRenderer = dynamic(
export default function MentorResourceDetailPage() { export default function MentorResourceDetailPage() {
const params = useParams() const params = useParams()
const router = useRouter()
const resourceId = params.id as string const resourceId = params.id as string
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId }) const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
@@ -73,11 +73,9 @@ export default function MentorResourceDetailPage() {
This resource may have been removed or you don&apos;t have access. This resource may have been removed or you don&apos;t have access.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Button asChild> <Button onClick={() => router.back()}>
<Link href="/mentor/resources"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Resources
</Link>
</Button> </Button>
</div> </div>
) )
@@ -87,11 +85,9 @@ export default function MentorResourceDetailPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href="/mentor/resources"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Resources
</Link>
</Button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{resource.externalUrl && ( {resource.externalUrl && (

View File

@@ -1,8 +1,6 @@
'use client' 'use client'
import { useParams } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -16,6 +14,7 @@ import { toast } from 'sonner'
export default function MentorWorkspaceDetailPage() { export default function MentorWorkspaceDetailPage() {
const params = useParams() const params = useParams()
const router = useRouter()
const projectId = params.projectId as string const projectId = params.projectId as string
// Get mentor assignment for this project // Get mentor assignment for this project
@@ -39,11 +38,9 @@ export default function MentorWorkspaceDetailPage() {
if (!project) { if (!project) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" onClick={() => router.back()}>
<Link href={'/mentor/workspace' as Route}> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back
</Link>
</Button> </Button>
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
@@ -58,11 +55,9 @@ export default function MentorWorkspaceDetailPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" onClick={() => router.back()}>
<Link href={'/mentor/workspace' as Route}> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back
</Link>
</Button> </Button>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">

View File

@@ -2,6 +2,7 @@
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -20,6 +21,7 @@ const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'ou
} }
export default function MentorWorkspacePage() { export default function MentorWorkspacePage() {
const router = useRouter()
const { data: assignments, isLoading } = trpc.mentor.getMyProjects.useQuery() const { data: assignments, isLoading } = trpc.mentor.getMyProjects.useQuery()
if (isLoading) { if (isLoading) {
@@ -46,11 +48,9 @@ export default function MentorWorkspacePage() {
Collaborate with your assigned mentee projects Collaborate with your assigned mentee projects
</p> </p>
</div> </div>
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" onClick={() => router.back()}>
<Link href={'/mentor' as Route}> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Dashboard
</Link>
</Button> </Button>
</div> </div>

View File

@@ -1,13 +1,34 @@
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import { requireRole } from '@/lib/auth-redirect' import { requireRole } from '@/lib/auth-redirect'
import { ObserverNav } from '@/components/layouts/observer-nav' import { ObserverNav } from '@/components/layouts/observer-nav'
import { EditionProvider } from '@/components/observer/observer-edition-context' import { EditionProvider } from '@/components/observer/observer-edition-context'
export const dynamic = 'force-dynamic'
export default async function ObserverLayout({ export default async function ObserverLayout({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const session = await requireRole('OBSERVER') const session = await requireRole('OBSERVER')
const isImpersonating = !!session.user.impersonating
// Check if user has completed onboarding (skip during impersonation)
if (!isImpersonating) {
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { onboardingCompletedAt: true },
})
if (!user) {
redirect('/login')
}
if (!user.onboardingCompletedAt) {
redirect('/onboarding')
}
}
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useParams } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
@@ -67,6 +67,7 @@ const fileTypeLabels: Record<string, string> = {
export function SubmissionDetailClient() { export function SubmissionDetailClient() {
const params = useParams() const params = useParams()
const router = useRouter()
const { data: session } = useSession() const { data: session } = useSession()
const projectId = params.id as string const projectId = params.id as string
const [activeTab, setActiveTab] = useState('details') const [activeTab, setActiveTab] = useState('details')
@@ -116,11 +117,9 @@ export function SubmissionDetailClient() {
{error?.message || 'Submission not found'} {error?.message || 'Submission not found'}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Button asChild className="mt-4"> <Button className="mt-4" onClick={() => router.back()}>
<Link href="/my-submission"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to My Submissions
</Link>
</Button> </Button>
</div> </div>
) )
@@ -133,11 +132,9 @@ export function SubmissionDetailClient() {
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Link href="/my-submission"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to My Submissions
</Link>
</Button> </Button>
</div> </div>

View File

@@ -203,10 +203,8 @@ export default function TeamManagementPage() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild> <Button variant="ghost" size="icon" onClick={() => router.back()}>
<Link href={`/my-submission/${projectId}`}> <ArrowLeft className="h-5 w-5" />
<ArrowLeft className="h-5 w-5" />
</Link>
</Button> </Button>
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2"> <h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">

View File

@@ -94,10 +94,10 @@ export default function ProfileSettingsPage() {
setExpertiseTags(user.expertiseTags || []) setExpertiseTags(user.expertiseTags || [])
setDigestFrequency(user.digestFrequency || 'none') setDigestFrequency(user.digestFrequency || 'none')
setPreferredWorkload(user.preferredWorkload ?? null) setPreferredWorkload(user.preferredWorkload ?? null)
const avail = user.availabilityJson as { startDate?: string; endDate?: string } | null const avail = user.availabilityJson as Array<{ start?: string; end?: string }> | null
if (avail) { if (avail && avail.length > 0) {
setAvailabilityStart(avail.startDate || '') setAvailabilityStart(avail[0].start || '')
setAvailabilityEnd(avail.endDate || '') setAvailabilityEnd(avail[0].end || '')
} }
setProfileLoaded(true) setProfileLoaded(true)
} }
@@ -114,10 +114,10 @@ export default function ProfileSettingsPage() {
expertiseTags, expertiseTags,
digestFrequency: digestFrequency as 'none' | 'daily' | 'weekly', digestFrequency: digestFrequency as 'none' | 'daily' | 'weekly',
preferredWorkload: preferredWorkload ?? undefined, preferredWorkload: preferredWorkload ?? undefined,
availabilityJson: (availabilityStart || availabilityEnd) ? { availabilityJson: (availabilityStart || availabilityEnd) ? [{
startDate: availabilityStart || undefined, start: availabilityStart || '',
endDate: availabilityEnd || undefined, end: availabilityEnd || '',
} : undefined, }] : undefined,
}) })
toast.success('Profile updated successfully') toast.success('Profile updated successfully')
refetch() refetch()

View File

@@ -4,6 +4,9 @@ import { checkRateLimit } from '@/lib/rate-limit'
const AUTH_RATE_LIMIT = 10 // requests per window const AUTH_RATE_LIMIT = 10 // requests per window
const AUTH_RATE_WINDOW_MS = 60 * 1000 // 1 minute const AUTH_RATE_WINDOW_MS = 60 * 1000 // 1 minute
const CSRF_RATE_LIMIT = 20 // requests per window
const CSRF_RATE_WINDOW_MS = 15 * 60 * 1000 // 15 minutes
function getClientIp(req: Request): string { function getClientIp(req: Request): string {
return ( return (
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
@@ -12,15 +15,35 @@ function getClientIp(req: Request): string {
) )
} }
function withRateLimit(handler: (req: Request) => Promise<Response>) { function withPostRateLimit(handler: (req: Request) => Promise<Response>) {
return async (req: Request) => { return async (req: Request) => {
// Only rate limit POST requests (sign-in, magic link sends) const ip = getClientIp(req)
if (req.method === 'POST') { const { success, resetAt } = checkRateLimit(`auth:${ip}`, AUTH_RATE_LIMIT, AUTH_RATE_WINDOW_MS)
if (!success) {
return new Response(JSON.stringify({ error: 'Too many authentication attempts' }), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(Math.ceil((resetAt - Date.now()) / 1000)),
},
})
}
return handler(req)
}
}
function withGetRateLimit(handler: (req: Request) => Promise<Response>) {
return async (req: Request) => {
// Rate-limit the CSRF token endpoint to prevent token farming
const url = new URL(req.url)
if (url.pathname.endsWith('/csrf')) {
const ip = getClientIp(req) const ip = getClientIp(req)
const { success, resetAt } = checkRateLimit(`auth:${ip}`, AUTH_RATE_LIMIT, AUTH_RATE_WINDOW_MS) const { success, resetAt } = checkRateLimit(`csrf:${ip}`, CSRF_RATE_LIMIT, CSRF_RATE_WINDOW_MS)
if (!success) { if (!success) {
return new Response(JSON.stringify({ error: 'Too many authentication attempts' }), { return new Response(JSON.stringify({ error: 'Too many requests' }), {
status: 429, status: 429,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -34,5 +57,5 @@ function withRateLimit(handler: (req: Request) => Promise<Response>) {
} }
} }
export const GET = handlers.GET export const GET = withGetRateLimit(handlers.GET as (req: Request) => Promise<Response>)
export const POST = withRateLimit(handlers.POST as (req: Request) => Promise<Response>) export const POST = withPostRateLimit(handlers.POST as (req: Request) => Promise<Response>)

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { checkRateLimit } from '@/lib/rate-limit'
/**
* Pre-check whether an email exists before sending a magic link.
* This is a closed platform (no self-registration) so revealing
* email existence is acceptable and helps users who mistype.
* Rate-limited to 10 requests per 15 minutes per IP.
*/
export async function POST(req: NextRequest) {
try {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
const rateResult = checkRateLimit(`check-email:${ip}`, 10, 15 * 60 * 1000)
if (!rateResult.success) {
return NextResponse.json(
{ exists: false, error: 'Too many requests' },
{ status: 429 },
)
}
const { email } = await req.json()
if (!email || typeof email !== 'string') {
return NextResponse.json({ exists: false }, { status: 400 })
}
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase().trim() },
select: { status: true },
})
const exists = !!user && user.status !== 'SUSPENDED'
return NextResponse.json({ exists })
} catch {
return NextResponse.json({ exists: false }, { status: 500 })
}
}

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { processRoundClose } from '@/server/services/round-finalization'
export async function GET(request: NextRequest): Promise<NextResponse> {
const cronSecret = request.headers.get('x-cron-secret')
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const now = new Date()
// Find rounds with expired grace periods that haven't been finalized
const expiredRounds = await prisma.round.findMany({
where: {
status: 'ROUND_CLOSED',
gracePeriodEndsAt: { lt: now },
finalizedAt: null,
},
select: { id: true, name: true },
})
const results: Array<{ roundId: string; roundName: string; processed: number }> = []
for (const round of expiredRounds) {
try {
const result = await processRoundClose(round.id, 'system-cron', prisma)
results.push({ roundId: round.id, roundName: round.name, processed: result.processed })
} catch (err) {
console.error(`[Cron] processRoundClose failed for round ${round.id}:`, err)
}
}
return NextResponse.json({
ok: true,
processedRounds: results.length,
results,
timestamp: now.toISOString(),
})
} catch (error) {
console.error('Cron grace period processing failed:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,9 +1,19 @@
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { auth } from '@/lib/auth'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest): Promise<Response> { export async function GET(request: NextRequest): Promise<Response> {
// Require authentication — prevent unauthenticated access to live vote data
const userSession = await auth()
if (!userSession?.user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const sessionId = searchParams.get('sessionId') const sessionId = searchParams.get('sessionId')

View File

@@ -1,7 +1,10 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import Script from "next/script";
import './globals.css' import './globals.css'
import { Providers } from './providers' import { Providers } from './providers'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
import { VersionGuard } from '@/components/shared/version-guard'
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
@@ -20,9 +23,28 @@ export default function RootLayout({
children: React.ReactNode children: React.ReactNode
}>) { }>) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<head>
{process.env.NODE_ENV === "development" && (
<Script
src="//unpkg.com/react-grab/dist/index.global.js"
crossOrigin="anonymous"
strategy="beforeInteractive"
/>
)}
{process.env.NODE_ENV === "development" && (
<Script
src="//unpkg.com/@react-grab/mcp/dist/client.global.js"
strategy="lazyOnload"
/>
)}
</head>
<body className="min-h-screen bg-background font-sans antialiased"> <body className="min-h-screen bg-background font-sans antialiased">
<Providers>{children}</Providers> <Providers>
<VersionGuard />
<ImpersonationBanner />
{children}
</Providers>
<Toaster <Toaster
position="top-right" position="top-right"
toastOptions={{ toastOptions={{

Some files were not shown because too many files have changed in this diff Show More