Compare commits

..

50 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
186 changed files with 14947 additions and 8160 deletions

1
.gitignore vendored
View File

@@ -61,3 +61,4 @@ build-output.txt
# Private keys and secrets
private/
public/build-id.json

View File

@@ -23,9 +23,9 @@ COPY . .
# Generate Prisma client
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
RUN npm run build
RUN --mount=type=cache,target=/app/.next/cache npm run build
# Production image, copy all the files and run next
FROM base AS runner
@@ -69,5 +69,8 @@ EXPOSE 7600
ENV PORT=7600
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)
CMD ["/app/docker-entrypoint.sh"]

View File

@@ -68,7 +68,7 @@ services:
env_file:
- ../.env
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_SECRET=${NEXTAUTH_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
environment:
- 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_SECRET=${NEXTAUTH_SECRET}
- AUTH_SECRET=${NEXTAUTH_SECRET}

View File

@@ -59,4 +59,18 @@ else
fi
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 = {
output: 'standalone',
typedRoutes: true,
serverExternalPackages: ['@prisma/client', 'minio'],
typescript: {
ignoreBuildErrors: false,
},
experimental: {
optimizePackageImports: ['lucide-react'],
optimizePackageImports: [
'lucide-react',
'sonner',
'date-fns',
'recharts',
'motion/react',
'zod',
'@radix-ui/react-icons',
],
},
images: {
remotePatterns: [

1995
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

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 {
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")
}
@@ -130,13 +134,6 @@ enum PartnerType {
OTHER
}
enum OverrideReasonCode {
DATA_CORRECTION
POLICY_EXCEPTION
JURY_CONFLICT
SPONSOR_DECISION
ADMIN_DISCRETION
}
// =============================================================================
// COMPETITION / ROUND ENGINE ENUMS
@@ -175,13 +172,6 @@ enum ProjectRoundStateValue {
WITHDRAWN
}
enum AdvancementRuleType {
AUTO_ADVANCE
SCORE_THRESHOLD
TOP_N
ADMIN_SELECTION
AI_RECOMMENDED
}
enum CapMode {
HARD
@@ -302,7 +292,7 @@ model User {
email String @unique
name String?
emailVerified DateTime? // Required by NextAuth Prisma adapter
role UserRole @default(JURY_MEMBER)
role UserRole @default(APPLICANT)
roles UserRole[] @default([])
status UserStatus @default(INVITED)
expertiseTags String[] @default([])
@@ -335,6 +325,10 @@ model User {
inviteToken String? @unique
inviteTokenExpiresAt DateTime?
// Password reset token
passwordResetToken String? @unique
passwordResetExpiresAt DateTime?
// Digest & availability preferences
digestFrequency String @default("none") // 'none' | 'daily' | 'weekly'
preferredWorkload Int?
@@ -423,7 +417,6 @@ model User {
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
resultLocksCreated ResultLock[] @relation("ResultLockCreator")
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
assignmentExceptionsApproved AssignmentException[] @relation("AssignmentExceptionApprover")
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
@@ -555,7 +548,6 @@ model EvaluationForm {
model Project {
id String @id @default(cuid())
programId String
roundId String?
status ProjectStatus @default(SUBMITTED)
// Core fields
@@ -755,7 +747,6 @@ model Assignment {
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
evaluation Evaluation?
conflictOfInterest ConflictOfInterest?
exceptions AssignmentException[]
@@unique([userId, projectId, roundId])
@@index([roundId])
@@ -764,6 +755,7 @@ model Assignment {
@@index([isCompleted])
@@index([projectId, userId])
@@index([juryGroupId])
@@index([roundId, isCompleted])
}
model Evaluation {
@@ -781,11 +773,6 @@ model Evaluation {
binaryDecision Boolean? // Yes/No for semi-finalist
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
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -960,6 +947,7 @@ model NotificationLog {
@@index([projectId])
@@index([batchId])
@@index([email])
@@index([type, status])
}
// =============================================================================
@@ -1490,6 +1478,7 @@ model RankingSnapshot {
@@index([roundId])
@@index([triggeredById])
@@index([createdAt])
@@index([roundId, createdAt])
}
// Tracks progress of long-running AI tagging jobs
@@ -1718,7 +1707,6 @@ model ConflictOfInterest {
assignmentId String @unique
userId String
projectId String
roundId String? // Legacy — kept for historical data
hasConflict Boolean @default(false)
conflictType String? // "financial", "personal", "organizational", "other"
description String? @db.Text
@@ -1736,6 +1724,8 @@ model ConflictOfInterest {
@@index([userId])
@@index([hasConflict])
@@index([projectId])
@@index([userId, hasConflict])
}
// =============================================================================
@@ -2098,24 +2088,6 @@ model LiveProgressCursor {
@@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 {
id String @id @default(cuid())
eventType String // stage.transitioned, routing.executed, filtering.completed, etc.
@@ -2133,21 +2105,6 @@ model DecisionAuditLog {
@@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)
// =============================================================================
@@ -2223,7 +2180,6 @@ model Round {
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
projectRoundStates ProjectRoundState[]
advancementRules AdvancementRule[]
visibleSubmissionWindows RoundSubmissionVisibility[]
assignmentIntents AssignmentIntent[]
deliberationSessions DeliberationSession[]
@@ -2279,24 +2235,7 @@ model ProjectRoundState {
@@index([projectId])
@@index([roundId])
@@index([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])
@@index([roundId, state])
}
// =============================================================================
@@ -2475,22 +2414,6 @@ model AssignmentIntent {
@@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)
// =============================================================================

View File

@@ -15,7 +15,6 @@ import {
RoundStatus,
CapMode,
JuryGroupMemberRole,
AdvancementRuleType,
} from '@prisma/client'
import bcrypt from 'bcryptjs'
// 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 = [
{ 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: '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> = {}
for (const account of staffAccounts) {
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({
where: { email: account.email },
update: isSuperAdmin
update: needsPassword
? {
status: UserStatus.ACTIVE,
passwordHash,
@@ -348,11 +348,11 @@ async function main() {
name: account.name,
role: account.role,
roles: [account.role],
status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE,
passwordHash: isSuperAdmin ? passwordHash : null,
mustSetPassword: !isSuperAdmin,
passwordSetAt: isSuperAdmin ? new Date() : null,
onboardingCompletedAt: isSuperAdmin ? new Date() : null,
status: needsPassword ? UserStatus.ACTIVE : UserStatus.NONE,
passwordHash: needsPassword ? passwordHash : null,
mustSetPassword: !needsPassword,
passwordSetAt: needsPassword ? new Date() : null,
onboardingCompletedAt: needsPassword ? new Date() : null,
},
})
staffUsers[account.email] = user.id
@@ -857,24 +857,6 @@ async function main() {
}
console.log(`${rounds.length} rounds created (R1-R8)`)
// --- Advancement Rules (auto-advance between rounds) ---
for (let i = 0; i < rounds.length - 1; i++) {
await prisma.advancementRule.upsert({
where: {
roundId_sortOrder: { roundId: rounds[i].id, sortOrder: 0 },
},
update: {},
create: {
roundId: rounds[i].id,
ruleType: AdvancementRuleType.AUTO_ADVANCE,
sortOrder: 0,
targetRoundId: rounds[i + 1].id,
configJson: {},
},
})
}
console.log(`${rounds.length - 1} advancement rules created`)
// --- Assign all projects to intake round (COMPLETED, since intake is closed) ---
const intakeRound = rounds[0]
const allProjects = await prisma.project.findMany({
@@ -916,6 +898,28 @@ async function main() {
}
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 ---
await prisma.systemSettings.upsert({
where: { key: 'feature.useCompetitionModel' },

View File

@@ -56,6 +56,7 @@ import { Switch } from '@/components/ui/switch'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import Link from 'next/link'
// Action type options (manual audit actions + auto-generated mutation audit actions)
const ACTION_TYPES = [
@@ -77,6 +78,8 @@ const ACTION_TYPES = [
'ROUND_ARCHIVED',
'UPLOAD_FILE',
'DELETE_FILE',
'FILE_VIEWED',
'FILE_OPENED',
'FILE_DOWNLOADED',
'BULK_CREATE',
'BULK_UPDATE_STATUS',
@@ -124,6 +127,11 @@ const ACTION_TYPES = [
'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
@@ -171,6 +179,8 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
ROUND_ACTIVATED: 'default',
ROUND_CLOSED: 'secondary',
ROUND_ARCHIVED: 'secondary',
FILE_VIEWED: 'outline',
FILE_OPENED: 'outline',
FILE_DOWNLOADED: 'outline',
ROLE_CHANGED: 'secondary',
PASSWORD_SET: 'outline',
@@ -206,9 +216,34 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
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() {
// Filter state
const [filters, setFilters] = useState({
@@ -541,14 +576,24 @@ export default function AuditLogPage() {
{formatDate(log.timestamp)}
</TableCell>
<TableCell>
<div>
<p className="font-medium text-sm">
{log.userId ? (
<Link
href={`/admin/members/${log.userId}`}
className="group block"
onClick={(e) => e.stopPropagation()}
>
<p className="font-medium text-sm group-hover:text-primary group-hover:underline">
{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>
<Badge
@@ -560,11 +605,22 @@ export default function AuditLogPage() {
<TableCell>
<div>
<p className="text-sm">{log.entityType}</p>
{log.entityId && (
{log.entityId && (() => {
const link = getEntityLink(log.entityType, log.entityId)
return link ? (
<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>
</TableCell>
<TableCell className="font-mono text-xs">
@@ -587,9 +643,18 @@ export default function AuditLogPage() {
<p className="text-xs font-medium text-muted-foreground">
Entity ID
</p>
<p className="font-mono text-sm">
{log.entityId || 'N/A'}
</p>
{log.entityId ? (() => {
const link = getEntityLink(log.entityType, log.entityId)
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>
<p className="text-xs font-medium text-muted-foreground">
@@ -686,12 +751,23 @@ export default function AuditLogPage() {
{formatDate(log.timestamp)}
</span>
</div>
<div className="flex items-center gap-1 text-muted-foreground">
{log.userId ? (
<Link
href={`/admin/members/${log.userId}`}
className="flex items-center gap-1 text-muted-foreground hover:text-primary"
onClick={(e) => e.stopPropagation()}
>
<User className="h-3 w-3" />
<span className="text-xs">
<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>
{isExpanded && (

View File

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

View File

@@ -113,6 +113,7 @@ import {
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'> = {
DRAFT: 'secondary',
@@ -663,11 +664,9 @@ export default function AwardDetailPage({
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/awards">
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Awards
</Link>
Back
</Button>
</div>
@@ -1024,7 +1023,7 @@ export default function AwardDetailPage({
'-'
)}
</TableCell>
<TableCell className="text-sm">{project.country || '-'}</TableCell>
<TableCell className="text-sm">{project.country ? <CountryDisplay country={project.country} /> : '-'}</TableCell>
<TableCell className="text-right">
<Button
size="sm"
@@ -1190,7 +1189,7 @@ export default function AwardDetailPage({
'-'
)}
</TableCell>
<TableCell>{e.project.country || '-'}</TableCell>
<TableCell>{e.project.country ? <CountryDisplay country={e.project.country} /> : '-'}</TableCell>
<TableCell>
<Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs gap-1">
{e.method === 'MANUAL' ? 'Manual' : <><Bot className="h-3 w-3" />AI Assessed</>}
@@ -1337,7 +1336,7 @@ export default function AwardDetailPage({
<TableRow key={j.id}>
<TableCell>
<div className="flex items-center gap-3">
<UserAvatar user={j.user} size="sm" />
<UserAvatar user={j.user} avatarUrl={j.user.avatarUrl} size="sm" />
<div>
<p className="font-medium">
{j.user.name || 'Unnamed'}

View File

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

View File

@@ -35,7 +35,7 @@ import { ActivityFeed } from '@/components/dashboard/activity-feed'
import { CategoryBreakdown } from '@/components/dashboard/category-breakdown'
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton'
import { RecentEvaluations } from '@/components/dashboard/recent-evaluations'
import { SemiFinalistTracker } from '@/components/dashboard/semi-finalist-tracker'
import { RoundUserTracker } from '@/components/dashboard/round-user-tracker'
type DashboardContentProps = {
editionId: string
@@ -126,11 +126,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{ limit: 8 },
{ enabled: !!editionId, refetchInterval: 30_000 }
)
const { data: semiFinalistStats } = trpc.dashboard.getSemiFinalistStats.useQuery(
{ editionId },
{ enabled: !!editionId, refetchInterval: 120_000 }
)
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery()
// Round User Tracker is self-contained — it fetches its own data
if (isLoading) {
return <DashboardSkeleton />
@@ -277,17 +273,9 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<SmartActions actions={nextActions} />
</AnimatedCard>
{semiFinalistStats && semiFinalistStats.byCategory.length > 0 && (
<AnimatedCard index={6}>
<SemiFinalistTracker
byCategory={semiFinalistStats.byCategory}
byAward={semiFinalistStats.byAward}
unactivatedProjects={semiFinalistStats.unactivatedProjects}
editionId={editionId}
reminderThresholdDays={featureFlags?.accountReminderDays}
/>
<RoundUserTracker editionId={editionId} />
</AnimatedCard>
)}
<AnimatedCard index={7}>
<ActivityFeed activity={liveActivity ?? recentActivity} />

View File

@@ -194,10 +194,8 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
<Card className="border-dashed">
<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>
<Button asChild className="mt-4" variant="outline">
<Link href={'/admin/juries' as Route}>
Back to Juries
</Link>
<Button className="mt-4" variant="outline" onClick={() => router.back()}>
Back
</Button>
</CardContent>
</Card>
@@ -212,13 +210,11 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
<Button
variant="ghost"
size="sm"
asChild
className="mb-2"
onClick={() => router.back()}
>
<Link href={'/admin/juries' as Route}>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Juries
</Link>
Back
</Button>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>

View File

@@ -2,7 +2,6 @@
import { useCallback, useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
@@ -257,11 +256,9 @@ export default function EditLearningResourcePage() {
The resource you&apos;re looking for does not exist.
</AlertDescription>
</Alert>
<Button asChild>
<Link href="/admin/learning">
<Button onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Learning Hub
</Link>
Back
</Button>
</div>
)
@@ -271,11 +268,9 @@ export default function EditLearningResourcePage() {
<div className="flex min-h-screen flex-col">
{/* 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">
<Button variant="ghost" size="sm" asChild>
<Link href="/admin/learning">
<Button variant="ghost" size="sm" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
<div className="flex items-center gap-2">

View File

@@ -2,7 +2,6 @@
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
@@ -165,11 +164,9 @@ export default function NewLearningResourcePage() {
<div className="flex min-h-screen flex-col">
{/* 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">
<Button variant="ghost" size="sm" asChild>
<Link href="/admin/learning">
<Button variant="ghost" size="sm" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
<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 Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { directSessionUpdate } from '@/lib/session-update'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -47,6 +48,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { UserAvatar } from '@/components/shared/user-avatar'
import {
ArrowLeft,
Save,
@@ -59,7 +61,44 @@ import {
Eye,
ThumbsUp,
ThumbsDown,
Globe,
Building2,
FileText,
FolderOpen,
LogIn,
Calendar,
Clock,
} 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() {
const params = useParams()
@@ -72,6 +111,7 @@ export default function MemberDetailPage() {
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
const updateUser = trpc.user.update.useMutation()
const sendInvitation = trpc.user.sendInvitation.useMutation()
const startImpersonation = trpc.user.startImpersonation.useMutation()
// Mentor assignments (only fetched for mentors)
const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery(
@@ -115,7 +155,7 @@ export default function MemberDetailPage() {
id: userId,
email: email || undefined,
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',
expertiseTags,
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
@@ -123,7 +163,6 @@ export default function MemberDetailPage() {
utils.user.get.invalidate({ id: userId })
utils.user.list.invalidate()
toast.success('Member updated successfully')
router.push('/admin/members')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update member')
}
@@ -140,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) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-32" />
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<Skeleton className="h-16 w-16 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
<Skeleton className="h-48 w-full" />
</div>
<Skeleton className="h-64 w-full" />
</div>
</div>
)
}
@@ -166,47 +222,48 @@ export default function MemberDetailPage() {
<AlertTitle>Error Loading Member</AlertTitle>
<AlertDescription>
{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>
</Alert>
<Button asChild>
<Link href="/admin/members">
<Button onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Members
</Link>
Back
</Button>
</div>
)
}
const displayRoles = user.roles?.length ? user.roles : [user.role]
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/members">
{/* Back nav */}
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Members
</Link>
Back
</Button>
</div>
<div className="flex items-start justify-between">
{/* Header Hero */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<UserAvatar user={user} avatarUrl={user.avatarUrl} size="lg" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{user.name || 'Unnamed Member'}
</h1>
<div className="flex items-center gap-2 mt-1">
<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">
<Badge variant={statusVariant[user.status] || 'secondary'}>
{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 className="flex items-center gap-2 shrink-0">
{(user.status === 'NONE' || user.status === 'INVITED') && (
<Button
variant="outline"
@@ -218,9 +275,22 @@ export default function MemberDetailPage() {
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Send Invitation
{user.status === 'INVITED' ? 'Resend Invite' : 'Send Invitation'}
</Button>
)}
<Button
variant="outline"
onClick={handleImpersonate}
disabled={startImpersonation.isPending}
>
{startImpersonation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LogIn className="mr-2 h-4 w-4" />
)}
Impersonate
</Button>
</div>
</div>
<Tabs defaultValue="profile" className="space-y-6">
@@ -243,13 +313,265 @@ export default function MemberDetailPage() {
</TabsList>
<TabsContent value="profile" className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
{/* Basic Info */}
<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>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Basic Information
<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">
@@ -289,15 +611,14 @@ export default function MemberDetailPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
{isSuperAdmin && (
<SelectItem value="SUPER_ADMIN">Super Admin</SelectItem>
)}
{isSuperAdmin && (
<SelectItem value="PROGRAM_ADMIN">Program Admin</SelectItem>
)}
{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>
@@ -315,19 +636,11 @@ export default function MemberDetailPage() {
</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">
{/* 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}
@@ -348,116 +661,10 @@ export default function MemberDetailPage() {
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}>
<Button onClick={handleSave} disabled={updateUser.isPending} className="w-full">
{updateUser.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
@@ -465,6 +672,9 @@ export default function MemberDetailPage() {
)}
Save Changes
</Button>
</CardContent>
</Card>
</div>
</div>
</TabsContent>
@@ -480,7 +690,6 @@ export default function MemberDetailPage() {
</Card>
) : (
(() => {
// Group evaluations by round
const byRound = new Map<string, typeof jurorEvaluations>()
for (const ev of jurorEvaluations) {
const key = ev.roundName
@@ -581,7 +790,6 @@ export default function MemberDetailPage() {
)}
</Tabs>
{/* Super Admin Confirmation Dialog */}
<AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}>
<AlertDialogContent>
@@ -594,11 +802,7 @@ export default function MemberDetailPage() {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setPendingSuperAdminRole(false)
}}
>
<AlertDialogCancel onClick={() => setPendingSuperAdminRole(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction

View File

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

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useMemo } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
@@ -49,6 +49,11 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Send,
Mail,
@@ -60,6 +65,12 @@ import {
Inbox,
CheckCircle2,
Eye,
Users,
FolderOpen,
XCircle,
ArrowRight,
ChevronDown,
ChevronRight,
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDate } from '@/lib/utils'
@@ -77,24 +88,44 @@ const RECIPIENT_TYPE_OPTIONS: { value: RecipientType; label: string }[] = [
const ROLES = ['JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'PROGRAM_ADMIN']
const STATE_LABELS: Record<string, string> = {
PENDING: 'Pending',
IN_PROGRESS: 'Active',
COMPLETED: 'Completed',
PASSED: 'Passed',
REJECTED: 'Rejected',
WITHDRAWN: 'Withdrawn',
}
const STATE_BADGE_VARIANT: Record<string, 'default' | 'success' | 'destructive' | 'secondary'> = {
PENDING: 'secondary',
IN_PROGRESS: 'default',
COMPLETED: 'default',
PASSED: 'success',
REJECTED: 'destructive',
WITHDRAWN: 'secondary',
}
export default function MessagesPage() {
const [recipientType, setRecipientType] = useState<RecipientType>('ALL')
const [selectedRole, setSelectedRole] = useState('')
const [roundId, setRoundId] = useState('')
const [roundIds, setRoundIds] = useState<string[]>([])
const [selectedProgramId, setSelectedProgramId] = useState('')
const [selectedUserId, setSelectedUserId] = useState('')
const [subject, setSubject] = useState('')
const [body, setBody] = useState('')
const [selectedTemplateId, setSelectedTemplateId] = useState('')
const [deliveryChannels, setDeliveryChannels] = useState<string[]>(['EMAIL', 'IN_APP'])
const [linkType, setLinkType] = useState<'NONE' | 'MESSAGES' | 'LOGIN' | 'INVITE'>('MESSAGES')
const [isScheduled, setIsScheduled] = useState(false)
const [scheduledAt, setScheduledAt] = useState('')
const [showPreview, setShowPreview] = useState(false)
const [excludeRejected, setExcludeRejected] = useState(true)
const [excludeWithdrawn, setExcludeWithdrawn] = useState(true)
const utils = trpc.useUtils()
// Fetch supporting data
// Get programs with stages
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ ...s, program: { name: p.name } }))
@@ -105,12 +136,69 @@ export default function MessagesPage() {
{ enabled: recipientType === 'USER' }
)
// Fetch sent messages for history (messages sent BY this admin)
// Fetch sent messages for history
const { data: sentMessages, isLoading: loadingSent } = trpc.message.sent.useQuery(
{ page: 1, pageSize: 50 },
{ refetchInterval: 30_000 }
)
// Compute exclude states list
const excludeStates = useMemo(() => {
const states: string[] = []
if (excludeRejected) states.push('REJECTED')
if (excludeWithdrawn) states.push('WITHDRAWN')
return states
}, [excludeRejected, excludeWithdrawn])
// Live recipient preview — fetches whenever selection changes
const recipientPreview = trpc.message.previewRecipients.useQuery(
{
recipientType,
recipientFilter: buildRecipientFilterValue(),
roundIds: roundIds.length > 0 ? roundIds : undefined,
excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined,
},
{
enabled: recipientType === 'ROUND_APPLICANTS'
? roundIds.length > 0
: recipientType === 'ROUND_JURY'
? roundIds.length > 0
: recipientType === 'ROLE'
? !!selectedRole
: recipientType === 'USER'
? !!selectedUserId
: recipientType === 'PROGRAM_TEAM'
? !!selectedProgramId
: recipientType === 'ALL',
}
)
// Detailed recipient list (fetched on-demand when user expands section)
const [showRecipientDetails, setShowRecipientDetails] = useState(false)
const recipientDetails = trpc.message.listRecipientDetails.useQuery(
{
recipientType,
recipientFilter: buildRecipientFilterValue(),
roundIds: roundIds.length > 0 ? roundIds : undefined,
excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined,
},
{
enabled: showRecipientDetails && (
recipientType === 'ROUND_APPLICANTS'
? roundIds.length > 0
: recipientType === 'ROUND_JURY'
? roundIds.length > 0
: recipientType === 'ROLE'
? !!selectedRole
: recipientType === 'USER'
? !!selectedUserId
: recipientType === 'PROGRAM_TEAM'
? !!selectedProgramId
: recipientType === 'ALL'
),
}
)
const emailPreview = trpc.message.previewEmail.useQuery(
{ subject, body },
{ enabled: showPreview && subject.length > 0 && body.length > 0 }
@@ -136,11 +224,13 @@ export default function MessagesPage() {
setBody('')
setSelectedTemplateId('')
setSelectedRole('')
setRoundId('')
setRoundIds([])
setSelectedProgramId('')
setSelectedUserId('')
setIsScheduled(false)
setScheduledAt('')
setLinkType('MESSAGES')
setShowRecipientDetails(false)
}
const handleTemplateSelect = (templateId: string) => {
@@ -164,7 +254,7 @@ export default function MessagesPage() {
)
}
const buildRecipientFilter = (): unknown => {
function buildRecipientFilterValue(): unknown {
switch (recipientType) {
case 'ROLE':
return selectedRole ? { role: selectedRole } : undefined
@@ -186,20 +276,24 @@ export default function MessagesPage() {
return roleLabel ? `All ${roleLabel}s` : 'By Role (none selected)'
}
case 'ROUND_JURY': {
if (!roundId) return 'Stage Jury (none selected)'
const stage = rounds?.find(
(r) => r.id === roundId
)
return stage
? `Jury of ${stage.program ? `${stage.program.name} - ` : ''}${stage.name}`
: 'Stage Jury'
if (roundIds.length === 0) return 'Stage Jury (none selected)'
const selectedJuryRounds = rounds?.filter((r) => roundIds.includes(r.id))
if (!selectedJuryRounds?.length) return 'Stage Jury'
if (selectedJuryRounds.length === 1) {
const s = selectedJuryRounds[0]
return `Jury of ${s.program ? `${s.program.name} - ` : ''}${s.name}`
}
return `Jury across ${selectedJuryRounds.length} rounds`
}
case 'ROUND_APPLICANTS': {
if (!roundId) return 'Round Applicants (none selected)'
const appRound = rounds?.find((r) => r.id === roundId)
return appRound
? `Applicants in ${appRound.program ? `${appRound.program.name} - ` : ''}${appRound.name}`
: 'Round Applicants'
if (roundIds.length === 0) return 'Round Applicants (none selected)'
const selectedAppRounds = rounds?.filter((r) => roundIds.includes(r.id))
if (!selectedAppRounds?.length) return 'Round Applicants'
if (selectedAppRounds.length === 1) {
const ar = selectedAppRounds[0]
return `Applicants in ${ar.program ? `${ar.program.name} - ` : ''}${ar.name}`
}
return `Applicants across ${selectedAppRounds.length} rounds`
}
case 'PROGRAM_TEAM': {
if (!selectedProgramId) return 'Program Team (none selected)'
@@ -219,53 +313,61 @@ export default function MessagesPage() {
}
}
const handlePreview = () => {
const validateForm = (): boolean => {
if (!subject.trim()) {
toast.error('Subject is required')
return
return false
}
if (!body.trim()) {
toast.error('Message body is required')
return
return false
}
if (deliveryChannels.length === 0) {
toast.error('Select at least one delivery channel')
return
return false
}
if (recipientType === 'ROLE' && !selectedRole) {
toast.error('Please select a role')
return
return false
}
if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && !roundId) {
toast.error('Please select a round')
return
if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && roundIds.length === 0) {
toast.error('Please select at least one round')
return false
}
if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) {
toast.error('Please select a program')
return
return false
}
if (recipientType === 'USER' && !selectedUserId) {
toast.error('Please select a user')
return
return false
}
return true
}
const handlePreview = () => {
if (!validateForm()) return
setShowPreview(true)
}
const handleActualSend = () => {
sendMutation.mutate({
recipientType,
recipientFilter: buildRecipientFilter(),
roundId: roundId || undefined,
recipientFilter: buildRecipientFilterValue(),
roundIds: roundIds.length > 0 ? roundIds : undefined,
excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined,
subject: subject.trim(),
body: body.trim(),
deliveryChannels,
scheduledAt: isScheduled && scheduledAt ? new Date(scheduledAt).toISOString() : undefined,
templateId: selectedTemplateId && selectedTemplateId !== '__none__' ? selectedTemplateId : undefined,
linkType,
})
setShowPreview(false)
}
const preview = recipientPreview.data
return (
<div className="space-y-6">
{/* Header */}
@@ -297,7 +399,9 @@ export default function MessagesPage() {
</TabsList>
<TabsContent value="compose" className="space-y-4 mt-4">
{/* Compose Form */}
<div className="grid gap-4 lg:grid-cols-3">
{/* Left: Compose form */}
<div className="lg:col-span-2 space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">Compose Message</CardTitle>
@@ -314,7 +418,7 @@ export default function MessagesPage() {
onValueChange={(v) => {
setRecipientType(v as RecipientType)
setSelectedRole('')
setRoundId('')
setRoundIds([])
setSelectedProgramId('')
setSelectedUserId('')
}}
@@ -353,19 +457,70 @@ export default function MessagesPage() {
{(recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && (
<div className="space-y-2">
<Label>Select Round</Label>
<Select value={roundId} onValueChange={setRoundId}>
<SelectTrigger>
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
<SelectContent>
{rounds?.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.program ? `${round.program.name} - ${round.name}` : round.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Label>Select Rounds</Label>
<div className="rounded-lg border p-3 space-y-2 max-h-48 overflow-y-auto">
{rounds?.length === 0 && (
<p className="text-sm text-muted-foreground">No rounds available</p>
)}
{rounds?.map((round) => {
const label = round.program ? `${round.program.name} - ${round.name}` : round.name
const isChecked = roundIds.includes(round.id)
return (
<div key={round.id} className="flex items-center gap-2">
<Checkbox
id={`round-${round.id}`}
checked={isChecked}
onCheckedChange={(checked) => {
setRoundIds((prev) =>
checked
? [...prev, round.id]
: prev.filter((id) => id !== round.id)
)
}}
/>
<label htmlFor={`round-${round.id}`} className="text-sm cursor-pointer">
{label}
</label>
</div>
)
})}
</div>
{roundIds.length > 0 && (
<p className="text-xs text-muted-foreground">
{roundIds.length} round{roundIds.length > 1 ? 's' : ''} selected
</p>
)}
</div>
)}
{/* Exclude filters for Round Applicants */}
{recipientType === 'ROUND_APPLICANTS' && roundIds.length > 0 && (
<div className="rounded-lg border p-3 space-y-2">
<Label className="text-sm font-medium">Exclude Project States</Label>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="exclude-rejected"
checked={excludeRejected}
onCheckedChange={(v) => setExcludeRejected(!!v)}
/>
<label htmlFor="exclude-rejected" className="text-sm cursor-pointer flex items-center gap-1.5">
<XCircle className="h-3.5 w-3.5 text-destructive" />
Exclude Rejected
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="exclude-withdrawn"
checked={excludeWithdrawn}
onCheckedChange={(v) => setExcludeWithdrawn(!!v)}
/>
<label htmlFor="exclude-withdrawn" className="text-sm cursor-pointer flex items-center gap-1.5">
<XCircle className="h-3.5 w-3.5 text-muted-foreground" />
Exclude Withdrawn
</label>
</div>
</div>
</div>
)}
@@ -522,6 +677,36 @@ export default function MessagesPage() {
</div>
</div>
{/* Link in Email */}
{deliveryChannels.includes('EMAIL') && (
<div className="space-y-2">
<Label>Email Link Button</Label>
<Select
value={linkType}
onValueChange={(v) => setLinkType(v as typeof linkType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">No link button</SelectItem>
<SelectItem value="MESSAGES">Link to Messages</SelectItem>
<SelectItem value="LOGIN">Link to Login</SelectItem>
<SelectItem value="INVITE">Invite / Accept link (for new members)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{linkType === 'NONE'
? 'No button will appear in the email.'
: linkType === 'MESSAGES'
? 'Recipients see a "View Details" button linking to their messages inbox.'
: linkType === 'LOGIN'
? 'Recipients see a button linking to the login page.'
: 'New members get their personal invite link; existing members get the login page.'}
</p>
</div>
)}
{/* Schedule */}
<div className="space-y-2">
<div className="flex items-center gap-2">
@@ -544,7 +729,7 @@ export default function MessagesPage() {
)}
</div>
{/* Send button */}
{/* Preview button */}
<div className="flex justify-end">
<Button onClick={handlePreview} disabled={sendMutation.isPending}>
{sendMutation.isPending ? (
@@ -557,6 +742,124 @@ export default function MessagesPage() {
</div>
</CardContent>
</Card>
</div>
{/* Right sidebar: Recipient Summary */}
<div className="space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<Users className="h-4 w-4 text-blue-500" />
Recipient Summary
</CardTitle>
</CardHeader>
<CardContent>
{recipientPreview.isLoading ? (
<div className="space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-24" />
</div>
) : preview ? (
<div className="space-y-3">
<div className="text-sm text-muted-foreground">
{getRecipientDescription()}
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-3">
{preview.totalProjects > 0 && (
<div className="rounded-lg border p-3 text-center">
<div className="flex items-center justify-center gap-1.5 mb-1">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Projects</span>
</div>
<p className="text-xl font-bold tabular-nums">{preview.totalProjects}</p>
</div>
)}
<div className={`rounded-lg border p-3 text-center ${preview.totalProjects > 0 ? '' : 'col-span-2'}`}>
<div className="flex items-center justify-center gap-1.5 mb-1">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Recipients</span>
</div>
<p className="text-xl font-bold tabular-nums">{preview.totalApplicants}</p>
</div>
</div>
{/* State breakdown for Round Applicants */}
{recipientType === 'ROUND_APPLICANTS' && Object.keys(preview.stateBreakdown).length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Projects by Status
</p>
<div className="space-y-1.5">
{Object.entries(preview.stateBreakdown)
.sort(([a], [b]) => {
const order = ['IN_PROGRESS', 'PENDING', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN']
return order.indexOf(a) - order.indexOf(b)
})
.map(([state, count]) => {
const isExcluded = excludeStates.includes(state)
return (
<div
key={state}
className={`flex items-center justify-between rounded-md px-2.5 py-1.5 text-sm ${
isExcluded ? 'bg-muted/50 opacity-50 line-through' : 'bg-muted/30'
}`}
>
<div className="flex items-center gap-2">
<Badge
variant={STATE_BADGE_VARIANT[state] || 'secondary'}
className="text-[10px] px-1.5 py-0"
>
{STATE_LABELS[state] || state}
</Badge>
{isExcluded && (
<span className="text-[10px] text-muted-foreground">(excluded)</span>
)}
</div>
<span className="font-semibold tabular-nums">{count}</span>
</div>
)
})}
</div>
{/* Included vs excluded summary */}
{excludeStates.length > 0 && (
<div className="rounded-lg bg-blue-500/5 border border-blue-200/50 p-2.5 mt-2">
<p className="text-xs text-blue-700">
Sending to <span className="font-semibold">{preview.totalApplicants}</span> applicants
{' '}across{' '}
<span className="font-semibold">
{preview.totalProjects - Object.entries(preview.stateBreakdown)
.filter(([s]) => excludeStates.includes(s))
.reduce((sum, [, c]) => sum + c, 0)}
</span>{' '}
projects (excluding{' '}
{excludeStates.map((s) => STATE_LABELS[s] || s).join(' & ').toLowerCase()}).
</p>
</div>
)}
</div>
)}
{/* Expandable recipient details */}
<RecipientDetailsList
open={showRecipientDetails}
onOpenChange={setShowRecipientDetails}
data={recipientDetails.data}
isLoading={recipientDetails.isLoading}
recipientType={recipientType}
/>
</div>
) : (
<p className="text-sm text-muted-foreground">
Select a recipient type{recipientType !== 'ALL' ? ' and filter' : ''} to see a summary.
</p>
)}
</CardContent>
</Card>
</div>
</div>
</TabsContent>
<TabsContent value="history" className="mt-4">
@@ -609,6 +912,8 @@ export default function MessagesPage() {
? `By role`
: msg.recipientType === 'ROUND_JURY'
? 'Round jury'
: msg.recipientType === 'ROUND_APPLICANTS'
? 'Round applicants'
: msg.recipientType === 'USER'
? `${recipientCount || 1} user${recipientCount > 1 ? 's' : ''}`
: msg.recipientType}
@@ -671,30 +976,30 @@ export default function MessagesPage() {
</TabsContent>
</Tabs>
{/* Preview Dialog */}
{/* Preview & Send Dialog */}
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Preview Message</DialogTitle>
<DialogDescription>Review your message before sending</DialogDescription>
<DialogTitle className="text-xl">Review & Send</DialogTitle>
<DialogDescription>Verify everything looks correct before sending</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Recipients</p>
<p className="text-sm mt-1">{getRecipientDescription()}</p>
</div>
<div className="grid gap-6 md:grid-cols-5">
{/* Left: Message details */}
<div className="md:col-span-3 space-y-4">
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Subject</p>
<p className="text-sm font-medium mt-1">{subject}</p>
<p className="text-base font-semibold mt-1">{subject}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Email Preview</p>
<div className="mt-1 rounded-lg border overflow-hidden bg-gray-50">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">Email Preview</p>
<div className="rounded-lg border overflow-hidden bg-gray-50">
{emailPreview.data?.html ? (
<iframe
srcDoc={emailPreview.data.html}
sandbox="allow-same-origin"
className="w-full h-[500px] border-0"
className="w-full h-[400px] border-0"
title="Email Preview"
/>
) : (
@@ -704,9 +1009,71 @@ export default function MessagesPage() {
)}
</div>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Delivery Channels</p>
<div className="flex items-center gap-2 mt-1">
</div>
{/* Right: Recipient & delivery summary */}
<div className="md:col-span-2 space-y-4">
<div className="rounded-lg border p-4 space-y-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Recipients</p>
<p className="text-sm font-medium">{getRecipientDescription()}</p>
{preview && (
<div className="space-y-2">
{preview.totalProjects > 0 && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground flex items-center gap-1.5">
<FolderOpen className="h-3.5 w-3.5" />
Projects
</span>
<span className="font-semibold tabular-nums">
{preview.totalProjects - Object.entries(preview.stateBreakdown)
.filter(([s]) => excludeStates.includes(s))
.reduce((sum, [, c]) => sum + c, 0)}
{excludeStates.length > 0 && (
<span className="text-muted-foreground font-normal"> / {preview.totalProjects}</span>
)}
</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" />
Applicants
</span>
<span className="font-semibold tabular-nums">{preview.totalApplicants}</span>
</div>
{/* State breakdown in preview */}
{recipientType === 'ROUND_APPLICANTS' && Object.keys(preview.stateBreakdown).length > 0 && (
<div className="pt-2 border-t space-y-1">
{Object.entries(preview.stateBreakdown)
.sort(([a], [b]) => {
const order = ['IN_PROGRESS', 'PENDING', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN']
return order.indexOf(a) - order.indexOf(b)
})
.map(([state, count]) => {
const isExcluded = excludeStates.includes(state)
return (
<div
key={state}
className={`flex items-center justify-between text-xs ${isExcluded ? 'opacity-40 line-through' : ''}`}
>
<Badge variant={STATE_BADGE_VARIANT[state] || 'secondary'} className="text-[10px] px-1.5 py-0">
{STATE_LABELS[state] || state}
</Badge>
<span className="tabular-nums">{count} projects</span>
</div>
)
})}
</div>
)}
</div>
)}
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Delivery</p>
<div className="flex flex-wrap gap-1.5">
{deliveryChannels.includes('EMAIL') && (
<Badge variant="outline" className="text-xs">
<Mail className="mr-1 h-3 w-3" />
@@ -720,15 +1087,22 @@ export default function MessagesPage() {
</Badge>
)}
</div>
</div>
{isScheduled && scheduledAt && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Scheduled For</p>
<p className="text-sm mt-1">{formatDate(new Date(scheduledAt))}</p>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-1">
<Clock className="h-3.5 w-3.5" />
Scheduled: {formatDate(new Date(scheduledAt))}
</div>
)}
{deliveryChannels.includes('EMAIL') && (
<p className="text-xs text-muted-foreground mt-1">
Link: {linkType === 'NONE' ? 'None' : linkType === 'MESSAGES' ? 'Messages inbox' : linkType === 'LOGIN' ? 'Login page' : 'Invite / Accept link'}
</p>
)}
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
</div>
</div>
<DialogFooter className="flex-col sm:flex-row gap-2 mt-4 border-t pt-4">
<Button
variant="ghost"
size="sm"
@@ -744,15 +1118,15 @@ export default function MessagesPage() {
Send Test to Me
</Button>
<Button variant="outline" onClick={() => setShowPreview(false)}>
Edit
Back to Edit
</Button>
<Button onClick={handleActualSend} disabled={sendMutation.isPending}>
<Button onClick={handleActualSend} disabled={sendMutation.isPending} size="lg">
{sendMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{isScheduled ? 'Confirm & Schedule' : 'Confirm & Send'}
{isScheduled ? 'Confirm & Schedule' : `Send to ${preview?.totalApplicants ?? '...'} Recipients`}
</Button>
</DialogFooter>
</DialogContent>
@@ -760,3 +1134,183 @@ export default function MessagesPage() {
</div>
)
}
// =============================================================================
// Expandable recipient details
// =============================================================================
type RecipientDetailsData = {
type: 'projects' | 'jurors' | 'users'
projects: Array<{
id: string
title: string
state: string
roundId?: string
roundName?: string
members: Array<{ id: string; name: string | null; email: string }>
}>
users: Array<{
id: string
name: string | null
email: string
projectCount?: number
projectNames?: string[]
projectName?: string | null
role?: string
roundNames?: string[]
}>
}
function RecipientDetailsList({
open,
onOpenChange,
data,
isLoading,
recipientType,
}: {
open: boolean
onOpenChange: (open: boolean) => void
data?: RecipientDetailsData
isLoading: boolean
recipientType: string
}) {
return (
<Collapsible open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-full pt-2">
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
View Recipients
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 max-h-[300px] overflow-y-auto rounded-md border bg-muted/20 p-2 space-y-1">
{isLoading ? (
<div className="space-y-2 p-1">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
) : !data || (data.projects.length === 0 && data.users.length === 0) ? (
<p className="text-xs text-muted-foreground p-1">No recipients found.</p>
) : data.type === 'projects' ? (
// ROUND_APPLICANTS: projects grouped by round (if multi-round)
(() => {
const roundGroups = new Map<string, { roundName: string; projects: typeof data.projects }>()
for (const project of data.projects) {
const key = project.roundId || '_unknown'
const existing = roundGroups.get(key)
if (existing) {
existing.projects.push(project)
} else {
roundGroups.set(key, {
roundName: project.roundName || 'Unknown Round',
projects: [project],
})
}
}
const groups = Array.from(roundGroups.values())
const isMultiRound = groups.length > 1
return groups.map((group) => (
<div key={group.roundName}>
{isMultiRound && (
<div className="sticky top-0 bg-muted/60 backdrop-blur-sm px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground border-b mb-1">
{group.roundName} ({group.projects.length})
</div>
)}
{group.projects.map((project) => (
<ProjectRecipientRow key={`${project.roundId}-${project.id}`} project={project} />
))}
</div>
))
})()
) : data.type === 'jurors' ? (
// ROUND_JURY: jurors with project counts and round info
data.users.map((user) => (
<div key={user.id} className="flex items-center justify-between rounded px-2 py-1.5 text-xs hover:bg-muted/50">
<div className="min-w-0">
<Link
href={`/admin/members/${user.id}`}
className="font-medium hover:underline text-primary truncate block"
>
{user.name || user.email}
</Link>
<span className="text-muted-foreground">
{user.projectCount !== undefined && (
<>{user.projectCount} project{user.projectCount !== 1 ? 's' : ''} assigned</>
)}
{user.roundNames && user.roundNames.length > 1 && (
<> &middot; {user.roundNames.length} rounds</>
)}
</span>
</div>
</div>
))
) : (
// ALL, ROLE, USER, PROGRAM_TEAM: plain user list
data.users.map((user) => (
<div key={user.id} className="flex items-center justify-between rounded px-2 py-1.5 text-xs hover:bg-muted/50">
<div className="min-w-0">
<Link
href={`/admin/members/${user.id}`}
className="font-medium hover:underline text-primary truncate block"
>
{user.name || user.email}
</Link>
{user.projectName && (
<span className="text-muted-foreground truncate block">{user.projectName}</span>
)}
</div>
{user.role && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 shrink-0 ml-2">
{user.role.replace(/_/g, ' ')}
</Badge>
)}
</div>
))
)}
</div>
</CollapsibleContent>
</Collapsible>
)
}
function ProjectRecipientRow({ project }: {
project: {
id: string
title: string
state: string
roundId?: string
roundName?: string
members: Array<{ id: string; name: string | null; email: string }>
}
}) {
const [open, setOpen] = useState(false)
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex items-center justify-between w-full rounded px-2 py-1.5 text-xs hover:bg-muted/50">
<div className="flex items-center gap-1.5 min-w-0">
{open ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
<span className="font-medium truncate">{project.title}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
<Badge variant={STATE_BADGE_VARIANT[project.state] || 'secondary'} className="text-[10px] px-1.5 py-0">
{STATE_LABELS[project.state] || project.state}
</Badge>
<span className="text-muted-foreground">{project.members.length}</span>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="ml-5 space-y-0.5 pb-1">
{project.members.map((member) => (
<Link
key={member.id}
href={`/admin/members/${member.id}`}
className="block text-xs px-2 py-0.5 text-primary hover:underline truncate"
>
{member.name || member.email}
</Link>
))}
</div>
</CollapsibleContent>
</Collapsible>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -300,19 +300,17 @@ function EditProjectContent({ projectId }: { projectId: string }) {
if (!project) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/projects">
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
</Link>
Back
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium">Project Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/projects">Back to Projects</Link>
<Button className="mt-4" onClick={() => router.back()}>
Back
</Button>
</CardContent>
</Card>
@@ -330,11 +328,9 @@ function EditProjectContent({ projectId }: { projectId: string }) {
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/projects/${projectId}`}>
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
Back
</Button>
</div>

View File

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

View File

@@ -3,6 +3,7 @@
import { Suspense, use, useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import {
Card,
@@ -77,6 +78,8 @@ import {
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDateOnly } from '@/lib/utils'
import { getCountryName, getCountryFlag } from '@/lib/countries'
import { CountryDisplay } from '@/components/shared/country-display'
interface PageProps {
params: Promise<{ id: string }>
@@ -101,6 +104,7 @@ const evalStatusColors: Record<string, 'default' | 'secondary' | 'destructive' |
}
function ProjectDetailContent({ projectId }: { projectId: string }) {
const router = useRouter()
// Fetch project + assignments + stats in a single combined query
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery(
{ id: projectId },
@@ -170,6 +174,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
},
})
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')
@@ -188,19 +202,17 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
if (!project) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/projects">
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
</Link>
Back
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium">Project Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/projects">Back to Projects</Link>
<Button className="mt-4" onClick={() => router.back()}>
Back
</Button>
</CardContent>
</Card>
@@ -212,11 +224,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/projects">
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
</Link>
Back
</Button>
</div>
@@ -226,6 +236,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
project={project}
size="lg"
fallback="initials"
clickToEnlarge
/>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
@@ -363,7 +374,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<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.country}</p>
<p className="text-sm">{project.geographicZone}{project.geographicZone && project.country ? ', ' : ''}{project.country ? <CountryDisplay country={project.country} /> : null}</p>
</div>
</div>
)}
@@ -513,10 +524,15 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<CardContent>
{project.teamMembers && project.teamMembers.length > 0 ? (
<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 } }) => {
const isLastLead =
member.role === 'LEAD' &&
project.teamMembers.filter((m: { role: string }) => m.role === 'LEAD').length <= 1
const details = [
member.user.nationality ? `${getCountryFlag(member.user.nationality)} ${getCountryName(member.user.nationality)}` : null,
member.user.institution,
member.user.country && member.user.country !== member.user.nationality ? `${getCountryFlag(member.user.country)} ${getCountryName(member.user.country)}` : null,
].filter(Boolean)
return (
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
{member.role === 'LEAD' ? (
@@ -528,12 +544,28 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-sm truncate">
<Link href={`/admin/members/${member.user.id}`} className="font-medium text-sm truncate hover:underline text-primary">
{member.user.name || 'Unnamed'}
</p>
<Badge variant="outline" className="text-xs">
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</Badge>
</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}
@@ -541,6 +573,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
{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>
@@ -778,16 +815,18 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
/>
</div>
{/* All Files list */}
{/* All Files list — grouped by round */}
{files && files.length > 0 && (
<>
<Separator />
<FileViewer
projectId={projectId}
files={files.map((f) => ({
groupedFiles={(() => {
const groups = new Map<string, { roundId: string | null; roundName: string; sortOrder: number; files: typeof mappedFiles }>()
const mappedFiles = files.map((f) => ({
id: f.id,
fileName: f.fileName,
fileType: f.fileType,
fileType: f.fileType as 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' | 'BUSINESS_PLAN' | 'VIDEO_PITCH' | 'SUPPORTING_DOC',
mimeType: f.mimeType,
size: f.size,
bucket: f.bucket,
@@ -804,7 +843,20 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
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())
})()}
/>
</>
)}

View File

@@ -2,6 +2,7 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
@@ -62,6 +63,7 @@ type UploadState = {
type UploadMap = Record<string, UploadState>
export default function BulkUploadPage() {
const router = useRouter()
const [roundId, setRoundId] = useState('')
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
@@ -146,7 +148,7 @@ export default function BulkUploadPage() {
const handleViewFile = useCallback(
async (bucket: string, objectKey: string) => {
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')
} catch {
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">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href="/admin/projects">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<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">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/projects">
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
</Link>
Back
</Button>
</div>

View File

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

View File

@@ -5,6 +5,7 @@ import Link from 'next/link'
import { useSearchParams, usePathname } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import {
Card,
CardContent,
@@ -94,6 +95,7 @@ import { ProjectLogo } from '@/components/shared/project-logo'
import { BulkNotificationDialog } from '@/components/admin/projects/bulk-notification-dialog'
import { Pagination } from '@/components/shared/pagination'
import { SortableHeader } from '@/components/shared/sortable-header'
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
import { CountryFlagImg } from '@/components/ui/country-select'
import {
@@ -144,6 +146,9 @@ function parseFiltersFromParams(
statuses: searchParams.get('status')
? searchParams.get('status')!.split(',')
: [],
roundStates: searchParams.get('roundState')
? searchParams.get('roundState')!.split(',')
: [],
roundId: searchParams.get('round') || '',
competitionCategory: searchParams.get('category') || '',
oceanIssue: searchParams.get('issue') || '',
@@ -178,6 +183,8 @@ function filtersToParams(
if (filters.search) params.set('q', filters.search)
if (filters.statuses.length > 0)
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.competitionCategory)
params.set('category', filters.competitionCategory)
@@ -203,6 +210,7 @@ export default function ProjectsPage() {
const [filters, setFilters] = useState<ProjectFilters>({
search: parsed.search,
statuses: parsed.statuses,
roundStates: parsed.roundStates,
roundId: parsed.roundId,
competitionCategory: parsed.competitionCategory,
oceanIssue: parsed.oceanIssue,
@@ -215,6 +223,8 @@ export default function ProjectsPage() {
const [perPage, setPerPage] = useState(parsed.perPage || 20)
const [searchInput, setSearchInput] = useState(parsed.search)
const [viewMode, setViewMode] = useState<'table' | 'card'>('table')
const [sortBy, setSortBy] = useState<string | undefined>(undefined)
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
// Fetch display settings
const { data: displaySettings } = trpc.settings.getMultiple.useQuery({
@@ -260,6 +270,16 @@ export default function ProjectsPage() {
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
const queryInput = {
search: filters.search || undefined,
@@ -296,8 +316,16 @@ export default function ProjectsPage() {
wantsMentorship: filters.wantsMentorship,
hasFiles: filters.hasFiles,
hasAssignments: filters.hasAssignments,
roundStates:
filters.roundStates.length > 0
? (filters.roundStates as Array<
'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'PASSED' | 'REJECTED' | 'WITHDRAWN'
>)
: undefined,
page,
perPage,
sortBy: sortBy as 'title' | 'category' | 'program' | 'assignments' | 'status' | 'createdAt' | undefined,
sortDir: sortBy ? sortDir : undefined,
}
const utils = trpc.useUtils()
@@ -737,7 +765,7 @@ export default function ProjectsPage() {
/>
{/* 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 flex-wrap items-center gap-2 text-sm">
{Object.entries(data.statusCounts ?? {})
@@ -745,15 +773,43 @@ export default function ProjectsPage() {
const order = ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN']
return order.indexOf(a) - order.indexOf(b)
})
.map(([status, count]) => (
<Badge
.map(([status, count]) => {
const isActive = filters.roundStates.includes(status)
return (
<button
key={status}
type="button"
onClick={() => {
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="text-xs font-normal"
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 && (
<span className="text-xs text-muted-foreground ml-1">
(page {data.page} of {data.totalPages})
@@ -876,6 +932,17 @@ export default function ProjectsPage() {
</Card>
) : data ? (
<>
{/* Top Pagination */}
{data.totalPages > 1 && (
<Pagination
page={data.page}
totalPages={data.totalPages}
total={data.total}
perPage={perPage}
onPageChange={setPage}
/>
)}
{/* Table View */}
{viewMode === 'table' ? (
<>
@@ -891,12 +958,12 @@ export default function ProjectsPage() {
aria-label="Select all projects"
/>
</TableHead>
<TableHead className="min-w-[280px]">Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Program</TableHead>
<SortableHeader label="Project" column="title" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} className="min-w-[280px]" />
<SortableHeader label="Category" column="category" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<SortableHeader label="Program" column="program" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<TableHead>Tags</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Status</TableHead>
<SortableHeader label="Assignments" column="assignments" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<SortableHeader label="Status" column="status" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>

View File

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

View File

@@ -1,7 +1,7 @@
'use client'
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 type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
@@ -77,6 +77,8 @@ import {
ArrowRight,
RotateCcw,
ListChecks,
FileText,
Languages,
} from 'lucide-react'
import {
Tooltip,
@@ -150,8 +152,7 @@ const stateColors: Record<string, string> = Object.fromEntries(
export default function RoundDetailPage() {
const params = useParams()
const roundId = params.roundId as string
const searchParams = useSearchParams()
const backUrl = searchParams.get('from')
const router = useRouter()
const [config, setConfig] = useState<Record<string, unknown>>({})
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
@@ -544,11 +545,9 @@ export default function RoundDetailPage() {
return (
<div className="space-y-6">
<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">
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Round Not Found</h1>
<p className="text-sm text-muted-foreground">This round does not exist.</p>
@@ -620,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 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="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'}>
<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()}>
<ArrowLeft className="h-4 w-4" />
<span className="text-xs hidden sm:inline">{round.specialAwardId ? 'Back to Award' : 'Back to Rounds'}</span>
<span className="text-xs hidden sm:inline">Back</span>
</Button>
</Link>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2.5">
{/* 4.6 Inline-editable round name */}
@@ -1471,6 +1468,9 @@ export default function RoundDetailPage() {
</Card>
</AnimatedCard>
</div>
{/* Document Language Summary */}
<DocumentLanguageSummary roundId={roundId as string} />
</TabsContent>
{/* ═══════════ PROJECTS TAB ═══════════ */}
@@ -2482,3 +2482,75 @@ export default function RoundDetailPage() {
</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,
Save,
Loader2,
Award,
Trophy,
ArrowRight,
} from 'lucide-react'
@@ -151,27 +150,42 @@ export default function RoundsPage() {
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[]
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])
// Group awards by their evaluationRoundId
const awardsByRound = useMemo(() => {
const map = new Map<string, SpecialAwardItem[]>()
for (const award of (awards ?? []) as SpecialAwardItem[]) {
if (award.evaluationRoundId) {
const existing = map.get(award.evaluationRoundId) ?? []
existing.push(award)
map.set(award.evaluationRoundId, existing)
// Group award-track rounds by their specialAwardId, paired with the award metadata
const awardTrackGroups = useMemo(() => {
const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[]
const awardRounds = allRounds.filter((r) => r.specialAwardId)
const groups = new Map<string, { award: SpecialAwardItem; rounds: RoundWithStats[] }>()
for (const round of awardRounds) {
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
}, [awards])
return Array.from(groups.values())
}, [compDetail?.rounds, awards])
const floatingAwards = useMemo(() => {
return ((awards ?? []) as SpecialAwardItem[]).filter((a) => !a.evaluationRoundId)
}, [awards])
// Awards that have no evaluationRoundId AND no rounds linked via specialAwardId
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 = () => {
if (!roundForm.name.trim() || !roundForm.roundType || !comp) {
@@ -271,8 +285,10 @@ export default function RoundsPage() {
const activeFilter = filterType !== 'all'
const totalProjects = (compDetail as any)?.distinctProjectCount ?? 0
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 activeRound = allRounds.find((r) => r.status === 'ROUND_ACTIVE')
const activeRound = allMainRounds.find((r) => r.status === 'ROUND_ACTIVE')
return (
<TooltipProvider delayDuration={200}>
@@ -313,7 +329,7 @@ export default function RoundsPage() {
</Tooltip>
</div>
<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>{totalProjects} projects</span>
<span className="text-muted-foreground/30">|</span>
@@ -330,12 +346,12 @@ export default function RoundsPage() {
</span>
</>
)}
{awards && awards.length > 0 && (
{awardTrackGroups.length > 0 && (
<>
<span className="text-muted-foreground/30">|</span>
<span className="flex items-center gap-1">
<Award className="h-3.5 w-3.5" />
{awards.length} awards
<Trophy className="h-3.5 w-3.5" />
{awardTrackGroups.length} award {awardTrackGroups.length === 1 ? 'track' : 'tracks'}
</span>
</>
)}
@@ -389,7 +405,7 @@ export default function RoundsPage() {
</div>
{/* ── Pipeline View ───────────────────────────────────────────── */}
{rounds.length === 0 ? (
{mainRounds.length === 0 && awardTrackGroups.length === 0 ? (
<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" />
<p className="text-sm text-muted-foreground">
@@ -397,142 +413,79 @@ export default function RoundsPage() {
</p>
</div>
) : (
<div className="space-y-6">
{/* ── Main Competition Pipeline ───────────────────────── */}
{mainRounds.length > 0 && (
<div className="relative">
{/* Main pipeline track */}
{rounds.map((round, index) => {
const isLast = index === rounds.length - 1
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
const roundAwards = awardsByRound.get(round.id) ?? []
{mainRounds.map((round, index) => (
<RoundRow
key={round.id}
round={round}
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 (
<div key={round.id} className="relative">
{/* Round row with pipeline connector */}
<div className="flex">
{/* Left: pipeline track */}
<div className="flex flex-col items-center shrink-0 w-10">
{/* Status dot */}
<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>
{/* 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"
key={award.id}
className="rounded-lg border border-amber-200/80 bg-amber-50/30 overflow-hidden"
>
<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 */}
{/* Award track header */}
<Link href={`/admin/awards/${award.id}` as Route}>
<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">
<div className="flex items-center justify-center h-8 w-8 rounded-full bg-amber-100 shrink-0">
<Trophy className="h-4 w-4 text-amber-600" />
</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-wider shrink-0 w-[70px]',
typeColors.text
'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'
)}>
{round.roundType.replace('_', ' ')}
{isExclusive ? 'Exclusive pool' : 'Parallel'}
</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 className="text-muted-foreground/30">&middot;</span>
<span className={statusColor}>
{award.status.replace('_', ' ')}
</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>
<ArrowRight className="h-4 w-4 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} />
{/* 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>
</div>
</div>
)
})}
{/* Floating awards (no evaluationRoundId) */}
{/* Floating awards (no linked rounds) */}
{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">
Unlinked Awards
</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 ──────────────────────────────────────────────────────────────
function AwardNode({ award }: { award: SpecialAwardItem }) {

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
'use client'
import { useSession } from 'next-auth/react'
import Link from 'next/link'
import type { Route } from 'next'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline'
import { ArrowLeft, FileText, Calendar } from 'lucide-react'
import { ArrowLeft, FileText } from 'lucide-react'
export default function ApplicantCompetitionPage() {
const router = useRouter()
const { data: session } = useSession()
const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
enabled: !!session,
@@ -36,11 +36,9 @@ export default function ApplicantCompetitionPage() {
Track your progress through competition rounds
</p>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={'/applicant' as Route} aria-label="Back to applicant dashboard">
<Button variant="ghost" size="sm" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
</Link>
Back
</Button>
</div>
@@ -61,29 +59,6 @@ export default function ApplicantCompetitionPage() {
<ApplicantCompetitionTimeline />
</div>
<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>
<CardHeader>
<CardTitle>Timeline Info</CardTitle>

View File

@@ -1,5 +1,6 @@
'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
@@ -13,6 +14,7 @@ import {
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer'
import {
FileText,
Upload,
@@ -22,7 +24,10 @@ import {
File,
Download,
Eye,
X,
Loader2,
} from 'lucide-react'
import { toast } from 'sonner'
const fileTypeIcons: Record<string, typeof FileText> = {
EXEC_SUMMARY: FileText,
@@ -44,31 +49,111 @@ const fileTypeLabels: Record<string, string> = {
SUPPORTING_DOC: 'Supporting Document',
}
function FileActionButtons({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) {
const { data: viewData } = trpc.file.getDownloadUrl.useQuery(
{ bucket, objectKey, forDownload: false },
{ staleTime: 10 * 60 * 1000 }
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 }
)
const { data: dlData } = trpc.file.getDownloadUrl.useQuery(
{ bucket, objectKey, forDownload: true, fileName },
{ staleTime: 10 * 60 * 1000 }
)
const viewUrl = typeof viewData === 'string' ? viewData : viewData?.url
const dlUrl = typeof dlData === 'string' ? dlData : dlData?.url
return (
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" asChild disabled={!viewUrl}>
<a href={viewUrl || '#'} target="_blank" rel="noopener noreferrer">
<Eye className="h-3 w-3" /> View
</a>
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" asChild disabled={!dlUrl}>
<a href={dlUrl || '#'} download={fileName}>
<Download className="h-3 w-3" /> Download
</a>
</Button>
<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>
)
}
@@ -113,7 +198,6 @@ export default function ApplicantDocumentsPage() {
}
const { project, openRounds, isRejected } = data
const isDraft = !project.submittedAt
return (
<div className="space-y-6">
@@ -204,41 +288,9 @@ export default function ApplicantDocumentsPage() {
) : (
<div className="space-y-2">
{project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null; bucket?: string; objectKey?: string }
const fileRecord = file as typeof file & { isLate?: boolean; bucket?: string; objectKey?: string; mimeType?: string }
return (
<div
key={file.id}
className="flex items-center justify-between p-3 rounded-lg border"
>
<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>
{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>
{fileRecord.bucket && fileRecord.objectKey && (
<FileActionButtons
bucket={fileRecord.bucket}
objectKey={fileRecord.objectKey}
fileName={file.fileName}
/>
)}
</div>
<FileRow key={file.id} file={fileRecord} />
)
})}
</div>

View File

@@ -8,8 +8,82 @@ import {
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
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() {
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>
<p className="text-muted-foreground">Anonymous evaluations from jury members</p>
</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">
{[1, 2].map((i) => (
<Card key={i}>
@@ -37,6 +119,28 @@ export default function ApplicantEvaluationsPage() {
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 (
<div className="space-y-6">
<div>
@@ -49,7 +153,9 @@ export default function ApplicantEvaluationsPage() {
{!hasEvaluations ? (
<Card>
<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>
<p className="text-muted-foreground text-center max-w-md">
Evaluations will appear here once jury review is complete and results are published.
@@ -58,44 +164,128 @@ export default function ApplicantEvaluationsPage() {
</Card>
) : (
<div className="space-y-6">
{rounds.map((round) => (
<Card key={round.roundId}>
<CardHeader>
{/* Stats Summary Strip */}
<AnimatedCard index={0}>
<Card className="p-0 overflow-hidden">
<div className="grid grid-cols-3 divide-x divide-border">
<div className="p-4 text-center">
<div className="flex items-center justify-center gap-1.5 mb-1">
<BarChart3 className="h-3.5 w-3.5 text-blue-500" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Reviews</span>
</div>
<p className="text-2xl font-bold tabular-nums">{totalEvaluations}</p>
</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>
</AnimatedCard>
{/* Per-Round Cards */}
{rounds.map((round, roundIdx) => {
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>{round.roundName}</CardTitle>
<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} evaluation{round.evaluationCount !== 1 ? 's' : ''}
{round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'evaluation'}{round.evaluationCount !== 1 ? 's' : ''}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 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="rounded-lg border p-4 space-y-3"
className="px-6 py-4 space-y-3"
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">
Evaluator #{idx + 1}
{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>
{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>
<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 }>
@@ -105,14 +295,21 @@ export default function ApplicantEvaluationsPage() {
.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="flex items-center justify-between text-sm">
<span>{c.label || c.name || `Criterion ${ci + 1}`}</span>
<span className="font-medium">
<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 : '—'}
{c.maxScore ? ` / ${c.maxScore}` : ''}
<span className="text-muted-foreground font-normal text-xs"> / {cMax}</span>
</span>
</div>
{score !== undefined && (
<Progress value={pct} className="h-1.5" />
)}
</div>
)
})
})()}
@@ -122,25 +319,34 @@ export default function ApplicantEvaluationsPage() {
{ev.feedbackText && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<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" />
Written Feedback
{round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'}
</div>
<blockquote className="border-l-2 border-muted pl-4 text-sm italic text-muted-foreground">
<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}
</blockquote>
</p>
</div>
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
))}
</AnimatedCard>
)
})}
<p className="text-xs text-muted-foreground text-center">
{/* 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>
)

View File

@@ -1,5 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useSession } from 'next-auth/react'
import Link from 'next/link'
import type { Route } from 'next'
@@ -13,11 +14,12 @@ import {
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { StatusTracker } from '@/components/shared/status-tracker'
import { Textarea } from '@/components/ui/textarea'
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
import { WithdrawButton } from '@/components/applicant/withdraw-button'
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
import { AnimatedCard } from '@/components/shared/animated-container'
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
import { Progress } from '@/components/ui/progress'
import {
FileText,
Calendar,
@@ -29,7 +31,28 @@ import {
ArrowRight,
Star,
AlertCircle,
Pencil,
Loader2,
Check,
X,
UserCircle,
Trophy,
Vote,
Clock,
} 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'> = {
DRAFT: 'secondary',
@@ -42,9 +65,13 @@ const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destru
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() {
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const utils = trpc.useUtils()
const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
enabled: isAuthenticated,
@@ -62,7 +89,18 @@ export default function ApplicantDashboardPage() {
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 (
<div className="space-y-6">
<div className="space-y-2">
@@ -115,20 +153,38 @@ export default function ApplicantDashboardPage() {
const programYear = project.program?.year
const programName = project.program?.name
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
const canEditDescription = flags?.applicantAllowDescriptionEdit && !isRejected
return (
<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-center gap-4">
{/* Project logo */}
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden">
{/* Project logo — clickable for any team member to change */}
<ProjectLogoUpload
projectId={project.id}
currentLogoUrl={data.logoUrl}
onUploadComplete={() => utils.applicant.getMyDashboard.invalidate()}
>
<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>
@@ -143,11 +199,50 @@ export default function ApplicantDashboardPage() {
</p>
</div>
</div>
{project.isTeamLead && currentStatus !== 'REJECTED' && (currentStatus as string) !== 'WINNER' && (
<WithdrawButton projectId={project.id} />
)}
</div>
{/* Active round deadline banner */}
{!isRejected && openRounds.length > 0 && (() => {
const submissionTypes = new Set(['INTAKE', 'SUBMISSION', 'MENTORING'])
const roundsWithDeadline = openRounds.filter((r) => r.windowCloseAt && submissionTypes.has(r.roundType))
if (roundsWithDeadline.length === 0) return null
return roundsWithDeadline.map((round) => {
const closeAt = new Date(round.windowCloseAt!).getTime()
const remaining = closeAt - now
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">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
@@ -164,12 +259,19 @@ export default function ApplicantDashboardPage() {
<p>{project.teamName}</p>
</div>
)}
{project.description && (
{/* Description — editable if admin allows */}
{project.description && !canEditDescription && (
<div>
<p className="text-sm font-medium text-muted-foreground">Description</p>
<p className="whitespace-pre-wrap">{project.description}</p>
</div>
)}
{canEditDescription && (
<EditableDescription
projectId={project.id}
initialDescription={project.description || ''}
/>
)}
{project.tags && project.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
@@ -183,22 +285,27 @@ export default function ApplicantDashboardPage() {
</div>
)}
{/* Metadata */}
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
{/* Metadata — filter out team members (shown in sidebar) */}
{project.metadataJson && (() => {
const entries = Object.entries(project.metadataJson as Record<string, unknown>)
.filter(([key]) => !HIDDEN_METADATA_KEYS.has(key))
if (entries.length === 0) return null
return (
<div className="border-t pt-4 mt-4">
<p className="text-sm font-medium text-muted-foreground mb-3">Additional Information</p>
<dl className="space-y-2">
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => (
<div key={key} className="flex justify-between">
<dt className="text-sm text-muted-foreground capitalize">
{entries.map(([key, value]) => (
<div key={key} className="flex justify-between gap-4">
<dt className="text-sm text-muted-foreground capitalize shrink-0">
{key.replace(/_/g, ' ')}
</dt>
<dd className="text-sm font-medium">{String(value)}</dd>
<dd className="text-sm font-medium text-right">{String(value)}</dd>
</div>
))}
</dl>
</div>
)}
)
})()}
{/* Meta info row */}
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground border-t pt-4 mt-4">
@@ -235,53 +342,6 @@ export default function ApplicantDashboardPage() {
</AnimatedCard>
)}
{/* Quick actions */}
{!isRejected && (
<AnimatedCard index={2}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<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">
<div className="rounded-xl bg-blue-500/10 p-2.5 transition-colors group-hover:bg-blue-500/20">
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<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>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
<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 */}
{docCompleteness && docCompleteness.length > 0 && (
@@ -318,7 +378,7 @@ export default function ApplicantDashboardPage() {
{/* Sidebar */}
<div className="space-y-6">
{/* Competition timeline or status tracker */}
{/* Competition timeline */}
<AnimatedCard index={3}>
<Card>
<CardHeader>
@@ -330,7 +390,7 @@ export default function ApplicantDashboardPage() {
</Card>
</AnimatedCard>
{/* Mentoring Request Card — show when there's an active MENTORING round */}
{/* Mentoring Request Card */}
{project.isTeamLead && openRounds.filter((r) => r.roundType === 'MENTORING').map((mentoringRound) => (
<AnimatedCard key={mentoringRound.id} index={4}>
<MentoringRequestCard
@@ -348,7 +408,9 @@ export default function ApplicantDashboardPage() {
<CardHeader>
<div className="flex items-center justify-between">
<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
</CardTitle>
<Button variant="ghost" size="sm" asChild>
@@ -358,17 +420,53 @@ export default function ApplicantDashboardPage() {
</Button>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{totalEvaluations} evaluation{totalEvaluations !== 1 ? 's' : ''} available from{' '}
{evaluations?.length ?? 0} round{(evaluations?.length ?? 0) !== 1 ? 's' : ''}.
</p>
<CardContent className="space-y-3">
{evaluations?.map((round) => {
const scores = round.evaluations
.map((ev) => ev.globalScore)
.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>
</Card>
</AnimatedCard>
)}
{/* Team overview */}
{/* Team overview — proper cards */}
<AnimatedCard index={5}>
<Card>
<CardHeader>
@@ -384,27 +482,25 @@ export default function ApplicantDashboardPage() {
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
<CardContent className="space-y-2">
{project.teamMembers.length > 0 ? (
project.teamMembers.slice(0, 5).map((member) => (
<div key={member.id} className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
<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 shrink-0">
{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">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
<UserCircle className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{member.user.name || member.user.email}
</p>
<p className="text-xs text-muted-foreground">
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</p>
</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>
))
) : (
@@ -474,3 +570,69 @@ export default function ApplicantDashboardPage() {
</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'
import { useEffect } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
@@ -27,6 +26,7 @@ const ResourceRenderer = dynamic(
export default function ApplicantResourceDetailPage() {
const params = useParams()
const router = useRouter()
const resourceId = params.id as string
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.
</AlertDescription>
</Alert>
<Button asChild>
<Link href="/applicant/resources">
<Button onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Resources
</Link>
Back
</Button>
</div>
)
@@ -87,11 +85,9 @@ export default function ApplicantResourceDetailPage() {
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/applicant/resources">
<Button variant="ghost" onClick={() => router.back()} className="-ml-4">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Resources
</Link>
Back
</Button>
<div className="flex items-center gap-2">
{resource.externalUrl && (

View File

@@ -68,8 +68,10 @@ import {
GraduationCap,
Heart,
Calendar,
Pencil,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import { CountryDisplay } from '@/components/shared/country-display'
const inviteSchema = z.object({
name: z.string().min(1, 'Name is required'),
@@ -243,14 +245,31 @@ export default function ApplicantProjectPage() {
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
{/* Project logo */}
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden">
{/* 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>
<h1 className="text-2xl font-semibold tracking-tight">
{project.title}
@@ -314,7 +333,7 @@ export default function ApplicantProjectPage() {
<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.country}</p>
<p className="text-sm">{project.geographicZone}{project.geographicZone && project.country ? ', ' : ''}{project.country ? <CountryDisplay country={project.country} /> : null}</p>
</div>
</div>
)}
@@ -365,7 +384,7 @@ export default function ApplicantProjectPage() {
</Card>
{/* Project Logo */}
{isTeamLead && projectId && (
{projectId && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">

View File

@@ -18,6 +18,52 @@ import { AnimatedCard } from '@/components/shared/animated-container'
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() {
const [state, setState] = useState<InviteState>('loading')
const [errorType, setErrorType] = useState<string | null>(null)
@@ -105,18 +151,21 @@ function AcceptInviteContent() {
icon: <XCircle className="h-6 w-6 text-red-600" />,
title: 'Invalid Invitation',
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':
return {
icon: <Clock className="h-6 w-6 text-amber-600" />,
title: 'Invitation Expired',
description: 'This invitation has expired. Please contact your administrator to receive a new invitation.',
redirect: '/login?expired=1',
}
case 'ALREADY_ACCEPTED':
return {
icon: <CheckCircle2 className="h-6 w-6 text-blue-600" />,
title: 'Already Accepted',
description: 'This invitation has already been accepted. You can sign in with your credentials.',
redirect: '/login',
}
case 'AUTH_FAILED':
return {
@@ -148,34 +197,12 @@ function AcceptInviteContent() {
)
}
// Error state
// Error state — auto-redirect to login after 4 seconds for known errors
if (state === 'error') {
const errorContent = getErrorContent()
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>
<Button
variant="outline"
className="w-full"
onClick={() => router.push('/login')}
>
Go to Login
</Button>
</CardContent>
</Card>
</AnimatedCard>
)
const redirectTarget = errorContent.redirect || '/login'
return <ErrorRedirectCard errorContent={errorContent} redirectTarget={redirectTarget} />
}
// Valid invitation - show welcome

View File

@@ -1,24 +1,36 @@
'use client'
import { useSearchParams } from 'next/navigation'
import { useSearchParams, useRouter } from 'next/navigation'
import { useEffect, Suspense } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/shared/logo'
import { AlertCircle } from 'lucide-react'
import { AlertCircle, Clock, Loader2 } from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
const errorMessages: Record<string, string> = {
Configuration: 'There is a problem with the server configuration.',
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.',
}
export default function AuthErrorPage() {
function AuthErrorContent() {
const searchParams = useSearchParams()
const router = useRouter()
const error = searchParams.get('error') || '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 (
<AnimatedCard>
@@ -29,22 +41,56 @@ export default function AuthErrorPage() {
<Logo variant="small" />
</div>
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10">
{isExpired ? (
<Clock className="h-6 w-6 text-amber-600" />
) : (
<AlertCircle className="h-6 w-6 text-destructive" />
)}
</div>
<CardTitle className="text-xl">Authentication Error</CardTitle>
<CardTitle className="text-xl">
{isExpired ? 'Link Expired' : 'Authentication Error'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-muted-foreground">{message}</p>
{isExpired && (
<p className="text-xs text-muted-foreground">
Redirecting to login in 5 seconds...
</p>
)}
<div className="flex gap-3 justify-center border-t pt-4">
<Button asChild>
<Link href="/login">Return to Login</Link>
<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>
)
}
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

@@ -26,7 +26,11 @@ export default async function AuthLayout({
})
if (dbUser) {
// If user hasn't completed onboarding, don't redirect away from auth pages.
// The /onboarding page lives in this (auth) layout, so they need to stay here.
if (!dbUser.onboardingCompletedAt) {
// Fall through — let them access /onboarding (and other auth pages)
} else {
const role = session.user.role
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
redirect('/admin')
@@ -40,6 +44,7 @@ export default async function AuthLayout({
redirect('/applicant')
}
}
}
// If user doesn't exist in DB, fall through and show auth page
}

View File

@@ -1,8 +1,11 @@
'use client'
import { useState } from 'react'
import type { Route } from 'next'
import { useSearchParams, useRouter } from 'next/navigation'
import { signIn } from 'next-auth/react'
import Image from 'next/image'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -13,7 +16,7 @@ import {
CardHeader,
CardTitle,
} 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'
type LoginMode = 'password' | 'magic-link'
@@ -30,6 +33,7 @@ export default function LoginPage() {
const router = useRouter()
const callbackUrl = searchParams.get('callbackUrl') || '/'
const errorParam = searchParams.get('error')
const isExpiredLink = searchParams.get('expired') === '1'
const handlePasswordLogin = async (e: React.FormEvent) => {
e.preventDefault()
@@ -67,6 +71,19 @@ export default function LoginPage() {
setError(null)
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
const csrfRes = await fetch('/api/auth/csrf')
const { csrfToken } = await csrfRes.json()
@@ -149,6 +166,15 @@ export default function LoginPage() {
<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="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>
<CardDescription>
{mode === 'password'
@@ -157,6 +183,17 @@ export default function LoginPage() {
</CardDescription>
</CardHeader>
<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' ? (
// Password login form
<form onSubmit={handlePasswordLogin} className="space-y-4">
@@ -192,16 +229,12 @@ export default function LoginPage() {
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<button
type="button"
<Link
href={'/forgot-password' as Route}
className="text-sm text-muted-foreground hover:text-primary transition-colors"
onClick={() => {
setMode('magic-link')
setError(null)
}}
>
Forgot password?
</button>
</Link>
</div>
<Input
id="password"
@@ -302,6 +335,12 @@ export default function LoginPage() {
</>
)}
</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>
</CardContent>
</Card>

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

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

View File

@@ -1,6 +1,6 @@
'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'
@@ -8,12 +8,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
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 { JurorProgressDashboard } from '@/components/jury/juror-progress-dashboard'
export default function JuryRoundDetailPage() {
const params = useParams()
const router = useRouter()
const roundId = params.roundId as string
const { data: assignments, isLoading } = trpc.roundAssignment.getMyAssignments.useQuery(
@@ -38,11 +39,9 @@ export default function JuryRoundDetailPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={'/jury/competitions' as Route} aria-label="Back to competitions list">
<Button variant="ghost" size="sm" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
<div>
<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'
return (
<Link
<div
key={assignment.id}
href={`/jury/competitions/${roundId}/projects/${assignment.projectId}` as Route}
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"
role="button"
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">
<p className="font-medium truncate">{assignment.project.title}</p>
@@ -97,12 +99,26 @@ export default function JuryRoundDetailPage() {
)}
</div>
</div>
<div className="flex items-center gap-3 ml-4">
<div className="flex items-center gap-2 ml-4">
{isCompleted ? (
<>
<Badge variant="default" className="bg-emerald-50 text-emerald-700 border-emerald-200">
<CheckCircle2 className="mr-1 h-3 w-3" />
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 ? (
<Badge variant="secondary" className="bg-amber-50 text-amber-700 border-amber-200">
<Clock className="mr-1 h-3 w-3" />
@@ -115,7 +131,7 @@ export default function JuryRoundDetailPage() {
</Badge>
)}
</div>
</Link>
</div>
)
})}
</div>

View File

@@ -16,7 +16,7 @@ import { cn } from '@/lib/utils'
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
import { Badge } from '@/components/ui/badge'
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 type { EvaluationConfig } from '@/types/competition-configs'
@@ -468,8 +468,10 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
// Check if round is 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 (
<div className="space-y-6">
<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
if (coiRequired && myAssignment && !coiLoading && !coiDeclared) {
// Read-only view for submitted evaluations in closed rounds
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 (
<div className="space-y-6">
<div className="flex items-center gap-4">
@@ -533,8 +538,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
)
}
// COI conflict declared — block evaluation
if (coiRequired && coiConflict) {
// COI conflict declared — block evaluation (skip for read-only views)
if (coiRequired && !isReadOnly && coiConflict) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
@@ -578,15 +583,22 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
{isReadOnly ? (
<Button variant="ghost" size="sm" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</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>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
Evaluate Project
{isReadOnly ? 'Submitted Evaluation' : 'Evaluate Project'}
</h1>
<div className="flex items-center gap-2 mt-1">
<p className="text-muted-foreground">{project.title}</p>
@@ -606,9 +618,24 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</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 */}
<MultiWindowDocViewer roundId={roundId} projectId={projectId} />
{!isReadOnly && (
<Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-3 p-4">
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
@@ -621,6 +648,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
@@ -673,12 +701,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<div className="flex gap-4">
<button
type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, true)}
className={cn(
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
currentValue === true
? '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" />
@@ -686,12 +716,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</button>
<button
type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, false)}
className={cn(
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
currentValue === false
? '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" />
@@ -718,12 +750,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<div className="flex gap-3">
<button
type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, true)}
className={cn(
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
currentValue === true
? '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" />
@@ -731,12 +765,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</button>
<button
type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, false)}
className={cn(
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
currentValue === false
? '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" />
@@ -766,6 +802,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
placeholder={criterion.placeholder || 'Enter your response...'}
rows={4}
maxLength={criterion.maxLength}
disabled={isReadOnly}
/>
<p className="text-xs text-muted-foreground text-right">
{currentValue.length}/{criterion.maxLength}
@@ -807,6 +844,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
value={[sliderValue]}
onValueChange={(v) => handleCriterionChange(criterion.id, v[0])}
className="flex-1"
disabled={isReadOnly}
/>
<span className="text-xs text-muted-foreground w-4">{max}</span>
</div>
@@ -816,6 +854,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<button
key={num}
type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, num)}
className={cn(
'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'
: displayValue !== undefined && displayValue > num
? 'bg-primary/20 text-primary'
: 'bg-muted hover:bg-muted/80'
: 'bg-muted hover:bg-muted/80',
isReadOnly && 'cursor-default'
)}
>
{num}
@@ -856,6 +896,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
value={[globalScore ? parseInt(globalScore, 10) : 5]}
onValueChange={(v) => handleGlobalScoreChange(v[0].toString())}
className="flex-1"
disabled={isReadOnly}
/>
<span className="text-xs text-muted-foreground">10</span>
</div>
@@ -866,6 +907,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<button
key={num}
type="button"
disabled={isReadOnly}
onClick={() => handleGlobalScoreChange(num.toString())}
className={cn(
'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'
: current > num
? 'bg-primary/20 text-primary'
: 'bg-muted hover:bg-muted/80'
: 'bg-muted hover:bg-muted/80',
isReadOnly && 'cursor-default'
)}
>
{num}
@@ -890,7 +933,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<Label>
Decision <span className="text-destructive">*</span>
</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">
<RadioGroupItem value="accept" id="accept" />
<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)}
placeholder="Provide your feedback on the project..."
rows={8}
disabled={isReadOnly}
/>
{requireFeedback && (
<p className="text-xs text-muted-foreground">
@@ -931,6 +975,17 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</CardContent>
</Card>
{isReadOnly ? (
<div className="flex items-center">
<Button
variant="outline"
onClick={() => router.back()}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</div>
) : (
<div className="flex items-center justify-between flex-wrap gap-4">
<Button
variant="outline"
@@ -957,6 +1012,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</Button>
</div>
</div>
)}
</div>
)
}

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
'use client'
import { useEffect } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
@@ -27,6 +26,7 @@ const ResourceRenderer = dynamic(
export default function JuryResourceDetailPage() {
const params = useParams()
const router = useRouter()
const resourceId = params.id as string
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.
</AlertDescription>
</Alert>
<Button asChild>
<Link href="/jury/learning">
<Button onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Learning Hub
</Link>
Back
</Button>
</div>
)
@@ -87,11 +85,9 @@ export default function JuryResourceDetailPage() {
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/learning">
<Button variant="ghost" onClick={() => router.back()} className="-ml-4">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Learning Hub
</Link>
Back
</Button>
<div className="flex items-center gap-2">
{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">
<BarChart3 className="h-4 w-4 text-brand-teal" />
</div>
<CardTitle className="text-lg">Stage Summary</CardTitle>
<CardTitle className="text-lg">Round Summary</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
'use client'
import { useEffect } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
@@ -27,6 +26,7 @@ const ResourceRenderer = dynamic(
export default function MentorResourceDetailPage() {
const params = useParams()
const router = useRouter()
const resourceId = params.id as string
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.
</AlertDescription>
</Alert>
<Button asChild>
<Link href="/mentor/resources">
<Button onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Resources
</Link>
Back
</Button>
</div>
)
@@ -87,11 +85,9 @@ export default function MentorResourceDetailPage() {
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/mentor/resources">
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Resources
</Link>
Back
</Button>
<div className="flex items-center gap-2">
{resource.externalUrl && (

View File

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

View File

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

View File

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

View File

@@ -203,10 +203,8 @@ export default function TeamManagementPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href={`/my-submission/${projectId}`}>
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-5 w-5" />
</Link>
</Button>
<div>
<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 || [])
setDigestFrequency(user.digestFrequency || 'none')
setPreferredWorkload(user.preferredWorkload ?? null)
const avail = user.availabilityJson as { startDate?: string; endDate?: string } | null
if (avail) {
setAvailabilityStart(avail.startDate || '')
setAvailabilityEnd(avail.endDate || '')
const avail = user.availabilityJson as Array<{ start?: string; end?: string }> | null
if (avail && avail.length > 0) {
setAvailabilityStart(avail[0].start || '')
setAvailabilityEnd(avail[0].end || '')
}
setProfileLoaded(true)
}
@@ -114,10 +114,10 @@ export default function ProfileSettingsPage() {
expertiseTags,
digestFrequency: digestFrequency as 'none' | 'daily' | 'weekly',
preferredWorkload: preferredWorkload ?? undefined,
availabilityJson: (availabilityStart || availabilityEnd) ? {
startDate: availabilityStart || undefined,
endDate: availabilityEnd || undefined,
} : undefined,
availabilityJson: (availabilityStart || availabilityEnd) ? [{
start: availabilityStart || '',
end: availabilityEnd || '',
}] : undefined,
})
toast.success('Profile updated successfully')
refetch()

View File

@@ -4,6 +4,9 @@ import { checkRateLimit } from '@/lib/rate-limit'
const AUTH_RATE_LIMIT = 10 // requests per window
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 {
return (
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
@@ -12,10 +15,8 @@ function getClientIp(req: Request): string {
)
}
function withRateLimit(handler: (req: Request) => Promise<Response>) {
function withPostRateLimit(handler: (req: Request) => Promise<Response>) {
return async (req: Request) => {
// Only rate limit POST requests (sign-in, magic link sends)
if (req.method === 'POST') {
const ip = getClientIp(req)
const { success, resetAt } = checkRateLimit(`auth:${ip}`, AUTH_RATE_LIMIT, AUTH_RATE_WINDOW_MS)
@@ -28,11 +29,33 @@ function withRateLimit(handler: (req: Request) => Promise<Response>) {
},
})
}
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 { success, resetAt } = checkRateLimit(`csrf:${ip}`, CSRF_RATE_LIMIT, CSRF_RATE_WINDOW_MS)
if (!success) {
return new Response(JSON.stringify({ error: 'Too many requests' }), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(Math.ceil((resetAt - Date.now()) / 1000)),
},
})
}
}
return handler(req)
}
}
export const GET = handlers.GET
export const POST = withRateLimit(handlers.POST as (req: Request) => Promise<Response>)
export const GET = withGetRateLimit(handlers.GET 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

@@ -1,9 +1,19 @@
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/lib/auth'
export const dynamic = 'force-dynamic'
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 sessionId = searchParams.get('sessionId')

View File

@@ -1,8 +1,10 @@
import type { Metadata } from 'next'
import Script from "next/script";
import './globals.css'
import { Providers } from './providers'
import { Toaster } from 'sonner'
import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
import { VersionGuard } from '@/components/shared/version-guard'
export const metadata: Metadata = {
title: {
@@ -22,8 +24,24 @@ export default function RootLayout({
}>) {
return (
<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">
<Providers>
<VersionGuard />
<ImpersonationBanner />
{children}
</Providers>

View File

@@ -5,6 +5,7 @@ import Link from 'next/link'
import { useSearchParams, usePathname } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import { CountryDisplay } from '@/components/shared/country-display'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
@@ -28,6 +29,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { UserAvatar } from '@/components/shared/user-avatar'
import { UserActions, UserMobileActions } from '@/components/admin/user-actions'
import { Pagination } from '@/components/shared/pagination'
import { SortableHeader } from '@/components/shared/sortable-header'
import { Plus, Users, Search, Mail, Loader2, X, Send } from 'lucide-react'
import { toast } from 'sonner'
import { formatRelativeTime } from '@/lib/utils'
@@ -138,6 +140,18 @@ export function MembersContent() {
const roles = TAB_ROLES[tab]
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [sortBy, setSortBy] = useState<string | undefined>(undefined)
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const handleSort = (column: string) => {
if (sortBy === column) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortBy(column)
setSortDir('asc')
}
updateParams({ page: '1' })
}
const { data: currentUser } = trpc.user.me.useQuery()
const currentUserRole = currentUser?.role as RoleValue | undefined
@@ -147,6 +161,8 @@ export function MembersContent() {
search: search || undefined,
page,
perPage: 20,
sortBy: sortBy as 'name' | 'email' | 'role' | 'status' | 'lastLoginAt' | 'createdAt' | undefined,
sortDir: sortBy ? sortDir : undefined,
})
const invitableIdsQuery = trpc.user.listInvitableIds.useQuery(
@@ -290,6 +306,17 @@ export function MembersContent() {
<MembersSkeleton />
) : data && data.users.length > 0 ? (
<>
{/* Top Pagination */}
{data.totalPages > 1 && (
<Pagination
page={page}
totalPages={data.totalPages}
total={data.total}
perPage={data.perPage}
onPageChange={(newPage) => updateParams({ page: String(newPage) })}
/>
)}
{/* Bulk selection controls */}
<Card>
<CardContent className="py-3 flex flex-wrap items-center justify-between gap-2">
@@ -334,12 +361,12 @@ export function MembersContent() {
/>
)}
</TableHead>
<TableHead>Member</TableHead>
<TableHead>Role</TableHead>
<SortableHeader label="Member" column="name" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<SortableHeader label="Role" column="role" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<TableHead>Expertise</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Login</TableHead>
<SortableHeader label="Status" column="status" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<SortableHeader label="Last Login" column="lastLoginAt" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -354,19 +381,19 @@ export function MembersContent() {
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<Link href={`/admin/members/${user.id}`} className="flex items-center gap-3 hover:opacity-80">
<UserAvatar
user={user}
avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined}
size="sm"
/>
<div>
<p className="font-medium">{user.name || 'Unnamed'}</p>
<p className="font-medium hover:underline">{user.name || 'Unnamed'}</p>
<p className="text-sm text-muted-foreground">
{user.email}
</p>
</div>
</div>
</Link>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
@@ -400,7 +427,28 @@ export function MembersContent() {
</TableCell>
<TableCell>
<div>
{user.role === 'MENTOR' ? (
{user.role === 'APPLICANT' ? (
(() => {
const info = (user as unknown as { applicantRoundInfo?: { projectName: string; roundName: string; state: string } | null }).applicantRoundInfo
if (!info) return <span className="text-sm text-muted-foreground">-</span>
const stateColor = info.state === 'REJECTED' ? 'destructive' as const
: info.state === 'WITHDRAWN' ? 'secondary' as const
: info.state === 'PASSED' ? 'success' as const
: 'outline' as const
const stateLabel = info.state === 'REJECTED' ? 'Rejected'
: info.state === 'WITHDRAWN' ? 'Withdrawn'
: info.state === 'PASSED' ? 'Passed'
: info.roundName
return (
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium truncate max-w-[200px]">{info.projectName}</span>
<Badge variant={stateColor} className="w-fit text-[10px] px-1.5 py-0">
{stateLabel}
</Badge>
</div>
)
})()
) : user.role === 'MENTOR' ? (
<p>{(user as unknown as { _count: { mentorAssignments: number; assignments: number } })._count.mentorAssignments} mentored</p>
) : (
<p>{(user as unknown as { _count: { mentorAssignments: number; assignments: number } })._count.assignments} assigned</p>
@@ -460,14 +508,14 @@ export function MembersContent() {
avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined}
size="md"
/>
<div>
<CardTitle className="text-base">
<Link href={`/admin/members/${user.id}`}>
<CardTitle className="text-base hover:underline">
{user.name || 'Unnamed'}
</CardTitle>
<CardDescription className="text-xs">
{user.email}
</CardDescription>
</div>
</Link>
</div>
<div className="flex flex-col items-end gap-1.5">
<Badge variant={statusColors[user.status] || 'secondary'}>
@@ -494,9 +542,32 @@ export function MembersContent() {
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span>
<span className="text-muted-foreground">
{user.role === 'APPLICANT' ? 'Project' : 'Assignments'}
</span>
<span>
{user.role === 'MENTOR'
{user.role === 'APPLICANT' ? (
(() => {
const info = (user as unknown as { applicantRoundInfo?: { projectName: string; roundName: string; state: string } | null }).applicantRoundInfo
if (!info) return <span className="text-muted-foreground">-</span>
const stateColor = info.state === 'REJECTED' ? 'destructive' as const
: info.state === 'WITHDRAWN' ? 'secondary' as const
: info.state === 'PASSED' ? 'success' as const
: 'outline' as const
const stateLabel = info.state === 'REJECTED' ? 'Rejected'
: info.state === 'WITHDRAWN' ? 'Withdrawn'
: info.state === 'PASSED' ? 'Passed'
: info.roundName
return (
<span className="flex flex-col items-end gap-0.5">
<span className="truncate max-w-[160px]">{info.projectName}</span>
<Badge variant={stateColor} className="text-[10px] px-1.5 py-0">
{stateLabel}
</Badge>
</span>
)
})()
) : user.role === 'MENTOR'
? `${(user as unknown as { _count: { mentorAssignments: number; assignments: number } })._count.mentorAssignments} mentored`
: `${(user as unknown as { _count: { mentorAssignments: number; assignments: number } })._count.assignments} assigned`}
</span>
@@ -681,6 +752,17 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search:
return (
<>
{/* Top Pagination */}
{data.totalPages > 1 && (
<Pagination
page={page}
totalPages={data.totalPages}
total={data.total}
perPage={data.perPage}
onPageChange={setPage}
/>
)}
{/* Desktop table */}
<Card className="hidden md:block">
<Table>
@@ -695,6 +777,8 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search:
</TableHead>
<TableHead>Applicant</TableHead>
<TableHead>Project</TableHead>
<TableHead>Nationality</TableHead>
<TableHead>Institution</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Login</TableHead>
</TableRow>
@@ -709,10 +793,10 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search:
/>
</TableCell>
<TableCell>
<div>
<p className="font-medium">{user.name || 'Unnamed'}</p>
<Link href={`/admin/members/${user.id}`} className="block">
<p className="font-medium hover:underline">{user.name || 'Unnamed'}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
</Link>
</TableCell>
<TableCell>
{user.projectName ? (
@@ -721,6 +805,12 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search:
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<span className="text-sm">{user.nationality ? <CountryDisplay country={user.nationality} /> : <span className="text-muted-foreground">-</span>}</span>
</TableCell>
<TableCell>
<span className="text-sm">{user.institution || <span className="text-muted-foreground">-</span>}</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Badge variant={statusColors[user.status] || 'secondary'}>

View File

@@ -35,6 +35,7 @@ import {
AlertTriangle,
Search,
} from 'lucide-react'
import { CountryDisplay } from '@/components/shared/country-display'
type AwardShortlistProps = {
awardId: string
@@ -342,7 +343,13 @@ export function AwardShortlist({
</a>
</p>
<p className="text-xs text-muted-foreground">
{[e.project.teamName, e.project.country, e.project.competitionCategory].filter(Boolean).join(', ') || '—'}
{[e.project.teamName, e.project.competitionCategory].filter(Boolean).length > 0 || e.project.country ? (
<>
{[e.project.teamName, e.project.competitionCategory].filter(Boolean).join(', ')}
{(e.project.teamName || e.project.competitionCategory) && e.project.country ? ', ' : ''}
{e.project.country && <CountryDisplay country={e.project.country} />}
</>
) : '—'}
</p>
</div>
</td>

View File

@@ -74,6 +74,7 @@ import { motion, AnimatePresence } from 'motion/react'
import Link from 'next/link'
import type { Route } from 'next'
import { AwardShortlist } from './award-shortlist'
import { CountryDisplay } from '@/components/shared/country-display'
type FilteringDashboardProps = {
competitionId: string
@@ -924,7 +925,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
</div>
<p className="text-xs text-muted-foreground truncate">
{result.project?.teamName}
{result.project?.country && ` \u00b7 ${result.project.country}`}
{result.project?.country && <> · <CountryDisplay country={result.project.country} /></>}
</p>
</div>
</div>

View File

@@ -44,6 +44,7 @@ import {
import { cn } from '@/lib/utils'
import { projectStateConfig } from '@/lib/round-config'
import { EmailPreviewDialog } from './email-preview-dialog'
import { CountryDisplay } from '@/components/shared/country-display'
// ── Types ──────────────────────────────────────────────────────────────────
@@ -233,7 +234,7 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
{project.category === 'STARTUP' ? 'Startup' : project.category === 'BUSINESS_CONCEPT' ? 'Concept' : project.category ?? '-'}
</td>
<td className="px-3 py-2.5 hidden md:table-cell text-muted-foreground">
{project.country ?? '-'}
{project.country ? <CountryDisplay country={project.country} /> : '-'}
</td>
<td className="px-3 py-2.5 text-center">
<Badge variant="secondary" className={cn('text-xs', stateLabelColors[project.currentState] ?? '')}>

View File

@@ -63,6 +63,7 @@ import {
} from 'lucide-react'
import Link from 'next/link'
import type { Route } from 'next'
import { CountryDisplay } from '@/components/shared/country-display'
const PROJECT_STATES = ['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'] as const
type ProjectState = (typeof PROJECT_STATES)[number]
@@ -448,7 +449,7 @@ export function ProjectStatesTable({ competitionId, roundId, roundStatus, compet
</Badge>
</div>
<div className="text-xs text-muted-foreground truncate">
{ps.project?.country || '—'}
{ps.project?.country ? <CountryDisplay country={ps.project.country} /> : '—'}
</div>
<div>
<Badge variant="outline" className={`text-xs ${cfg.color}`}>
@@ -1087,7 +1088,7 @@ function AddProjectDialog({
<p className="text-sm font-medium truncate">{project.title}</p>
<p className="text-xs text-muted-foreground truncate">
{project.teamName}
{project.country && <> &middot; {project.country}</>}
{project.country && <> &middot; <CountryDisplay country={project.country} /></>}
</p>
</div>
{project.competitionCategory && (
@@ -1237,7 +1238,7 @@ function AddProjectDialog({
<p className="text-sm font-medium truncate">{project.title}</p>
<p className="text-xs text-muted-foreground truncate">
{project.teamName}
{project.country && <> &middot; {project.country}</>}
{project.country && <> &middot; <CountryDisplay country={project.country} /></>}
</p>
</div>
<div className="flex items-center gap-1.5 ml-2 shrink-0">

View File

@@ -55,6 +55,7 @@ import {
Download,
} from 'lucide-react'
import type { RankedProjectEntry } from '@/server/services/ai-ranking'
import { CountryDisplay } from '@/components/shared/country-display'
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -163,7 +164,7 @@ function SortableProjectRow({
{projectInfo?.teamName && (
<p className="text-xs text-muted-foreground truncate">
{projectInfo.teamName}
{projectInfo.country ? ` · ${projectInfo.country}` : ''}
{projectInfo.country ? <> · <CountryDisplay country={projectInfo.country} /></> : ''}
</p>
)}
</div>

View File

@@ -3,71 +3,19 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
type SubmissionConfigProps = {
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
}
const STATUSES = [
{ value: 'PENDING', label: 'Pending', color: 'bg-gray-100 text-gray-700' },
{ value: 'IN_PROGRESS', label: 'In Progress', color: 'bg-blue-100 text-blue-700' },
{ value: 'PASSED', label: 'Passed', color: 'bg-emerald-100 text-emerald-700' },
{ value: 'REJECTED', label: 'Rejected', color: 'bg-red-100 text-red-700' },
{ value: 'COMPLETED', label: 'Completed', color: 'bg-purple-100 text-purple-700' },
{ value: 'WITHDRAWN', label: 'Withdrawn', color: 'bg-amber-100 text-amber-700' },
]
export function SubmissionConfig({ config, onChange }: SubmissionConfigProps) {
const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
const eligible = (config.eligibleStatuses as string[]) ?? ['PASSED']
const toggleStatus = (status: string) => {
const current = [...eligible]
const idx = current.indexOf(status)
if (idx >= 0) {
current.splice(idx, 1)
} else {
current.push(status)
}
update('eligibleStatuses', current)
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Submission Eligibility</CardTitle>
<CardDescription>
Which project states from the previous round are eligible to submit documents in this round
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Eligible Project Statuses</Label>
<p className="text-xs text-muted-foreground">
Projects with these statuses from the previous round can submit
</p>
<div className="flex flex-wrap gap-2">
{STATUSES.map((s) => (
<Badge
key={s.value}
variant={eligible.includes(s.value) ? 'default' : 'outline'}
className="cursor-pointer select-none"
onClick={() => toggleStatus(s.value)}
>
{s.label}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Notifications & Locking</CardTitle>

View File

@@ -3,8 +3,10 @@
import { useState, useMemo } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { CountryDisplay } from '@/components/shared/country-display'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -55,6 +57,7 @@ type SemiFinalistsContentProps = {
}
export function SemiFinalistsContent({ editionId }: SemiFinalistsContentProps) {
const router = useRouter()
const { data, isLoading } = trpc.dashboard.getSemiFinalistDetail.useQuery(
{ editionId },
{ enabled: !!editionId }
@@ -116,11 +119,9 @@ export function SemiFinalistsContent({ editionId }: SemiFinalistsContentProps) {
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href={'/admin' as Route}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold tracking-tight md:text-2xl">
Semi-Finalists
@@ -214,7 +215,7 @@ export function SemiFinalistsContent({ editionId }: SemiFinalistsContentProps) {
{categoryLabels[project.category ?? ''] ?? project.category}
</Badge>
</TableCell>
<TableCell className="text-sm">{project.country || '—'}</TableCell>
<TableCell className="text-sm">{project.country ? <CountryDisplay country={project.country} /> : '—'}</TableCell>
<TableCell className="text-sm">{project.currentRound}</TableCell>
<TableCell>
<TooltipProvider>

View File

@@ -6,6 +6,7 @@ import type { Route } from 'next'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { directSessionUpdate } from '@/lib/session-update'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@@ -71,7 +72,7 @@ function getRoleHomePath(role: string): string {
export function UserActions({ userId, userEmail, userStatus, userRole, userRoles, currentUserRole }: UserActionsProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isSending, setIsSending] = useState(false)
const { data: session, update } = useSession()
const { data: session } = useSession()
const router = useRouter()
const utils = trpc.useUtils()
@@ -125,10 +126,13 @@ export function UserActions({ userId, userEmail, userStatus, userRole, userRoles
const handleImpersonate = async () => {
try {
const result = await startImpersonation.mutateAsync({ targetUserId: userId })
await update({ impersonate: userId })
toast.success(`Now impersonating ${userEmail}`)
router.push(getRoleHomePath(result.targetRole) as Route)
router.refresh()
// Direct POST to session endpoint — bypasses useSession().update()'s loading gate
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')
}
@@ -279,7 +283,7 @@ export function UserMobileActions({
currentUserRole,
}: UserMobileActionsProps) {
const [isSending, setIsSending] = useState(false)
const { data: session, update } = useSession()
const { data: session } = useSession()
const router = useRouter()
const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation()
@@ -301,10 +305,12 @@ export function UserMobileActions({
const handleImpersonateMobile = async () => {
try {
const result = await startImpersonation.mutateAsync({ targetUserId: userId })
await update({ impersonate: userId })
toast.success(`Now impersonating ${userEmail}`)
router.push(getRoleHomePath(result.targetRole) as Route)
router.refresh()
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')
}

View File

@@ -202,13 +202,35 @@ export function CompetitionTimelineSidebar() {
// Is this entry after the elimination point?
const isAfterElimination = eliminationIndex >= 0 && index > eliminationIndex
// Is this the current round the project is in (regardless of round status)?
const isCurrent = !!entry.projectState && entry.projectState !== 'PASSED' && entry.projectState !== 'COMPLETED' && entry.projectState !== 'REJECTED'
// Is this the current round? Either has an active project state,
// or is the first round the project hasn't passed yet (for seed data
// where project states may be missing).
const hasActiveProjectState = !!entry.projectState && entry.projectState !== 'PASSED' && entry.projectState !== 'COMPLETED' && entry.projectState !== 'REJECTED'
const isCurrent = !isAfterElimination && (hasActiveProjectState || (
!isPassed && !isRejected && !isCompleted &&
data.entries.slice(0, index).every((prev) =>
prev.projectState === 'PASSED' || prev.projectState === 'COMPLETED' ||
prev.status === 'ROUND_CLOSED' || prev.status === 'ROUND_ARCHIVED'
) && index > 0
))
// Determine connector segment color (no icons, just colored lines)
// Connector color: green up to and including the current round,
// red leading into the rejected round, neutral after.
let connectorColor = 'bg-border'
if ((isPassed || isCompleted) && !isAfterElimination) connectorColor = 'bg-emerald-400'
else if (isRejected) connectorColor = 'bg-destructive/30'
const nextEntry = data.entries[index + 1]
const nextIsRejected = nextEntry?.projectState === 'REJECTED'
if (isAfterElimination) {
connectorColor = 'bg-border'
} else if (isRejected) {
// From rejected round onward = neutral
connectorColor = 'bg-border'
} else if (nextIsRejected) {
// Connector leading INTO the rejected round = red
connectorColor = 'bg-destructive/40'
} else if (isCompleted || isPassed) {
// Rounds the project has passed through = green
connectorColor = 'bg-emerald-400'
}
// Dot inner content
let dotInner: React.ReactNode = null
@@ -222,7 +244,7 @@ export function CompetitionTimelineSidebar() {
} else if (isGrandFinale && (isCompleted || isPassed)) {
dotClasses = 'bg-yellow-500 border-2 border-yellow-500'
dotInner = <Trophy className="h-3.5 w-3.5 text-white" />
} else if (isCompleted || isPassed) {
} else if (isPassed || (isCompleted && !isCurrent)) {
dotClasses = 'bg-emerald-500 border-2 border-emerald-500'
dotInner = <Check className="h-3.5 w-3.5 text-white" />
} else if (isCurrent) {

View File

@@ -1,7 +1,18 @@
'use client'
import { BarChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { scoreGradient } from './chart-theme'
import { ArrowRight } from 'lucide-react'
interface StageComparison {
roundId: string
@@ -30,99 +41,115 @@ export function CrossStageComparisonChart({
)
}
const baseData = data.map((round) => ({
name: round.roundName,
Projects: round.projectCount,
Evaluations: round.evaluationCount,
'Completion Rate': round.completionRate,
'Avg Score': round.averageScore
? parseFloat(round.averageScore.toFixed(2))
: 0,
}))
const maxProjects = Math.max(...data.map((d) => d.projectCount), 1)
return (
<Card>
<CardHeader>
<CardTitle>Round Metrics Comparison</CardTitle>
<CardTitle className="text-base">Round Progression</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<BarChart
data={baseData}
index="name"
categories={['Projects']}
colors={['blue']}
showLegend={false}
yAxisWidth={40}
className="h-[200px]"
/>
</CardContent>
</Card>
{/* Pipeline funnel visualization */}
<div className="flex items-center gap-2 mb-6 overflow-x-auto pb-2">
{data.map((round, idx) => (
<div key={round.roundId} className="flex items-center gap-2">
<div className="flex flex-col items-center min-w-[100px]">
<div
className="rounded-lg bg-[#053d57] flex items-center justify-center text-white font-bold text-lg tabular-nums transition-all"
style={{
width: `${Math.max(60, (round.projectCount / maxProjects) * 120)}px`,
height: `${Math.max(40, (round.projectCount / maxProjects) * 60)}px`,
}}
>
{round.projectCount}
</div>
<p className="text-xs text-muted-foreground mt-1.5 text-center leading-tight max-w-[100px] truncate">
{round.roundName}
</p>
</div>
{idx < data.length - 1 && (
<div className="flex flex-col items-center shrink-0">
<ArrowRight className="h-4 w-4 text-muted-foreground" />
{data[idx + 1].projectCount < round.projectCount && (
<span className="text-[10px] text-rose-500 tabular-nums font-medium">
-{round.projectCount - data[idx + 1].projectCount}
</span>
)}
</div>
)}
</div>
))}
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Evaluations
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<BarChart
data={baseData}
index="name"
categories={['Evaluations']}
colors={['violet']}
showLegend={false}
yAxisWidth={40}
className="h-[200px]"
/>
</CardContent>
</Card>
{/* Detailed metrics table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Round</TableHead>
<TableHead className="text-right tabular-nums">Projects</TableHead>
<TableHead className="text-right tabular-nums">Evaluations</TableHead>
<TableHead>Completion</TableHead>
<TableHead className="text-right">Avg Score</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((round, idx) => {
const prevCount = idx > 0 ? data[idx - 1].projectCount : null
const attrition = prevCount !== null && prevCount > 0
? Math.round(((prevCount - round.projectCount) / prevCount) * 100)
: null
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Completion Rate
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<BarChart
data={baseData}
index="name"
categories={['Completion Rate']}
colors={['emerald']}
showLegend={false}
maxValue={100}
yAxisWidth={40}
valueFormatter={(v) => `${v}%`}
className="h-[200px]"
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Average Score
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<BarChart
data={baseData}
index="name"
categories={['Avg Score']}
colors={['amber']}
showLegend={false}
maxValue={10}
yAxisWidth={40}
className="h-[200px]"
/>
</CardContent>
</Card>
return (
<TableRow key={round.roundId}>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{round.roundName}</span>
{attrition !== null && attrition > 0 && (
<Badge variant="outline" className="text-[10px] text-rose-600 border-rose-200">
-{attrition}%
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{round.projectCount}
</TableCell>
<TableCell className="text-right tabular-nums">
{round.evaluationCount > 0 ? round.evaluationCount : '—'}
</TableCell>
<TableCell>
{round.evaluationCount > 0 ? (
<div className="flex items-center gap-2">
<Progress value={round.completionRate} className="w-16 h-2" />
<span className="text-xs tabular-nums text-muted-foreground">
{round.completionRate}%
</span>
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-right">
{round.averageScore !== null ? (
<span
className="inline-flex items-center justify-center rounded-md px-2 py-0.5 text-xs font-semibold tabular-nums min-w-[36px]"
style={{
backgroundColor: scoreGradient(round.averageScore),
color: '#ffffff',
}}
>
{round.averageScore.toFixed(1)}
</span>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>

View File

@@ -20,7 +20,7 @@ function getScoreColor(score: number | null): string {
function getTextColor(score: number | null): string {
if (score === null) return 'inherit'
return score >= 6 ? '#ffffff' : '#1a1a1a'
return '#ffffff'
}
function ScoreBadge({ score }: { score: number }) {
@@ -73,7 +73,6 @@ function JurorSummaryRow({
</td>
<td className="py-3 px-4 text-center tabular-nums text-sm">
{scored.length}
<span className="text-muted-foreground">/{projectCount}</span>
</td>
<td className="py-3 px-4 text-center">
{averageScore !== null ? (

View File

@@ -36,14 +36,16 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center">
<DonutChart
data={chartData}
category="value"
index="name"
colors={colors}
showLabel={true}
className="h-[300px]"
className="h-[250px] w-[250px]"
/>
</div>
</CardContent>
</Card>
)

View File

@@ -23,8 +23,8 @@ type ActivityFeedProps = {
export function ActivityFeed({ activity }: ActivityFeedProps) {
return (
<Card>
<CardHeader className="pb-3">
<Card className="flex flex-col overflow-hidden" style={{ maxHeight: 420 }}>
<CardHeader className="pb-3 shrink-0">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-blue/10">
<Activity className="h-4 w-4 text-brand-blue" />
@@ -32,7 +32,7 @@ export function ActivityFeed({ activity }: ActivityFeedProps) {
<CardTitle className="text-base">Activity</CardTitle>
</div>
</CardHeader>
<CardContent>
<CardContent className="overflow-y-auto min-h-0">
{activity.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Activity className="h-8 w-8 text-muted-foreground/30" />

View File

@@ -12,7 +12,7 @@ import {
} from '@/components/ui/card'
import { StatusBadge } from '@/components/shared/status-badge'
import { ProjectLogo } from '@/components/shared/project-logo'
import { getCountryName } from '@/lib/countries'
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
import { formatDateOnly, truncate, formatRelativeTime } from '@/lib/utils'
type BaseProject = {
@@ -133,13 +133,18 @@ export function ProjectListCompact({
)}
</>
) : (
[
<>
{[
project.teamName,
project.country ? getCountryName(project.country) : null,
formatDateOnly(project.submittedAt || project.createdAt),
]
.filter(Boolean)
.join(' \u00b7 ')
].filter(Boolean).join(' \u00b7 ')}
{project.country && (() => {
const code = normalizeCountryToCode(project.country)
const flag = code ? getCountryFlag(code) : null
const name = code ? getCountryName(code) : project.country
return <> · {flag && <span className="mr-0.5">{flag}</span>}{name}</>
})()}
</>
)}
</p>
</div>

View File

@@ -0,0 +1,209 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Users,
Send,
CheckCircle2,
AlertCircle,
Loader2,
} from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
const categoryLabels: Record<string, string> = {
STARTUP: 'Startup',
BUSINESS_CONCEPT: 'Business Concept',
UNKNOWN: 'Unknown',
}
type RoundUserTrackerProps = {
editionId: string
}
export function RoundUserTracker({ editionId }: RoundUserTrackerProps) {
const [selectedRoundId, setSelectedRoundId] = useState<string | undefined>(undefined)
const { data, isLoading } = trpc.dashboard.getRoundUserStats.useQuery(
{ editionId, roundId: selectedRoundId },
{ enabled: !!editionId, refetchInterval: 120_000 }
)
const utils = trpc.useUtils()
const sendReminders = trpc.dashboard.sendAccountReminders.useMutation({
onSuccess: (result) => {
toast.success(`Sent ${result.sent} reminder${result.sent !== 1 ? 's' : ''}${result.failed > 0 ? `, ${result.failed} failed` : ''}`)
utils.dashboard.getRoundUserStats.invalidate()
},
onError: (err) => {
toast.error(`Failed to send reminders: ${err.message}`)
},
})
const [sendingTarget, setSendingTarget] = useState<string | null>(null)
if (isLoading || !data) return null
const { rounds, byCategory } = data
const effectiveRoundId = data.selectedRoundId
// Don't render if no rounds at all
if (rounds.length === 0) return null
const totalProjects = byCategory.reduce((sum, c) => sum + c.total, 0)
const totalActivated = byCategory.reduce((sum, c) => sum + c.accountsSet, 0)
const totalPending = byCategory.reduce((sum, c) => sum + c.accountsNotSet, 0)
const selectedRound = effectiveRoundId ? rounds.find(r => r.id === effectiveRoundId) : undefined
const handleSendReminder = async (target: string, opts: { category?: 'STARTUP' | 'BUSINESS_CONCEPT' }) => {
setSendingTarget(target)
try {
await sendReminders.mutateAsync({
editionId,
roundId: effectiveRoundId!,
category: opts.category,
})
} finally {
setSendingTarget(null)
}
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-2">
<CardTitle className="flex items-center gap-2 text-base">
<Users className="h-4 w-4 text-brand-blue" />
Round User Tracker
</CardTitle>
{totalProjects > 0 && (
<Badge variant="outline" className="text-xs shrink-0">
{totalActivated}/{totalProjects} activated
</Badge>
)}
</div>
{/* Round selector */}
<Select
value={effectiveRoundId ?? ''}
onValueChange={(val) => setSelectedRoundId(val)}
>
<SelectTrigger className="h-8 text-xs mt-2">
<SelectValue placeholder="Select round" />
</SelectTrigger>
<SelectContent>
{rounds.map((r) => (
<SelectItem key={r.id} value={r.id} className="text-xs">
{r.name}
{r.status === 'ROUND_ACTIVE' && (
<span className="ml-1.5 text-emerald-600">(active)</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent className="space-y-4">
{totalProjects === 0 ? (
<div className="text-center py-4">
<Users className="h-8 w-8 text-muted-foreground/30 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
No projects have passed {selectedRound?.name ?? 'this round'} yet
</p>
</div>
) : (
<>
{/* Subtitle showing round context */}
<p className="text-xs text-muted-foreground">
Projects that passed <span className="font-medium">{selectedRound?.name ?? 'this round'}</span> account activation status
</p>
{/* Per-category rows */}
{byCategory.map((cat) => {
const pct = cat.total > 0 ? Math.round((cat.accountsSet / cat.total) * 100) : 0
const allSet = cat.accountsNotSet === 0
return (
<div key={cat.category} className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">
{categoryLabels[cat.category] || cat.category}
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{cat.accountsSet} of {cat.total} activated
</span>
{allSet ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
disabled={sendReminders.isPending}
onClick={() =>
handleSendReminder(`cat-${cat.category}`, {
category: cat.category as 'STARTUP' | 'BUSINESS_CONCEPT',
})
}
>
{sendingTarget === `cat-${cat.category}` ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Send className="mr-1 h-3 w-3" />
)}
Remind
</Button>
)}
</div>
</div>
<Progress value={pct} className="h-1.5" />
{cat.accountsNotSet > 0 && (
<p className="text-[11px] text-amber-600">
<AlertCircle className="mr-0.5 inline h-3 w-3" />
{cat.accountsNotSet} pending account setup
</p>
)}
</div>
)
})}
{/* Summary */}
<div className="border-t pt-3">
<div className="flex items-center justify-between text-sm">
<span className="font-semibold">
Total: {totalActivated} of {totalProjects} activated
</span>
{totalPending > 0 && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={sendReminders.isPending}
onClick={() => handleSendReminder('all', {})}
>
{sendingTarget === 'all' ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<Send className="mr-1.5 h-3 w-3" />
)}
Remind All ({totalPending})
</Button>
)}
</div>
</div>
</>
)}
</CardContent>
</Card>
)
}

View File

@@ -12,6 +12,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator'
@@ -315,26 +318,6 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
)}
</nav>
{/* Role Switcher — visible above user section */}
{switchableRoles.length > 0 && (
<div className="border-t px-3 py-2">
<p className="mb-1.5 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
<ArrowRightLeft className="h-3 w-3" />
Switch View
</p>
<div className="flex flex-wrap gap-1.5">
{switchableRoles.map(([, opt]) => (
<Link key={opt.path} href={opt.path as Route} onClick={() => setIsMobileMenuOpen(false)}>
<Button size="sm" variant="outline" className="h-7 gap-1.5 px-2.5 text-xs">
<opt.icon className="h-3 w-3" />
{opt.label}
</Button>
</Link>
))}
</div>
</div>
)}
{/* User Profile Section */}
<div className="border-t p-3">
<DropdownMenu>
@@ -393,12 +376,27 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
{switchableRoles.length > 0 && (
<>
<DropdownMenuSeparator className="my-1" />
<div className="px-2 py-1.5">
<p className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60">
<ArrowRightLeft className="h-3 w-3" />
Switch View
</p>
</div>
{switchableRoles.length <= 2 ? (
// Flat list for 1-2 roles
switchableRoles.map(([, opt]) => (
<DropdownMenuItem key={opt.path} asChild>
<Link
href={opt.path as Route}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
>
<opt.icon className="h-4 w-4 text-muted-foreground" />
<span>{opt.label}</span>
</Link>
</DropdownMenuItem>
))
) : (
// Submenu for 3+ roles
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex items-center gap-2.5 rounded-md px-2 py-2">
<ArrowRightLeft className="h-4 w-4 text-muted-foreground" />
<span>Switch View</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[160px]">
{switchableRoles.map(([, opt]) => (
<DropdownMenuItem key={opt.path} asChild>
<Link
@@ -410,6 +408,9 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
</Link>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</>
)}

View File

@@ -16,6 +16,8 @@ export function ApplicantNav({ user }: ApplicantNavProps) {
staleTime: 60_000,
})
const useExternal = featureFlags?.learningHubExternal && featureFlags.learningHubExternalUrl
const navigation: NavItem[] = [
{ name: 'Dashboard', href: '/applicant', icon: Home },
{ name: 'Project', href: '/applicant/team', icon: FolderOpen },
@@ -27,7 +29,12 @@ export function ApplicantNav({ user }: ApplicantNavProps) {
...(flags?.hasMentor
? [{ name: 'Mentoring', href: '/applicant/mentor', icon: MessageSquare }]
: []),
{ name: 'Resources', href: '/applicant/resources', icon: BookOpen },
{
name: 'Learning Hub',
href: useExternal ? featureFlags.learningHubExternalUrl : '/applicant/resources',
icon: BookOpen,
external: !!useExternal,
},
]
return (

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut, useSession } from 'next-auth/react'
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { UserAvatar } from '@/components/shared/user-avatar'
import { trpc } from '@/lib/trpc/client'
import { directSessionUpdate } from '@/lib/session-update'
import {
DropdownMenu,
DropdownMenuContent,
@@ -71,17 +72,40 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { data: session, status: sessionStatus, update: updateSession } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const isImpersonating = !!session?.user?.impersonating
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated,
})
const endImpersonation = trpc.user.endImpersonation.useMutation()
const logNavClick = trpc.learningResource.logNavClick.useMutation()
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
// Auto-refresh session on mount to pick up role changes without requiring re-login
const handleSignOut = async () => {
if (isImpersonating) {
try {
await endImpersonation.mutateAsync()
await directSessionUpdate({ endImpersonation: true })
window.location.href = '/admin/members'
} catch {
// Fallback: just sign out completely
signOut({ callbackUrl: '/login' })
}
return
}
signOut({ callbackUrl: '/login' })
}
// Auto-refresh session once on initial mount to pick up role changes.
// Uses a ref (not state) to avoid triggering an extra re-render.
const sessionRefreshedRef = useRef(false)
useEffect(() => {
if (isAuthenticated) {
updateSession()
if (isAuthenticated && !sessionRefreshedRef.current) {
sessionRefreshedRef.current = true
// Delay so the initial render finishes cleanly before session refresh
const timer = setTimeout(() => updateSession(), 3000)
return () => clearTimeout(timer)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated])
@@ -115,7 +139,12 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
)
if (item.external) {
return (
<a key={item.name} href={item.href} target="_blank" rel="noopener noreferrer" className={className}>
<a
key={item.name}
href={item.href}
className={className}
onClick={() => logNavClick.mutate({ url: item.href })}
>
<item.icon className="h-4 w-4" />
{item.name}
<ExternalLinkIcon className="h-3 w-3 opacity-50" />
@@ -211,11 +240,11 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
onClick={handleSignOut}
className="text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
{isImpersonating ? 'Return to Admin' : 'Sign Out'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -257,7 +286,12 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
)
if (item.external) {
return (
<a key={item.name} href={item.href} target="_blank" rel="noopener noreferrer" onClick={() => setIsMobileMenuOpen(false)} className={className}>
<a
key={item.name}
href={item.href}
onClick={() => { logNavClick.mutate({ url: item.href }); setIsMobileMenuOpen(false) }}
className={className}
>
<item.icon className="h-4 w-4" />
{item.name}
<ExternalLinkIcon className="h-3 w-3 opacity-50" />
@@ -299,10 +333,10 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
<Button
variant="ghost"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={() => signOut({ callbackUrl: '/login' })}
onClick={handleSignOut}
>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
{isImpersonating ? 'Return to Admin' : 'Sign Out'}
</Button>
</div>
</nav>

View File

@@ -0,0 +1,22 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { Lock } from 'lucide-react'
export function DeliberationPanel() {
return (
<div className="flex items-center justify-center min-h-[300px]">
<Card className="max-w-md w-full">
<CardContent className="py-12 text-center">
<div className="relative inline-block mb-4">
<Lock className="h-12 w-12 text-muted-foreground/40 animate-pulse" />
</div>
<h3 className="text-lg font-semibold mb-2">Results Coming Soon</h3>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
The jury is deliberating. Results will be shared when finalized.
</p>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,261 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { StatusBadge } from '@/components/shared/status-badge'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
Target,
TrendingUp,
Users,
ChevronDown,
ChevronUp,
Award,
} from 'lucide-react'
import { cn } from '@/lib/utils'
export function EvaluationPanel({ roundId, programId }: { roundId: string; programId: string }) {
const [expandedJurorId, setExpandedJurorId] = useState<string | null>(null)
const { data: roundStats, isLoading: statsLoading } = trpc.analytics.getRoundTypeStats.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const { data: advConfig } = trpc.analytics.getRoundAdvancementConfig.useQuery(
{ roundId },
{ refetchInterval: 60_000 },
)
const { data: dashStats } = trpc.analytics.getDashboardStats.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const { data: jurorWorkload } = trpc.analytics.getJurorWorkload.useQuery(
{ programId, roundId },
{ enabled: !!programId, refetchInterval: 30_000 },
)
const { data: projectsData } = trpc.analytics.getAllProjects.useQuery(
{ roundId, perPage: 8 },
{ refetchInterval: 30_000 },
)
const stats = roundStats?.stats as {
totalAssignments: number
completedEvaluations: number
completionRate: number
activeJurors: number
} | undefined
const allJurors = jurorWorkload ?? []
const projects = projectsData?.projects ?? []
const scoreDistribution = dashStats?.scoreDistribution ?? []
const maxScoreCount = Math.max(...scoreDistribution.map((b) => b.count), 1)
const scoreColors: Record<string, string> = {
'9-10': '#053d57',
'7-8': '#1e7a8a',
'5-6': '#557f8c',
'3-4': '#c4453a',
'1-2': '#de0f1e',
}
return (
<div className="space-y-4">
{/* Advancement Method Card */}
{advConfig && (
<AnimatedCard index={0}>
<Card className="border-brand-teal/30 bg-brand-teal/5">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-brand-teal/10 p-2">
<Target className="h-5 w-5 text-brand-teal" />
</div>
<div>
<p className="text-sm font-semibold">
{advConfig.advanceMode === 'threshold'
? `Score Threshold ≥ ${advConfig.advanceScoreThreshold ?? '?'}`
: 'Top N Advancement'}
</p>
{advConfig.advanceMode === 'count' && (
<p className="text-xs text-muted-foreground">
{advConfig.startupAdvanceCount != null && `${advConfig.startupAdvanceCount} Startups`}
{advConfig.startupAdvanceCount != null && advConfig.conceptAdvanceCount != null && ', '}
{advConfig.conceptAdvanceCount != null && `${advConfig.conceptAdvanceCount} Business Concepts`}
</p>
)}
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Completion Progress */}
{statsLoading ? (
<Skeleton className="h-20 rounded-lg" />
) : stats ? (
<Card className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Evaluation Progress</span>
<Badge variant="secondary" className="tabular-nums">
{stats.completionRate}%
</Badge>
</div>
<Progress value={stats.completionRate} className="h-2 mb-1" />
<p className="text-xs text-muted-foreground tabular-nums">
{stats.completedEvaluations} / {stats.totalAssignments} evaluations · {stats.activeJurors} jurors
</p>
</Card>
) : null}
{/* Score Distribution */}
{scoreDistribution.length > 0 && (
<AnimatedCard index={1}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<TrendingUp className="h-4 w-4 text-amber-500" />
Score Distribution
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1.5">
{scoreDistribution.map((bucket) => (
<div key={bucket.label} className="flex items-center gap-2">
<span className="w-8 text-right text-[11px] font-medium tabular-nums text-muted-foreground">
{bucket.label}
</span>
<div className="flex-1 overflow-hidden rounded-full bg-muted" style={{ height: 14 }}>
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${maxScoreCount > 0 ? (bucket.count / maxScoreCount) * 100 : 0}%`,
backgroundColor: scoreColors[bucket.label] ?? '#557f8c',
}}
/>
</div>
<span className="w-6 text-right text-[11px] tabular-nums text-muted-foreground">
{bucket.count}
</span>
</div>
))}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Juror Workload */}
<AnimatedCard index={2}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Users className="h-4 w-4 text-violet-500" />
Juror Workload
</CardTitle>
</CardHeader>
<CardContent>
{allJurors.length > 0 ? (
<div className="max-h-[320px] overflow-y-auto -mr-2 pr-2 space-y-2">
{allJurors.map((juror) => {
const isExpanded = expandedJurorId === juror.id
return (
<div key={juror.id}>
<button
type="button"
className="w-full text-left space-y-1 rounded-md px-1 -mx-1 py-1 hover:bg-muted/50 transition-colors"
onClick={() => setExpandedJurorId(isExpanded ? null : juror.id)}
>
<div className="flex items-center justify-between text-sm">
<span className="truncate font-medium">{juror.name ?? 'Unknown'}</span>
<div className="ml-2 flex shrink-0 items-center gap-1.5">
<span className="text-xs tabular-nums text-muted-foreground">
{juror.completionRate}%
</span>
{isExpanded
? <ChevronUp className="h-3 w-3 text-muted-foreground" />
: <ChevronDown className="h-3 w-3 text-muted-foreground" />}
</div>
</div>
<Progress value={juror.completionRate} className="h-1.5" />
<p className="text-[11px] text-muted-foreground">
{juror.completed} / {juror.assigned} evaluations
</p>
</button>
{isExpanded && juror.projects && (
<div className="ml-1 mt-1 space-y-1 border-l-2 border-muted pl-3">
{juror.projects.map((proj: { id: string; title: string; evalStatus: string }) => (
<Link
key={proj.id}
href={`/observer/projects/${proj.id}` as Route}
className="flex items-center justify-between gap-2 rounded py-1 text-xs hover:underline"
>
<span className="truncate">{proj.title}</span>
<StatusBadge status={proj.evalStatus} size="sm" />
</Link>
))}
</div>
)}
</div>
)
})}
</div>
) : (
<p className="text-sm text-muted-foreground">No juror assignments yet.</p>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Recently Reviewed */}
{projects.length > 0 && (
<AnimatedCard index={3}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Award className="h-4 w-4 text-emerald-500" />
Recently Reviewed
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
{projects
.filter((p) => {
const s = p.observerStatus ?? p.status
return s !== 'PENDING'
})
.slice(0, 6)
.map((p) => (
<Link
key={p.id}
href={`/observer/projects/${p.id}` as Route}
className="flex items-center justify-between gap-2 px-4 py-2.5 hover:bg-muted/50 transition-colors"
>
<span className="text-sm truncate">{p.title}</span>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={p.observerStatus ?? p.status} size="sm" />
<span className="text-xs tabular-nums text-muted-foreground">
{p.evaluationCount > 0 && p.averageScore !== null
? p.averageScore.toFixed(1)
: '—'}
</span>
</div>
</Link>
))}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
</div>
)
}

View File

@@ -0,0 +1,297 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import { AnimatedCard } from '@/components/shared/animated-container'
import { CountryDisplay } from '@/components/shared/country-display'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Filter, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react'
import { cn, formatCategory } from '@/lib/utils'
type AIScreeningData = {
meetsCriteria?: boolean
confidence?: number
reasoning?: string
qualityScore?: number
spamRisk?: boolean
}
function parseAIData(json: unknown): AIScreeningData | null {
if (!json || typeof json !== 'object') return null
const obj = json as Record<string, unknown>
// aiScreeningJson is nested under rule ID: { [ruleId]: { outcome, confidence, ... } }
if (!('outcome' in obj) && !('reasoning' in obj)) {
const keys = Object.keys(obj)
if (keys.length > 0) {
const inner = obj[keys[0]]
if (inner && typeof inner === 'object') return inner as AIScreeningData
}
return null
}
return obj as unknown as AIScreeningData
}
export function FilteringPanel({ roundId }: { roundId: string }) {
const [outcomeFilter, setOutcomeFilter] = useState<string>('ALL')
const [page, setPage] = useState(1)
const [expandedId, setExpandedId] = useState<string | null>(null)
const { data: roundStats, isLoading: statsLoading } = trpc.analytics.getRoundTypeStats.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const { data: resultStats } = trpc.analytics.getFilteringResultStats.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const { data: results, isLoading: resultsLoading } = trpc.analytics.getFilteringResults.useQuery(
{
roundId,
outcome: outcomeFilter === 'ALL' ? undefined : outcomeFilter as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED',
page,
perPage: 15,
},
{ refetchInterval: 30_000 },
)
const stats = roundStats?.stats as {
totalScreened: number
passed: number
filteredOut: number
flagged: number
passRate: number
} | undefined
const total = stats?.totalScreened ?? 0
const outcomeColor: Record<string, string> = {
PASSED: 'bg-emerald-500',
FILTERED_OUT: 'bg-rose-500',
FLAGGED: 'bg-amber-500',
}
return (
<div className="space-y-4">
{/* Screening Stats Bar */}
{statsLoading ? (
<Skeleton className="h-24 rounded-lg" />
) : stats ? (
<Card className="p-4">
<div className="flex items-center gap-2 mb-3">
<Filter className="h-4 w-4 text-brand-teal" />
<span className="text-sm font-semibold">Screening Results</span>
<Badge variant="secondary" className="ml-auto tabular-nums">{total} screened</Badge>
</div>
{/* Segmented bar */}
{total > 0 && (
<div className="flex h-3 rounded-full overflow-hidden bg-muted">
<div
className="bg-emerald-500 transition-all"
style={{ width: `${(stats.passed / total) * 100}%` }}
title={`Passed: ${stats.passed}`}
/>
<div
className="bg-rose-500 transition-all"
style={{ width: `${(stats.filteredOut / total) * 100}%` }}
title={`Filtered: ${stats.filteredOut}`}
/>
<div
className="bg-amber-500 transition-all"
style={{ width: `${(stats.flagged / total) * 100}%` }}
title={`Flagged: ${stats.flagged}`}
/>
</div>
)}
<div className="flex justify-between mt-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-emerald-500 inline-block" />
Passed {stats.passed}
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-rose-500 inline-block" />
Filtered {stats.filteredOut}
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-amber-500 inline-block" />
Flagged {stats.flagged}
</span>
</div>
</Card>
) : null}
{/* Detailed Stats */}
{resultStats && (
<div className="grid grid-cols-3 gap-3">
<Card className="p-3 text-center">
<p className="text-xl font-semibold tabular-nums text-emerald-600">
{resultStats.passed}
</p>
<p className="text-xs text-muted-foreground">Passed</p>
</Card>
<Card className="p-3 text-center">
<p className="text-xl font-semibold tabular-nums text-amber-600">
{resultStats.overridden}
</p>
<p className="text-xs text-muted-foreground">Overridden</p>
</Card>
<Card className="p-3 text-center">
<p className="text-xl font-semibold tabular-nums">
{resultStats.total > 0 ? Math.round((resultStats.passed / resultStats.total) * 100) : 0}%
</p>
<p className="text-xs text-muted-foreground">Pass Rate</p>
</Card>
</div>
)}
{/* AI Results Table */}
<AnimatedCard index={1}>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm">AI Screening Results</CardTitle>
<Select value={outcomeFilter} onValueChange={(v) => { setOutcomeFilter(v); setPage(1) }}>
<SelectTrigger className="w-32 h-8 text-xs">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All</SelectItem>
<SelectItem value="PASSED">Passed</SelectItem>
<SelectItem value="FILTERED_OUT">Filtered</SelectItem>
<SelectItem value="FLAGGED">Flagged</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="p-0">
{resultsLoading ? (
<div className="p-4 space-y-2">
{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : results && results.results.length > 0 ? (
<>
<div className="divide-y">
{results.results.map((r: any) => (
<div key={r.id}>
<button
type="button"
className="w-full text-left px-4 py-2.5 hover:bg-muted/50 transition-colors"
onClick={() => setExpandedId(expandedId === r.id ? null : r.id)}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<Link
href={`/observer/projects/${r.project?.id}` as Route}
className="text-sm font-medium truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{r.project?.title ?? 'Unknown'}
</Link>
<p className="text-xs text-muted-foreground truncate">
{formatCategory(r.project?.competitionCategory)} · {r.project?.country ? <CountryDisplay country={r.project.country} /> : ''}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{(() => {
const effectiveOutcome = r.finalOutcome ?? r.outcome
return (
<>
<span className={cn(
'h-2 w-2 rounded-full',
outcomeColor[effectiveOutcome] ?? 'bg-muted',
)} />
<span className="text-xs">{effectiveOutcome?.replace(/_/g, ' ')}</span>
</>
)
})()}
{expandedId === r.id
? <ChevronUp className="h-3 w-3 text-muted-foreground" />
: <ChevronDown className="h-3 w-3 text-muted-foreground" />}
</div>
</div>
</button>
{expandedId === r.id && (() => {
const ai = parseAIData(r.aiScreeningJson)
return (
<div className="px-4 pb-3 pt-0 space-y-2">
{ai?.confidence != null && (
<div className="flex items-center gap-3 text-xs">
{ai.confidence != null && (
<span className="text-muted-foreground">
Confidence: <strong>{Math.round(ai.confidence * 100)}%</strong>
</span>
)}
{ai.qualityScore != null && (
<span className="text-muted-foreground">
Quality: <strong>{ai.qualityScore}/10</strong>
</span>
)}
{ai.spamRisk && (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">Spam Risk</Badge>
)}
</div>
)}
<div className="rounded bg-muted/50 border p-3 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap">
{ai?.reasoning || 'No AI reasoning available'}
</div>
{r.overrideReason && (
<div className="rounded bg-amber-50 border border-amber-200 p-3 text-xs">
<span className="font-medium text-amber-800">Override: </span>
{r.overrideReason}
</div>
)}
</div>
)
})()}
</div>
))}
</div>
{/* Pagination */}
{results.totalPages > 1 && (
<div className="flex items-center justify-between border-t px-4 py-2">
<span className="text-xs text-muted-foreground">
Page {results.page} of {results.totalPages}
</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
disabled={page >= results.totalPages}
onClick={() => setPage(page + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
) : (
<div className="p-4 text-sm text-muted-foreground">No screening results yet.</div>
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
)
}

View File

@@ -0,0 +1,164 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { AnimatedCard } from '@/components/shared/animated-container'
import { CountryDisplay } from '@/components/shared/country-display'
import { Inbox, Globe, FolderOpen } from 'lucide-react'
function relativeTime(date: Date | string): string {
const now = Date.now()
const then = new Date(date).getTime()
const diff = Math.floor((now - then) / 1000)
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
export function IntakePanel({ roundId, programId }: { roundId: string; programId: string }) {
const { data: roundStats, isLoading: statsLoading } = trpc.analytics.getRoundTypeStats.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const { data: projectsData } = trpc.analytics.getAllProjects.useQuery(
{ roundId, perPage: 8 },
{ refetchInterval: 30_000 },
)
const { data: geoData } = trpc.analytics.getGeographicDistribution.useQuery(
{ programId, roundId },
{ enabled: !!programId, refetchInterval: 30_000 },
)
const stats = roundStats?.stats as {
totalProjects: number
byCategory: { category: string; count: number }[]
byState: { state: string; count: number }[]
} | undefined
const projects = projectsData?.projects ?? []
const topCountries = (geoData ?? []).slice(0, 10)
return (
<div className="space-y-4">
{/* Stats Cards */}
{statsLoading ? (
<div className="grid grid-cols-3 gap-3">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-20 rounded-lg" />)}
</div>
) : stats ? (
<div className="grid grid-cols-3 gap-3">
<Card className="p-3 text-center">
<p className="text-2xl font-semibold tabular-nums">{stats.totalProjects}</p>
<p className="text-xs text-muted-foreground mt-0.5">Total Projects</p>
</Card>
{stats.byCategory.map((c) => (
<Card key={c.category} className="p-3 text-center">
<p className="text-2xl font-semibold tabular-nums">{c.count}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">{c.category}</p>
</Card>
))}
</div>
) : null}
{/* Recent Submissions */}
<AnimatedCard index={1}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Inbox className="h-4 w-4 text-brand-teal" />
Recent Submissions
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{projects.length > 0 ? (
<div className="divide-y">
{projects.map((p) => (
<Link
key={p.id}
href={`/observer/projects/${p.id}` as Route}
className="flex items-center justify-between gap-2 px-4 py-2.5 hover:bg-muted/50 transition-colors"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{p.title}</p>
<p className="text-xs text-muted-foreground truncate">
{p.teamName ?? 'No team'} · {p.country ? <CountryDisplay country={p.country} /> : ''}
</p>
</div>
<span className="text-[11px] tabular-nums text-muted-foreground shrink-0">
{p.country ? <CountryDisplay country={p.country} /> : ''}
</span>
</Link>
))}
</div>
) : (
<div className="p-4 text-sm text-muted-foreground">No submissions yet.</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Country Ranking */}
{topCountries.length > 0 && (
<AnimatedCard index={2}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Globe className="h-4 w-4 text-blue-500" />
Top Countries
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1.5">
{topCountries.map((c, i) => (
<div key={c.countryCode} className="flex items-center justify-between text-sm">
<span className="truncate">
<span className="text-muted-foreground tabular-nums mr-2">{i + 1}.</span>
{c.countryCode}
</span>
<Badge variant="secondary" className="tabular-nums text-xs">
{c.count}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Category Breakdown */}
{stats && stats.byCategory.length > 0 && (
<AnimatedCard index={3}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<FolderOpen className="h-4 w-4 text-emerald-500" />
Category Breakdown
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2">
{stats.byCategory.map((c) => {
const pct = stats.totalProjects > 0 ? Math.round((c.count / stats.totalProjects) * 100) : 0
return (
<div key={c.category} className="flex-1 rounded-lg bg-muted p-3 text-center">
<p className="text-lg font-semibold tabular-nums">{pct}%</p>
<p className="text-xs text-muted-foreground truncate">{c.category}</p>
</div>
)
})}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
</div>
)
}

View File

@@ -0,0 +1,164 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { AnimatedCard } from '@/components/shared/animated-container'
import { Radio, Users, Trophy, Eye, EyeOff } from 'lucide-react'
import { cn } from '@/lib/utils'
const SESSION_STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; pulse?: boolean }> = {
NOT_STARTED: { label: 'Not Started', color: 'text-slate-500', bg: 'bg-slate-100 dark:bg-slate-800' },
IN_PROGRESS: { label: 'In Progress', color: 'text-emerald-600', bg: 'bg-emerald-50 dark:bg-emerald-900/20', pulse: true },
PAUSED: { label: 'Paused', color: 'text-amber-600', bg: 'bg-amber-50 dark:bg-amber-900/20' },
COMPLETED: { label: 'Completed', color: 'text-blue-600', bg: 'bg-blue-50 dark:bg-blue-900/20' },
}
export function LiveFinalPanel({ roundId }: { roundId: string }) {
const { data: liveDash, isLoading } = trpc.analytics.getLiveFinalDashboard.useQuery(
{ roundId },
{ refetchInterval: 10_000 },
)
const { data: roundStats } = trpc.analytics.getRoundTypeStats.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const stats = roundStats?.stats as {
sessionStatus: string
voteCount: number
} | undefined
const sessionStatus = liveDash?.sessionStatus ?? stats?.sessionStatus ?? 'NOT_STARTED'
const statusConfig = SESSION_STATUS_CONFIG[sessionStatus] ?? SESSION_STATUS_CONFIG.NOT_STARTED
const jurors = liveDash?.jurors ?? []
const votedCount = jurors.filter((j: any) => j.hasVoted).length
const standings = liveDash?.standings ?? []
const visibility = liveDash?.observerScoreVisibility ?? 'after_completion'
const scoresVisible = visibility === 'realtime'
|| (visibility === 'after_completion' && sessionStatus === 'COMPLETED')
return (
<div className="space-y-4">
{/* Session Status Card */}
{isLoading ? (
<Skeleton className="h-24 rounded-lg" />
) : (
<Card className={cn('p-5', statusConfig.bg)}>
<div className="flex items-center gap-4">
<div className="relative">
<Radio className={cn('h-8 w-8', statusConfig.color)} />
{statusConfig.pulse && (
<span className="absolute -top-0.5 -right-0.5 h-3 w-3 rounded-full bg-emerald-500 animate-pulse" />
)}
</div>
<div>
<p className={cn('text-lg font-semibold', statusConfig.color)}>
{statusConfig.label}
</p>
<p className="text-sm text-muted-foreground">
{liveDash?.voteCount ?? stats?.voteCount ?? 0} votes cast
</p>
</div>
</div>
</Card>
)}
{/* Vote Count */}
<div className="grid grid-cols-2 gap-3">
<Card className="p-3 text-center">
<p className="text-2xl font-semibold tabular-nums">
{liveDash?.voteCount ?? stats?.voteCount ?? 0}
</p>
<p className="text-xs text-muted-foreground mt-0.5">Total Votes</p>
</Card>
<Card className="p-3 text-center">
<p className="text-2xl font-semibold tabular-nums">
{votedCount}/{jurors.length}
</p>
<p className="text-xs text-muted-foreground mt-0.5">Jurors Voted</p>
</Card>
</div>
{/* Juror Participation */}
{jurors.length > 0 && (
<AnimatedCard index={1}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Users className="h-4 w-4 text-violet-500" />
Juror Participation
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1.5 max-h-[250px] overflow-y-auto">
{jurors.map((j: any) => (
<div key={j.id} className="flex items-center justify-between text-sm py-1">
<span className="truncate">{j.name}</span>
<Badge
variant={j.hasVoted ? 'default' : 'outline'}
className={cn(
'text-xs',
j.hasVoted && 'bg-emerald-500 hover:bg-emerald-600',
)}
>
{j.hasVoted ? 'Voted' : 'Pending'}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Standings / Score Visibility */}
<AnimatedCard index={2}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Trophy className="h-4 w-4 text-amber-500" />
Standings
{scoresVisible ? (
<Eye className="h-3.5 w-3.5 text-emerald-500 ml-auto" />
) : (
<EyeOff className="h-3.5 w-3.5 text-muted-foreground ml-auto" />
)}
</CardTitle>
</CardHeader>
<CardContent>
{scoresVisible && standings.length > 0 ? (
<div className="space-y-2">
{standings.map((s: any, i: number) => (
<div key={s.projectId} className="flex items-center justify-between text-sm py-1">
<div className="flex items-center gap-2 min-w-0">
<span className="text-muted-foreground tabular-nums font-medium w-5 text-right">
{i + 1}.
</span>
<span className="truncate">{s.projectTitle}</span>
</div>
<Badge variant="secondary" className="tabular-nums shrink-0">
{typeof s.score === 'number' ? s.score.toFixed(1) : s.score}
</Badge>
</div>
))}
</div>
) : (
<div className="text-center py-6">
<EyeOff className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
{sessionStatus === 'COMPLETED'
? 'Scores are hidden by admin configuration.'
: 'Scores will be revealed when voting completes.'}
</p>
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
)
}

View File

@@ -0,0 +1,139 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { AnimatedCard } from '@/components/shared/animated-container'
import { MessageCircle, Users, ChevronDown, ChevronUp } from 'lucide-react'
export function MentoringPanel({ roundId }: { roundId: string }) {
const [expandedMentorId, setExpandedMentorId] = useState<string | null>(null)
const { data: mentoringData, isLoading } = trpc.analytics.getMentoringDashboard.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const { data: roundStats } = trpc.analytics.getRoundTypeStats.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const stats = roundStats?.stats as {
mentorAssignments: number
totalMessages: number
} | undefined
const assignments = mentoringData?.assignments ?? []
const activeMentors = mentoringData?.activeMentors ?? 0
const totalMentors = mentoringData?.totalMentors ?? 0
return (
<div className="space-y-4">
{/* Stats Cards */}
{isLoading ? (
<div className="grid grid-cols-3 gap-3">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-20 rounded-lg" />)}
</div>
) : (
<div className="grid grid-cols-3 gap-3">
<Card className="p-3 text-center">
<p className="text-xl font-semibold tabular-nums">
{activeMentors}/{totalMentors}
</p>
<p className="text-xs text-muted-foreground mt-0.5">Active Mentors</p>
</Card>
<Card className="p-3 text-center">
<p className="text-xl font-semibold tabular-nums">
{mentoringData?.totalMessages ?? stats?.totalMessages ?? 0}
</p>
<p className="text-xs text-muted-foreground mt-0.5">Messages</p>
</Card>
<Card className="p-3 text-center">
<p className="text-xl font-semibold tabular-nums">
{stats?.mentorAssignments ?? assignments.length}
</p>
<p className="text-xs text-muted-foreground mt-0.5">Assignments</p>
</Card>
</div>
)}
{/* Mentor-Mentee Pairings */}
<AnimatedCard index={1}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Users className="h-4 w-4 text-brand-teal" />
Mentor-Mentee Pairings
</CardTitle>
</CardHeader>
<CardContent>
{assignments.length > 0 ? (
<div className="space-y-2 max-h-[400px] overflow-y-auto -mr-2 pr-2">
{assignments.map((mentor: any) => {
const isExpanded = expandedMentorId === mentor.mentorId
return (
<div key={mentor.mentorId} className="border rounded-lg">
<button
type="button"
className="w-full text-left px-3 py-2.5 hover:bg-muted/50 transition-colors rounded-lg"
onClick={() => setExpandedMentorId(isExpanded ? null : mentor.mentorId)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{mentor.mentorName}</span>
{mentor.projects?.some((p: any) => p.messageCount > 0) && (
<Badge variant="secondary" className="text-[10px] px-1.5">
Recently active
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="tabular-nums text-xs">
{mentor.projects?.length ?? 0} projects
</Badge>
{isExpanded
? <ChevronUp className="h-3 w-3 text-muted-foreground" />
: <ChevronDown className="h-3 w-3 text-muted-foreground" />}
</div>
</div>
</button>
{isExpanded && mentor.projects && (
<div className="border-t divide-y">
{mentor.projects.map((proj: any) => (
<Link
key={proj.id}
href={`/observer/projects/${proj.id}` as Route}
className="flex items-center justify-between gap-2 px-3 py-2 hover:bg-muted/50 transition-colors"
>
<div className="min-w-0 flex-1">
<p className="text-sm truncate">{proj.title}</p>
<p className="text-xs text-muted-foreground truncate">
{proj.teamName ?? ''}
</p>
</div>
<div className="flex items-center gap-1 shrink-0 text-xs text-muted-foreground">
<MessageCircle className="h-3 w-3" />
<span className="tabular-nums">{proj.messageCount}</span>
</div>
</Link>
))}
</div>
)}
</div>
)
})}
</div>
) : (
<p className="text-sm text-muted-foreground">No mentor assignments yet.</p>
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
)
}

View File

@@ -0,0 +1,149 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { AnimatedCard } from '@/components/shared/animated-container'
import { CountryDisplay } from '@/components/shared/country-display'
import { ArrowDown, ChevronDown, ChevronUp, TrendingDown } from 'lucide-react'
import { cn, formatCategory } from '@/lib/utils'
export function PreviousRoundSection({ currentRoundId }: { currentRoundId: string }) {
const [collapsed, setCollapsed] = useState(false)
const { data, isLoading } = trpc.analytics.getPreviousRoundComparison.useQuery(
{ currentRoundId },
{ refetchInterval: 60_000 },
)
if (isLoading) {
return <Skeleton className="h-40 w-full rounded-lg" />
}
if (!data || !data.hasPrevious) {
return null
}
const { previousRound, currentRound, eliminated, categoryBreakdown, countryAttrition } = data
return (
<AnimatedCard index={5}>
<Card>
<CardHeader className="pb-2">
<button
type="button"
className="flex items-center justify-between w-full text-left"
onClick={() => setCollapsed(!collapsed)}
>
<CardTitle className="flex items-center gap-2 text-base">
<div className="rounded-lg bg-rose-500/10 p-1.5">
<TrendingDown className="h-4 w-4 text-rose-500" />
</div>
Compared to Previous Round: {previousRound.name}
</CardTitle>
{collapsed
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronUp className="h-4 w-4 text-muted-foreground" />}
</button>
</CardHeader>
{!collapsed && (
<CardContent className="space-y-4">
{/* Headline Stat */}
<div className="flex items-center gap-3 rounded-lg bg-rose-50 dark:bg-rose-950/20 p-4">
<ArrowDown className="h-6 w-6 text-rose-500 shrink-0" />
<div>
<p className="text-lg font-semibold">
{eliminated} project{eliminated !== 1 ? 's' : ''} eliminated
</p>
<p className="text-sm text-muted-foreground">
{previousRound.projectCount} {currentRound.projectCount}
</p>
</div>
</div>
{/* Category Survival Bars */}
{categoryBreakdown && categoryBreakdown.length > 0 && (
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
By Category
</p>
{categoryBreakdown.map((cat: any) => {
const maxVal = Math.max(cat.previous, 1)
const prevPct = 100
const currPct = (cat.current / maxVal) * 100
return (
<div key={cat.category} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="font-medium truncate">{formatCategory(cat.category)}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{cat.previous} {cat.current}
<span className="text-rose-500 ml-1">(-{cat.eliminated})</span>
</span>
</div>
<div className="relative h-2.5 rounded-full bg-muted overflow-hidden">
<div
className="absolute inset-y-0 left-0 rounded-full bg-slate-300 dark:bg-slate-600 transition-all"
style={{ width: `${prevPct}%` }}
/>
<div
className="absolute inset-y-0 left-0 rounded-full bg-brand-teal transition-all"
style={{ width: `${currPct}%` }}
/>
</div>
</div>
)
})}
</div>
)}
{/* Country Attrition */}
{countryAttrition && countryAttrition.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
Country Attrition (Top 10)
</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
{countryAttrition.map((c: any) => (
<div key={c.country} className="flex items-center justify-between text-sm py-0.5">
<span className="truncate"><CountryDisplay country={c.country} /></span>
<Badge variant="destructive" className="tabular-nums text-xs">
-{c.lost}
</Badge>
</div>
))}
</div>
</div>
)}
{/* Score Comparison */}
{previousRound.avgScore != null && currentRound.avgScore != null && (
<div className="grid grid-cols-2 gap-3">
<Card className="p-3 text-center border-muted">
<p className="text-xs text-muted-foreground mb-1">{previousRound.name}</p>
<p className="text-lg font-semibold tabular-nums">
{typeof previousRound.avgScore === 'number'
? previousRound.avgScore.toFixed(1)
: previousRound.avgScore}
</p>
<p className="text-[10px] text-muted-foreground">Avg Score</p>
</Card>
<Card className="p-3 text-center border-brand-teal/30">
<p className="text-xs text-muted-foreground mb-1">{currentRound.name}</p>
<p className="text-lg font-semibold tabular-nums">
{typeof currentRound.avgScore === 'number'
? currentRound.avgScore.toFixed(1)
: currentRound.avgScore}
</p>
<p className="text-[10px] text-muted-foreground">Avg Score</p>
</Card>
</div>
)}
</CardContent>
)}
</Card>
</AnimatedCard>
)
}

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