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 keys and secrets
private/ private/
public/build-id.json

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ services:
- .env - .env
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc - DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc?connection_limit=10&pool_timeout=30
- NEXTAUTH_URL=${NEXTAUTH_URL} - NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET} - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- AUTH_SECRET=${NEXTAUTH_SECRET} - AUTH_SECRET=${NEXTAUTH_SECRET}

View File

@@ -59,4 +59,18 @@ else
fi fi
echo "==> Starting application..." echo "==> Starting application..."
exec node server.js
# Graceful shutdown: forward SIGTERM/SIGINT to the Node process
# so in-flight requests can complete before the container exits.
shutdown() {
echo "==> Received shutdown signal, stopping gracefully..."
kill -TERM "$NODE_PID" 2>/dev/null
wait "$NODE_PID"
exit $?
}
trap shutdown TERM INT
node server.js &
NODE_PID=$!
wait "$NODE_PID"

View File

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

1995
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,10 @@ generator client {
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
// connection_limit and pool_timeout are set via query params in DATABASE_URL:
// ?connection_limit=10&pool_timeout=30
// Defaults: connection_limit = num_cpus * 2 + 1, pool_timeout = 10s.
// Override in .env for production to prevent connection exhaustion.
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
@@ -130,13 +134,6 @@ enum PartnerType {
OTHER OTHER
} }
enum OverrideReasonCode {
DATA_CORRECTION
POLICY_EXCEPTION
JURY_CONFLICT
SPONSOR_DECISION
ADMIN_DISCRETION
}
// ============================================================================= // =============================================================================
// COMPETITION / ROUND ENGINE ENUMS // COMPETITION / ROUND ENGINE ENUMS
@@ -175,13 +172,6 @@ enum ProjectRoundStateValue {
WITHDRAWN WITHDRAWN
} }
enum AdvancementRuleType {
AUTO_ADVANCE
SCORE_THRESHOLD
TOP_N
ADMIN_SELECTION
AI_RECOMMENDED
}
enum CapMode { enum CapMode {
HARD HARD
@@ -302,7 +292,7 @@ model User {
email String @unique email String @unique
name String? name String?
emailVerified DateTime? // Required by NextAuth Prisma adapter emailVerified DateTime? // Required by NextAuth Prisma adapter
role UserRole @default(JURY_MEMBER) role UserRole @default(APPLICANT)
roles UserRole[] @default([]) roles UserRole[] @default([])
status UserStatus @default(INVITED) status UserStatus @default(INVITED)
expertiseTags String[] @default([]) expertiseTags String[] @default([])
@@ -335,6 +325,10 @@ model User {
inviteToken String? @unique inviteToken String? @unique
inviteTokenExpiresAt DateTime? inviteTokenExpiresAt DateTime?
// Password reset token
passwordResetToken String? @unique
passwordResetExpiresAt DateTime?
// Digest & availability preferences // Digest & availability preferences
digestFrequency String @default("none") // 'none' | 'daily' | 'weekly' digestFrequency String @default("none") // 'none' | 'daily' | 'weekly'
preferredWorkload Int? preferredWorkload Int?
@@ -423,7 +417,6 @@ model User {
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor") mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
resultLocksCreated ResultLock[] @relation("ResultLockCreator") resultLocksCreated ResultLock[] @relation("ResultLockCreator")
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker") resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
assignmentExceptionsApproved AssignmentException[] @relation("AssignmentExceptionApprover")
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter") submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement") deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
@@ -555,7 +548,6 @@ model EvaluationForm {
model Project { model Project {
id String @id @default(cuid()) id String @id @default(cuid())
programId String programId String
roundId String?
status ProjectStatus @default(SUBMITTED) status ProjectStatus @default(SUBMITTED)
// Core fields // Core fields
@@ -755,7 +747,6 @@ model Assignment {
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull) juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
evaluation Evaluation? evaluation Evaluation?
conflictOfInterest ConflictOfInterest? conflictOfInterest ConflictOfInterest?
exceptions AssignmentException[]
@@unique([userId, projectId, roundId]) @@unique([userId, projectId, roundId])
@@index([roundId]) @@index([roundId])
@@ -764,6 +755,7 @@ model Assignment {
@@index([isCompleted]) @@index([isCompleted])
@@index([projectId, userId]) @@index([projectId, userId])
@@index([juryGroupId]) @@index([juryGroupId])
@@index([roundId, isCompleted])
} }
model Evaluation { model Evaluation {
@@ -781,11 +773,6 @@ model Evaluation {
binaryDecision Boolean? // Yes/No for semi-finalist binaryDecision Boolean? // Yes/No for semi-finalist
feedbackText String? @db.Text feedbackText String? @db.Text
// Versioning (currently unused - evaluations are updated in-place.
// TODO: Implement proper versioning by creating new rows on re-submission
// if version history is needed for audit purposes)
version Int @default(1)
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -960,6 +947,7 @@ model NotificationLog {
@@index([projectId]) @@index([projectId])
@@index([batchId]) @@index([batchId])
@@index([email]) @@index([email])
@@index([type, status])
} }
// ============================================================================= // =============================================================================
@@ -1490,6 +1478,7 @@ model RankingSnapshot {
@@index([roundId]) @@index([roundId])
@@index([triggeredById]) @@index([triggeredById])
@@index([createdAt]) @@index([createdAt])
@@index([roundId, createdAt])
} }
// Tracks progress of long-running AI tagging jobs // Tracks progress of long-running AI tagging jobs
@@ -1718,7 +1707,6 @@ model ConflictOfInterest {
assignmentId String @unique assignmentId String @unique
userId String userId String
projectId String projectId String
roundId String? // Legacy — kept for historical data
hasConflict Boolean @default(false) hasConflict Boolean @default(false)
conflictType String? // "financial", "personal", "organizational", "other" conflictType String? // "financial", "personal", "organizational", "other"
description String? @db.Text description String? @db.Text
@@ -1736,6 +1724,8 @@ model ConflictOfInterest {
@@index([userId]) @@index([userId])
@@index([hasConflict]) @@index([hasConflict])
@@index([projectId])
@@index([userId, hasConflict])
} }
// ============================================================================= // =============================================================================
@@ -2098,24 +2088,6 @@ model LiveProgressCursor {
@@index([sessionId]) @@index([sessionId])
} }
model OverrideAction {
id String @id @default(cuid())
entityType String // ProjectRoundState, FilteringResult, AwardEligibility, etc.
entityId String
previousValue Json? @db.JsonB
newValueJson Json @db.JsonB
reasonCode OverrideReasonCode
reasonText String? @db.Text
actorId String
createdAt DateTime @default(now())
@@index([entityType, entityId])
@@index([actorId])
@@index([reasonCode])
@@index([createdAt])
}
model DecisionAuditLog { model DecisionAuditLog {
id String @id @default(cuid()) id String @id @default(cuid())
eventType String // stage.transitioned, routing.executed, filtering.completed, etc. eventType String // stage.transitioned, routing.executed, filtering.completed, etc.
@@ -2133,21 +2105,6 @@ model DecisionAuditLog {
@@index([createdAt]) @@index([createdAt])
} }
model NotificationPolicy {
id String @id @default(cuid())
eventType String @unique // stage.transitioned, filtering.completed, etc.
channel String @default("EMAIL") // EMAIL, IN_APP, BOTH, NONE
templateId String? // Optional reference to MessageTemplate
isActive Boolean @default(true)
configJson Json? @db.JsonB // Additional config (delay, batch, etc.)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([eventType])
@@index([isActive])
}
// ============================================================================= // =============================================================================
// COMPETITION / ROUND ENGINE MODELS (NEW — coexists with Pipeline/Track/Stage) // COMPETITION / ROUND ENGINE MODELS (NEW — coexists with Pipeline/Track/Stage)
// ============================================================================= // =============================================================================
@@ -2223,7 +2180,6 @@ model Round {
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull) juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull) submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
projectRoundStates ProjectRoundState[] projectRoundStates ProjectRoundState[]
advancementRules AdvancementRule[]
visibleSubmissionWindows RoundSubmissionVisibility[] visibleSubmissionWindows RoundSubmissionVisibility[]
assignmentIntents AssignmentIntent[] assignmentIntents AssignmentIntent[]
deliberationSessions DeliberationSession[] deliberationSessions DeliberationSession[]
@@ -2279,24 +2235,7 @@ model ProjectRoundState {
@@index([projectId]) @@index([projectId])
@@index([roundId]) @@index([roundId])
@@index([state]) @@index([state])
} @@index([roundId, state])
model AdvancementRule {
id String @id @default(cuid())
roundId String
targetRoundId String?
ruleType AdvancementRuleType
configJson Json @db.JsonB
isDefault Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
@@unique([roundId, sortOrder])
@@index([roundId])
} }
// ============================================================================= // =============================================================================
@@ -2475,22 +2414,6 @@ model AssignmentIntent {
@@index([status]) @@index([status])
} }
model AssignmentException {
id String @id @default(cuid())
assignmentId String
reason String @db.Text
overCapBy Int
approvedById String
createdAt DateTime @default(now())
// Relations
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
approvedBy User @relation("AssignmentExceptionApprover", fields: [approvedById], references: [id])
@@index([assignmentId])
@@index([approvedById])
}
// ============================================================================= // =============================================================================
// MENTORING WORKSPACE MODELS (NEW) // MENTORING WORKSPACE MODELS (NEW)
// ============================================================================= // =============================================================================

View File

@@ -15,7 +15,6 @@ import {
RoundStatus, RoundStatus,
CapMode, CapMode,
JuryGroupMemberRole, JuryGroupMemberRole,
AdvancementRuleType,
} from '@prisma/client' } from '@prisma/client'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
// Inline default configs so seed has ZERO dependency on src/ (not available in Docker prod image) // Inline default configs so seed has ZERO dependency on src/ (not available in Docker prod image)
@@ -316,6 +315,7 @@ async function main() {
const staffAccounts = [ const staffAccounts = [
{ email: 'matt@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' }, { email: 'matt@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' },
{ email: 'matt@letsbe.solutions', name: 'Matt (Applicant)', role: UserRole.APPLICANT, password: '195260Mp!' },
{ email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' }, { email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' },
{ email: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' }, { email: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' },
] ]
@@ -323,10 +323,10 @@ async function main() {
const staffUsers: Record<string, string> = {} const staffUsers: Record<string, string> = {}
for (const account of staffAccounts) { for (const account of staffAccounts) {
const passwordHash = await bcrypt.hash(account.password, 12) const passwordHash = await bcrypt.hash(account.password, 12)
const isSuperAdmin = account.role === UserRole.SUPER_ADMIN const needsPassword = account.role === UserRole.SUPER_ADMIN || account.role === UserRole.APPLICANT
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email: account.email }, where: { email: account.email },
update: isSuperAdmin update: needsPassword
? { ? {
status: UserStatus.ACTIVE, status: UserStatus.ACTIVE,
passwordHash, passwordHash,
@@ -348,11 +348,11 @@ async function main() {
name: account.name, name: account.name,
role: account.role, role: account.role,
roles: [account.role], roles: [account.role],
status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE, status: needsPassword ? UserStatus.ACTIVE : UserStatus.NONE,
passwordHash: isSuperAdmin ? passwordHash : null, passwordHash: needsPassword ? passwordHash : null,
mustSetPassword: !isSuperAdmin, mustSetPassword: !needsPassword,
passwordSetAt: isSuperAdmin ? new Date() : null, passwordSetAt: needsPassword ? new Date() : null,
onboardingCompletedAt: isSuperAdmin ? new Date() : null, onboardingCompletedAt: needsPassword ? new Date() : null,
}, },
}) })
staffUsers[account.email] = user.id staffUsers[account.email] = user.id
@@ -857,24 +857,6 @@ async function main() {
} }
console.log(`${rounds.length} rounds created (R1-R8)`) console.log(`${rounds.length} rounds created (R1-R8)`)
// --- Advancement Rules (auto-advance between rounds) ---
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) --- // --- Assign all projects to intake round (COMPLETED, since intake is closed) ---
const intakeRound = rounds[0] const intakeRound = rounds[0]
const allProjects = await prisma.project.findMany({ const allProjects = await prisma.project.findMany({
@@ -916,6 +898,28 @@ async function main() {
} }
console.log(`${visibilityLinks.length} submission visibility links created`) console.log(`${visibilityLinks.length} submission visibility links created`)
// --- Applicant/Observer visibility settings ---
const visibilitySettings = [
{ key: 'observer_show_team_tab', value: 'true', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show Team tab on observer project detail page' },
{ key: 'applicant_show_evaluation_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show anonymous jury evaluation feedback to applicants' },
{ key: 'applicant_show_evaluation_scores', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show global scores in evaluation feedback' },
{ key: 'applicant_show_evaluation_criteria', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show per-criterion scores in evaluation feedback' },
{ key: 'applicant_show_evaluation_text', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show written feedback text in evaluation feedback' },
{ key: 'applicant_show_livefinal_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show live final scores to applicants' },
{ key: 'applicant_show_livefinal_scores', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show individual jury scores from live finals' },
{ key: 'applicant_show_deliberation_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show deliberation results to applicants' },
{ key: 'applicant_hide_feedback_from_rejected', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Hide feedback from rejected projects' },
{ key: 'applicant_allow_description_edit', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Allow applicants to edit their project description' },
]
for (const s of visibilitySettings) {
await prisma.systemSettings.upsert({
where: { key: s.key },
update: {},
create: s,
})
}
console.log(` ✓ Created ${visibilitySettings.length} applicant/observer visibility settings`)
// --- Feature flag: enable competition model --- // --- Feature flag: enable competition model ---
await prisma.systemSettings.upsert({ await prisma.systemSettings.upsert({
where: { key: 'feature.useCompetitionModel' }, where: { key: 'feature.useCompetitionModel' },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -194,10 +194,8 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
<Card className="border-dashed"> <Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12 text-center"> <CardContent className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-muted-foreground">The requested jury group could not be found.</p> <p className="text-muted-foreground">The requested jury group could not be found.</p>
<Button asChild className="mt-4" variant="outline"> <Button className="mt-4" variant="outline" onClick={() => router.back()}>
<Link href={'/admin/juries' as Route}> Back
Back to Juries
</Link>
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -212,13 +210,11 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
asChild
className="mb-2" className="mb-2"
onClick={() => router.back()}
> >
<Link href={'/admin/juries' as Route}> <ArrowLeft className="h-4 w-4 mr-1" />
<ArrowLeft className="h-4 w-4 mr-1" /> Back
Back to Juries
</Link>
</Button> </Button>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { directSessionUpdate } from '@/lib/session-update'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@@ -47,6 +48,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { UserAvatar } from '@/components/shared/user-avatar'
import { import {
ArrowLeft, ArrowLeft,
Save, Save,
@@ -59,7 +61,44 @@ import {
Eye, Eye,
ThumbsUp, ThumbsUp,
ThumbsDown, ThumbsDown,
Globe,
Building2,
FileText,
FolderOpen,
LogIn,
Calendar,
Clock,
} from 'lucide-react' } from 'lucide-react'
import { getCountryName, getCountryFlag } from '@/lib/countries'
import { formatRelativeTime } from '@/lib/utils'
function getRoleHomePath(role: string): string {
switch (role) {
case 'JURY_MEMBER': return '/jury'
case 'APPLICANT': return '/applicant'
case 'MENTOR': return '/mentor'
case 'OBSERVER': return '/observer'
default: return '/admin'
}
}
const statusVariant: Record<string, 'default' | 'success' | 'destructive' | 'secondary'> = {
ACTIVE: 'success',
SUSPENDED: 'destructive',
INVITED: 'secondary',
NONE: 'secondary',
}
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
JURY_MEMBER: 'default',
MENTOR: 'secondary',
OBSERVER: 'outline',
PROGRAM_ADMIN: 'default',
SUPER_ADMIN: 'default',
APPLICANT: 'secondary',
AWARD_MASTER: 'outline',
AUDIENCE: 'outline',
}
export default function MemberDetailPage() { export default function MemberDetailPage() {
const params = useParams() const params = useParams()
@@ -72,6 +111,7 @@ export default function MemberDetailPage() {
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN' const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
const updateUser = trpc.user.update.useMutation() const updateUser = trpc.user.update.useMutation()
const sendInvitation = trpc.user.sendInvitation.useMutation() const sendInvitation = trpc.user.sendInvitation.useMutation()
const startImpersonation = trpc.user.startImpersonation.useMutation()
// Mentor assignments (only fetched for mentors) // Mentor assignments (only fetched for mentors)
const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery( const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery(
@@ -115,7 +155,7 @@ export default function MemberDetailPage() {
id: userId, id: userId,
email: email || undefined, email: email || undefined,
name: name || null, name: name || null,
role: role as 'SUPER_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN', role: role as 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE',
status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED', status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED',
expertiseTags, expertiseTags,
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null, maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
@@ -123,7 +163,6 @@ export default function MemberDetailPage() {
utils.user.get.invalidate({ id: userId }) utils.user.get.invalidate({ id: userId })
utils.user.list.invalidate() utils.user.list.invalidate()
toast.success('Member updated successfully') toast.success('Member updated successfully')
router.push('/admin/members')
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update member') toast.error(error instanceof Error ? error.message : 'Failed to update member')
} }
@@ -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) { if (isLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Skeleton className="h-9 w-32" /> <Skeleton className="h-9 w-32" />
<Card> <div className="flex items-center gap-4">
<CardHeader> <Skeleton className="h-16 w-16 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-6 w-48" /> <Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" /> <Skeleton className="h-4 w-72" />
</CardHeader> </div>
<CardContent className="space-y-4"> </div>
<Skeleton className="h-10 w-full" /> <div className="grid gap-6 lg:grid-cols-3">
<Skeleton className="h-10 w-full" /> <div className="lg:col-span-2 space-y-6">
</CardContent> <Skeleton className="h-48 w-full" />
</Card> </div>
<Skeleton className="h-64 w-full" />
</div>
</div> </div>
) )
} }
@@ -166,61 +222,75 @@ export default function MemberDetailPage() {
<AlertTitle>Error Loading Member</AlertTitle> <AlertTitle>Error Loading Member</AlertTitle>
<AlertDescription> <AlertDescription>
{error?.message || 'The member you\'re looking for does not exist.'} {error?.message || 'The member you\'re looking for does not exist.'}
{process.env.NODE_ENV === 'development' && (
<div className="mt-2 text-xs opacity-75">
User ID: {userId}
</div>
)}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Button asChild> <Button onClick={() => router.back()}>
<Link href="/admin/members"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Back
Back to Members
</Link>
</Button> </Button>
</div> </div>
) )
} }
const displayRoles = user.roles?.length ? user.roles : [user.role]
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Back nav */}
<div className="flex items-center gap-4"> <Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<Button variant="ghost" asChild className="-ml-4"> <ArrowLeft className="mr-2 h-4 w-4" />
<Link href="/admin/members"> Back
<ArrowLeft className="mr-2 h-4 w-4" /> </Button>
Back to Members
</Link>
</Button>
</div>
<div className="flex items-start justify-between"> {/* Header Hero */}
<div> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight"> <div className="flex items-center gap-4">
{user.name || 'Unnamed Member'} <UserAvatar user={user} avatarUrl={user.avatarUrl} size="lg" />
</h1> <div>
<div className="flex items-center gap-2 mt-1"> <h1 className="text-2xl font-semibold tracking-tight">
{user.name || 'Unnamed Member'}
</h1>
<p className="text-muted-foreground">{user.email}</p> <p className="text-muted-foreground">{user.email}</p>
<Badge variant={user.status === 'ACTIVE' ? 'success' : user.status === 'SUSPENDED' ? 'destructive' : 'secondary'}> <div className="flex items-center gap-2 mt-1.5">
{user.status === 'NONE' ? 'Not Invited' : user.status} <Badge variant={statusVariant[user.status] || 'secondary'}>
</Badge> {user.status === 'NONE' ? 'Not Invited' : user.status}
</Badge>
{displayRoles.map((r) => (
<Badge key={r} variant={roleColors[r] || 'secondary'} className="text-xs">
{r.replace(/_/g, ' ')}
</Badge>
))}
</div>
</div> </div>
</div> </div>
{(user.status === 'NONE' || user.status === 'INVITED') && ( <div className="flex items-center gap-2 shrink-0">
{(user.status === 'NONE' || user.status === 'INVITED') && (
<Button
variant="outline"
onClick={handleSendInvitation}
disabled={sendInvitation.isPending}
>
{sendInvitation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
{user.status === 'INVITED' ? 'Resend Invite' : 'Send Invitation'}
</Button>
)}
<Button <Button
variant="outline" variant="outline"
onClick={handleSendInvitation} onClick={handleImpersonate}
disabled={sendInvitation.isPending} disabled={startImpersonation.isPending}
> >
{sendInvitation.isPending ? ( {startImpersonation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
<Mail className="mr-2 h-4 w-4" /> <LogIn className="mr-2 h-4 w-4" />
)} )}
Send Invitation Impersonate
</Button> </Button>
)} </div>
</div> </div>
<Tabs defaultValue="profile" className="space-y-6"> <Tabs defaultValue="profile" className="space-y-6">
@@ -243,229 +313,369 @@ export default function MemberDetailPage() {
</TabsList> </TabsList>
<TabsContent value="profile" className="space-y-6"> <TabsContent value="profile" className="space-y-6">
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-3">
{/* Basic Info */} {/* Left column: Profile info + Projects */}
<Card> <div className="lg:col-span-2 space-y-6">
<CardHeader> {/* Profile Details (read-only) */}
<CardTitle className="flex items-center gap-2"> {(user.nationality || user.country || user.institution || user.bio) && (
<User className="h-5 w-5" /> <Card>
Basic Information <CardHeader className="pb-3">
</CardTitle> <CardTitle className="flex items-center gap-2 text-base">
</CardHeader> <div className="rounded-lg bg-blue-500/10 p-1.5">
<CardContent className="space-y-4"> <Globe className="h-4 w-4 text-blue-500" />
<div className="space-y-2"> </div>
<Label htmlFor="email">Email</Label> Profile Details
<Input </CardTitle>
id="email" <CardDescription>Information provided during onboarding</CardDescription>
type="email" </CardHeader>
value={email} <CardContent>
onChange={(e) => setEmail(e.target.value)} <div className="grid gap-4 sm:grid-cols-2">
/> {user.nationality && (
</div> <div className="flex items-start gap-3 rounded-lg border p-3">
<div className="space-y-2"> <span className="text-xl mt-0.5 shrink-0" role="img">{getCountryFlag(user.nationality)}</span>
<Label htmlFor="name">Name</Label> <div>
<Input <p className="text-xs font-medium text-muted-foreground">Nationality</p>
id="name" <p className="text-sm font-medium">{getCountryName(user.nationality)}</p>
value={name} </div>
onChange={(e) => setName(e.target.value)} </div>
placeholder="Enter name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={role}
onValueChange={(v) => {
if (v === 'SUPER_ADMIN') {
setPendingSuperAdminRole(true)
setShowSuperAdminConfirm(true)
} else {
setRole(v)
}
}}
disabled={!isSuperAdmin && (user.role === 'SUPER_ADMIN' || user.role === 'PROGRAM_ADMIN')}
>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
{isSuperAdmin && (
<SelectItem value="SUPER_ADMIN">Super Admin</SelectItem>
)}
{isSuperAdmin && (
<SelectItem value="PROGRAM_ADMIN">Program Admin</SelectItem>
)}
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
<SelectItem value="MENTOR">Mentor</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger id="status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">Not Invited</SelectItem>
<SelectItem value="INVITED">Invited</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="SUSPENDED">Suspended</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Expertise & Capacity */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Expertise & Capacity
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Expertise Tags</Label>
<TagInput
value={expertiseTags}
onChange={setExpertiseTags}
placeholder="Select expertise tags..."
maxTags={15}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxAssignments">Max Assignments</Label>
<Input
id="maxAssignments"
type="number"
min="1"
max="100"
value={maxAssignments}
onChange={(e) => setMaxAssignments(e.target.value)}
placeholder="Unlimited"
/>
</div>
{user._count && (
<div className="pt-4 border-t">
<h4 className="font-medium mb-2">Statistics</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Jury Assignments</p>
<p className="text-2xl font-semibold">{user._count.assignments}</p>
</div>
<div>
<p className="text-muted-foreground">Mentor Assignments</p>
<p className="text-2xl font-semibold">{user._count.mentorAssignments}</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* Mentor Assignments Section */}
{user.role === 'MENTOR' && mentorAssignments && mentorAssignments.assignments.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Mentored Projects</CardTitle>
<CardDescription>
Projects this mentor is assigned to
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Status</TableHead>
<TableHead>Assigned</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mentorAssignments.assignments.map((assignment) => (
<TableRow key={assignment.id}>
<TableCell>
<Link
href={`/admin/projects/${assignment.project.id}`}
className="font-medium hover:underline"
>
{assignment.project.title}
</Link>
{assignment.project.teamName && (
<p className="text-sm text-muted-foreground">
{assignment.project.teamName}
</p>
)} )}
</TableCell> {user.country && (
<TableCell> <div className="flex items-start gap-3 rounded-lg border p-3">
{assignment.project.competitionCategory ? ( <span className="text-xl mt-0.5 shrink-0" role="img">{getCountryFlag(user.country)}</span>
<Badge variant="outline"> <div>
{assignment.project.competitionCategory.replace('_', ' ')} <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> </Badge>
) : ( ))}
'-' </div>
)} </CardContent>
</TableCell> </Card>
<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 */} {/* Mentor Assignments */}
<UserActivityLog userId={userId} /> {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>
)}
{/* Status Alert */} {/* Activity Log */}
{user.status === 'NONE' && ( <UserActivityLog userId={userId} />
<Alert> </div>
<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 */} {/* Right sidebar: Edit form + Quick info */}
<div className="flex justify-end gap-4"> <div className="space-y-6">
<Button variant="outline" asChild> {/* Quick Info Card */}
<Link href="/admin/members">Cancel</Link> <Card>
</Button> <CardHeader className="pb-3">
<Button onClick={handleSave} disabled={updateUser.isPending}> <CardTitle className="flex items-center gap-2 text-base">
{updateUser.isPending ? ( <div className="rounded-lg bg-slate-500/10 p-1.5">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Clock className="h-4 w-4 text-slate-500" />
) : ( </div>
<Save className="mr-2 h-4 w-4" /> Quick Info
)} </CardTitle>
Save Changes </CardHeader>
</Button> <CardContent className="space-y-3">
</div> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Created</span>
<span>{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last Login</span>
<span>
{user.lastLoginAt ? (
<span title={new Date(user.lastLoginAt).toLocaleString()}>
{formatRelativeTime(user.lastLoginAt)}
</span>
) : 'Never'}
</span>
</div>
{user._count && !['APPLICANT', 'AUDIENCE'].includes(user.role) && (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Jury Assignments</span>
<span className="font-semibold">{user._count.assignments}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Mentor Assignments</span>
<span className="font-semibold">{user._count.mentorAssignments}</span>
</div>
</>
)}
</CardContent>
</Card>
{/* Status Alerts */}
{user.status === 'NONE' && (
<Alert>
<Mail className="h-4 w-4" />
<AlertTitle>Not Yet Invited</AlertTitle>
<AlertDescription>
This member was added via import but hasn&apos;t been invited yet.
</AlertDescription>
</Alert>
)}
{user.status === 'INVITED' && (
<Alert>
<Mail className="h-4 w-4" />
<AlertTitle>Invitation Pending</AlertTitle>
<AlertDescription>
This member hasn&apos;t accepted their invitation yet.
</AlertDescription>
</Alert>
)}
{/* Basic Info Edit */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<User className="h-4 w-4 text-blue-500" />
</div>
Edit Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={role}
onValueChange={(v) => {
if (v === 'SUPER_ADMIN') {
setPendingSuperAdminRole(true)
setShowSuperAdminConfirm(true)
} else {
setRole(v)
}
}}
disabled={!isSuperAdmin && (user.role === 'SUPER_ADMIN' || user.role === 'PROGRAM_ADMIN')}
>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
{isSuperAdmin && <SelectItem value="SUPER_ADMIN">Super Admin</SelectItem>}
{isSuperAdmin && <SelectItem value="PROGRAM_ADMIN">Program Admin</SelectItem>}
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
<SelectItem value="MENTOR">Mentor</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
<SelectItem value="APPLICANT">Applicant</SelectItem>
<SelectItem value="AWARD_MASTER">Award Master</SelectItem>
<SelectItem value="AUDIENCE">Audience</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger id="status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">Not Invited</SelectItem>
<SelectItem value="INVITED">Invited</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="SUSPENDED">Suspended</SelectItem>
</SelectContent>
</Select>
</div>
{/* Expertise & Capacity for non-applicant */}
{!['APPLICANT', 'AUDIENCE'].includes(user.role) && (
<>
<div className="border-t pt-4 space-y-2">
<Label>Expertise Tags</Label>
<TagInput
value={expertiseTags}
onChange={setExpertiseTags}
placeholder="Select expertise tags..."
maxTags={15}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxAssignments">Max Assignments</Label>
<Input
id="maxAssignments"
type="number"
min="1"
max="100"
value={maxAssignments}
onChange={(e) => setMaxAssignments(e.target.value)}
placeholder="Unlimited"
/>
</div>
</>
)}
<Button onClick={handleSave} disabled={updateUser.isPending} className="w-full">
{updateUser.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
</CardContent>
</Card>
</div>
</div>
</TabsContent> </TabsContent>
{/* Evaluations Tab */} {/* Evaluations Tab */}
@@ -480,7 +690,6 @@ export default function MemberDetailPage() {
</Card> </Card>
) : ( ) : (
(() => { (() => {
// Group evaluations by round
const byRound = new Map<string, typeof jurorEvaluations>() const byRound = new Map<string, typeof jurorEvaluations>()
for (const ev of jurorEvaluations) { for (const ev of jurorEvaluations) {
const key = ev.roundName const key = ev.roundName
@@ -581,7 +790,6 @@ export default function MemberDetailPage() {
)} )}
</Tabs> </Tabs>
{/* Super Admin Confirmation Dialog */} {/* Super Admin Confirmation Dialog */}
<AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}> <AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}>
<AlertDialogContent> <AlertDialogContent>
@@ -594,11 +802,7 @@ export default function MemberDetailPage() {
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel <AlertDialogCancel onClick={() => setPendingSuperAdminRole(false)}>
onClick={() => {
setPendingSuperAdminRole(false)
}}
>
Cancel Cancel
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState, useMemo, useCallback, useRef, useEffect } from 'react' import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
import { useParams, useSearchParams } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
@@ -77,6 +77,8 @@ import {
ArrowRight, ArrowRight,
RotateCcw, RotateCcw,
ListChecks, ListChecks,
FileText,
Languages,
} from 'lucide-react' } from 'lucide-react'
import { import {
Tooltip, Tooltip,
@@ -150,8 +152,7 @@ const stateColors: Record<string, string> = Object.fromEntries(
export default function RoundDetailPage() { export default function RoundDetailPage() {
const params = useParams() const params = useParams()
const roundId = params.roundId as string const roundId = params.roundId as string
const searchParams = useSearchParams() const router = useRouter()
const backUrl = searchParams.get('from')
const [config, setConfig] = useState<Record<string, unknown>>({}) const [config, setConfig] = useState<Record<string, unknown>>({})
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
@@ -544,11 +545,9 @@ export default function RoundDetailPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href={'/admin/rounds' as Route}> <Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back" onClick={() => router.back()}>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back"> <ArrowLeft className="h-4 w-4" />
<ArrowLeft className="h-4 w-4" /> </Button>
</Button>
</Link>
<div> <div>
<h1 className="text-xl font-bold">Round Not Found</h1> <h1 className="text-xl font-bold">Round Not Found</h1>
<p className="text-sm text-muted-foreground">This round does not exist.</p> <p className="text-sm text-muted-foreground">This round does not exist.</p>
@@ -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 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3 min-w-0"> <div className="flex items-start gap-3 min-w-0">
<Link href={(backUrl ?? (round.specialAwardId ? `/admin/awards/${round.specialAwardId}` : '/admin/rounds')) as Route} className="mt-0.5 shrink-0"> <Button variant="ghost" size="sm" className="mt-0.5 shrink-0 h-8 text-white/80 hover:text-white hover:bg-white/10 gap-1.5" aria-label="Back" onClick={() => router.back()}>
<Button variant="ghost" size="sm" className="h-8 text-white/80 hover:text-white hover:bg-white/10 gap-1.5" aria-label={round.specialAwardId ? 'Back to Award' : 'Back to rounds'}> <ArrowLeft className="h-4 w-4" />
<ArrowLeft className="h-4 w-4" /> <span className="text-xs hidden sm:inline">Back</span>
<span className="text-xs hidden sm:inline">{round.specialAwardId ? 'Back to Award' : 'Back to Rounds'}</span> </Button>
</Button>
</Link>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2.5">
{/* 4.6 Inline-editable round name */} {/* 4.6 Inline-editable round name */}
@@ -1471,6 +1468,9 @@ export default function RoundDetailPage() {
</Card> </Card>
</AnimatedCard> </AnimatedCard>
</div> </div>
{/* Document Language Summary */}
<DocumentLanguageSummary roundId={roundId as string} />
</TabsContent> </TabsContent>
{/* ═══════════ PROJECTS TAB ═══════════ */} {/* ═══════════ PROJECTS TAB ═══════════ */}
@@ -2482,3 +2482,75 @@ export default function RoundDetailPage() {
</div> </div>
) )
} }
// =============================================================================
// Document Language Summary — flags non-English docs
// =============================================================================
const LANG_NAMES: Record<string, string> = {
eng: 'English', fra: 'French', deu: 'German', spa: 'Spanish', ita: 'Italian',
por: 'Portuguese', nld: 'Dutch', rus: 'Russian', ara: 'Arabic', zho: 'Chinese',
jpn: 'Japanese', kor: 'Korean', tur: 'Turkish', pol: 'Polish', ron: 'Romanian',
ces: 'Czech', ell: 'Greek', hun: 'Hungarian', swe: 'Swedish', dan: 'Danish',
fin: 'Finnish', nor: 'Norwegian', und: 'Unknown',
}
function DocumentLanguageSummary({ roundId }: { roundId: string }) {
const { data, isLoading } = trpc.file.roundLanguageSummary.useQuery(
{ roundId },
{ refetchInterval: 60_000 }
)
if (isLoading || !data) return null
if (data.totalFiles === 0) return null
const allGood = data.nonEnglishCount === 0 && data.unanalyzedCount === 0
return (
<Card className={allGood ? 'border-green-200 bg-green-50/30' : data.nonEnglishCount > 0 ? 'border-amber-300 bg-amber-50/30' : ''}>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Languages className={cn('h-4 w-4', allGood ? 'text-green-600' : data.nonEnglishCount > 0 ? 'text-amber-600' : 'text-muted-foreground')} />
Document Languages
{data.nonEnglishCount > 0 && (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
{data.nonEnglishCount} flagged
</Badge>
)}
</CardTitle>
<CardDescription className="text-xs">
{data.analyzedCount} of {data.totalFiles} documents analyzed
{data.unanalyzedCount > 0 && `${data.unanalyzedCount} pending`}
</CardDescription>
</CardHeader>
{data.nonEnglishCount > 0 && (
<CardContent className="space-y-2">
{data.flaggedProjects.map((project) => (
<div key={project.projectId} className="rounded-md border bg-white p-3">
<Link
href={`/admin/projects/${project.projectId}` as Route}
className="text-sm font-medium text-primary hover:underline"
>
{project.projectTitle}
</Link>
<div className="mt-1.5 space-y-1">
{project.files.map((file) => (
<div key={file.id} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5 min-w-0">
<FileText className="h-3 w-3 text-muted-foreground shrink-0" />
<span className="truncate">{file.fileName}</span>
</div>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 shrink-0 ml-2 border-amber-300 text-amber-700">
{LANG_NAMES[file.detectedLang ?? ''] || file.detectedLang}
{file.langConfidence != null && ` (${Math.round(file.langConfidence * 100)}%)`}
</Badge>
</div>
))}
</div>
</div>
))}
</CardContent>
)}
</Card>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -13,6 +14,7 @@ import {
} from '@/components/ui/card' } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot' import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer'
import { import {
FileText, FileText,
Upload, Upload,
@@ -22,7 +24,10 @@ import {
File, File,
Download, Download,
Eye, Eye,
X,
Loader2,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner'
const fileTypeIcons: Record<string, typeof FileText> = { const fileTypeIcons: Record<string, typeof FileText> = {
EXEC_SUMMARY: FileText, EXEC_SUMMARY: FileText,
@@ -44,34 +49,114 @@ const fileTypeLabels: Record<string, string> = {
SUPPORTING_DOC: 'Supporting Document', SUPPORTING_DOC: 'Supporting Document',
} }
function FileActionButtons({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) { function FileRow({ file }: { file: { id: string; fileName: string; fileType: string; createdAt: string | Date; isLate?: boolean; bucket?: string; objectKey?: string; mimeType?: string } }) {
const { data: viewData } = trpc.file.getDownloadUrl.useQuery( const [showPreview, setShowPreview] = useState(false)
{ bucket, objectKey, forDownload: false }, const Icon = fileTypeIcons[file.fileType] || File
{ staleTime: 10 * 60 * 1000 } 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 ( return (
<div className="flex items-center gap-1 shrink-0"> <div className="rounded-lg border overflow-hidden">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" asChild disabled={!viewUrl}> <div className="flex items-center justify-between p-3">
<a href={viewUrl || '#'} target="_blank" rel="noopener noreferrer"> <div className="flex items-center gap-3 min-w-0">
<Eye className="h-3 w-3" /> View <Icon className="h-5 w-5 text-muted-foreground shrink-0" />
</a> <div className="min-w-0">
</Button> <div className="flex items-center gap-2">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" asChild disabled={!dlUrl}> <p className="font-medium text-sm truncate">{file.fileName}</p>
<a href={dlUrl || '#'} download={fileName}> {file.isLate && (
<Download className="h-3 w-3" /> Download <Badge variant="warning" className="text-xs gap-1">
</a> <AlertTriangle className="h-3 w-3" />
</Button> 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> </div>
) )
} }
function DownloadButton({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) {
const [downloading, setDownloading] = useState(false)
const { refetch } = trpc.file.getDownloadUrl.useQuery(
{ bucket, objectKey, forDownload: true, fileName, purpose: 'download' as const },
{ enabled: false }
)
const handleDownload = async () => {
setDownloading(true)
try {
const result = await refetch()
if (result.data?.url) {
window.location.href = result.data.url
}
} catch {
toast.error('Failed to download file')
} finally {
setTimeout(() => setDownloading(false), 1000)
}
}
return (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleDownload} disabled={downloading}>
{downloading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
Download
</Button>
)
}
export default function ApplicantDocumentsPage() { export default function ApplicantDocumentsPage() {
const { status: sessionStatus } = useSession() const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated' const isAuthenticated = sessionStatus === 'authenticated'
@@ -113,7 +198,6 @@ export default function ApplicantDocumentsPage() {
} }
const { project, openRounds, isRejected } = data const { project, openRounds, isRejected } = data
const isDraft = !project.submittedAt
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -204,41 +288,9 @@ export default function ApplicantDocumentsPage() {
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{project.files.map((file) => { {project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File const fileRecord = file as typeof file & { isLate?: boolean; bucket?: string; objectKey?: string; mimeType?: string }
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null; bucket?: string; objectKey?: string }
return ( return (
<div <FileRow key={file.id} file={fileRecord} />
key={file.id}
className="flex items-center justify-between p-3 rounded-lg border"
>
<div className="flex items-center gap-3 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>
) )
})} })}
</div> </div>

View File

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

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import { useState, useEffect } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
@@ -13,11 +14,12 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' 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 { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
import { WithdrawButton } from '@/components/applicant/withdraw-button'
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card' import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
import { Progress } from '@/components/ui/progress'
import { import {
FileText, FileText,
Calendar, Calendar,
@@ -29,7 +31,28 @@ import {
ArrowRight, ArrowRight,
Star, Star,
AlertCircle, AlertCircle,
Pencil,
Loader2,
Check,
X,
UserCircle,
Trophy,
Vote,
Clock,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner'
function formatCountdown(ms: number): string {
if (ms <= 0) return 'Closed'
const days = Math.floor(ms / (1000 * 60 * 60 * 24))
const hours = Math.floor((ms % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60))
const parts: string[] = []
if (days > 0) parts.push(`${days}d`)
if (hours > 0) parts.push(`${hours}h`)
parts.push(`${minutes}m`)
return parts.join(' ')
}
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = { const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
DRAFT: 'secondary', DRAFT: 'secondary',
@@ -42,9 +65,13 @@ const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destru
REJECTED: 'destructive', REJECTED: 'destructive',
} }
// Keys to hide from the metadata display (shown elsewhere or internal)
const HIDDEN_METADATA_KEYS = new Set(['TeamMembers', 'teammembers', 'team_members'])
export default function ApplicantDashboardPage() { export default function ApplicantDashboardPage() {
const { data: session, status: sessionStatus } = useSession() const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated' const isAuthenticated = sessionStatus === 'authenticated'
const utils = trpc.useUtils()
const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, { const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
enabled: isAuthenticated, enabled: isAuthenticated,
@@ -62,7 +89,18 @@ export default function ApplicantDashboardPage() {
enabled: isAuthenticated, enabled: isAuthenticated,
}) })
if (sessionStatus === 'loading' || isLoading) { const { data: flags } = trpc.settings.getFeatureFlags.useQuery(undefined, {
enabled: isAuthenticated,
})
// Live countdown timer for open rounds
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), 60_000)
return () => clearInterval(interval)
}, [])
if (sessionStatus === 'loading' || (isAuthenticated && isLoading)) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
@@ -115,20 +153,38 @@ export default function ApplicantDashboardPage() {
const programYear = project.program?.year const programYear = project.program?.year
const programName = project.program?.name const programName = project.program?.name
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0 const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
const canEditDescription = flags?.applicantAllowDescriptionEdit && !isRejected
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header — no withdraw button here */}
<div className="flex items-start justify-between flex-wrap gap-4"> <div className="flex items-start justify-between flex-wrap gap-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Project logo */} {/* Project logo — clickable for any team member to change */}
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden"> <ProjectLogoUpload
{data.logoUrl ? ( projectId={project.id}
<img src={data.logoUrl} alt={project.title} className="h-full w-full object-cover" /> currentLogoUrl={data.logoUrl}
) : ( onUploadComplete={() => utils.applicant.getMyDashboard.invalidate()}
<FileText className="h-7 w-7 text-muted-foreground/60" /> >
)} <button
</div> 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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1> <h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
@@ -143,11 +199,50 @@ export default function ApplicantDashboardPage() {
</p> </p>
</div> </div>
</div> </div>
{project.isTeamLead && currentStatus !== 'REJECTED' && (currentStatus as string) !== 'WINNER' && (
<WithdrawButton projectId={project.id} />
)}
</div> </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"> <div className="grid gap-6 lg:grid-cols-3">
{/* Main content */} {/* Main content */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
@@ -164,12 +259,19 @@ export default function ApplicantDashboardPage() {
<p>{project.teamName}</p> <p>{project.teamName}</p>
</div> </div>
)} )}
{project.description && ( {/* Description — editable if admin allows */}
{project.description && !canEditDescription && (
<div> <div>
<p className="text-sm font-medium text-muted-foreground">Description</p> <p className="text-sm font-medium text-muted-foreground">Description</p>
<p className="whitespace-pre-wrap">{project.description}</p> <p className="whitespace-pre-wrap">{project.description}</p>
</div> </div>
)} )}
{canEditDescription && (
<EditableDescription
projectId={project.id}
initialDescription={project.description || ''}
/>
)}
{project.tags && project.tags.length > 0 && ( {project.tags && project.tags.length > 0 && (
<div> <div>
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p> <p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
@@ -183,22 +285,27 @@ export default function ApplicantDashboardPage() {
</div> </div>
)} )}
{/* Metadata */} {/* Metadata — filter out team members (shown in sidebar) */}
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && ( {project.metadataJson && (() => {
<div className="border-t pt-4 mt-4"> const entries = Object.entries(project.metadataJson as Record<string, unknown>)
<p className="text-sm font-medium text-muted-foreground mb-3">Additional Information</p> .filter(([key]) => !HIDDEN_METADATA_KEYS.has(key))
<dl className="space-y-2"> if (entries.length === 0) return null
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => ( return (
<div key={key} className="flex justify-between"> <div className="border-t pt-4 mt-4">
<dt className="text-sm text-muted-foreground capitalize"> <p className="text-sm font-medium text-muted-foreground mb-3">Additional Information</p>
{key.replace(/_/g, ' ')} <dl className="space-y-2">
</dt> {entries.map(([key, value]) => (
<dd className="text-sm font-medium">{String(value)}</dd> <div key={key} className="flex justify-between gap-4">
</div> <dt className="text-sm text-muted-foreground capitalize shrink-0">
))} {key.replace(/_/g, ' ')}
</dl> </dt>
</div> <dd className="text-sm font-medium text-right">{String(value)}</dd>
)} </div>
))}
</dl>
</div>
)
})()}
{/* Meta info row */} {/* Meta info row */}
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground border-t pt-4 mt-4"> <div className="flex flex-wrap gap-4 text-sm text-muted-foreground border-t pt-4 mt-4">
@@ -235,53 +342,6 @@ export default function ApplicantDashboardPage() {
</AnimatedCard> </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 */} {/* Document Completeness */}
{docCompleteness && docCompleteness.length > 0 && ( {docCompleteness && docCompleteness.length > 0 && (
@@ -318,7 +378,7 @@ export default function ApplicantDashboardPage() {
{/* Sidebar */} {/* Sidebar */}
<div className="space-y-6"> <div className="space-y-6">
{/* Competition timeline or status tracker */} {/* Competition timeline */}
<AnimatedCard index={3}> <AnimatedCard index={3}>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -330,7 +390,7 @@ export default function ApplicantDashboardPage() {
</Card> </Card>
</AnimatedCard> </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) => ( {project.isTeamLead && openRounds.filter((r) => r.roundType === 'MENTORING').map((mentoringRound) => (
<AnimatedCard key={mentoringRound.id} index={4}> <AnimatedCard key={mentoringRound.id} index={4}>
<MentoringRequestCard <MentoringRequestCard
@@ -348,7 +408,9 @@ export default function ApplicantDashboardPage() {
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Star className="h-5 w-5" /> <div className="rounded-lg bg-yellow-500/10 p-1.5">
<Star className="h-4 w-4 text-yellow-500" />
</div>
Jury Feedback Jury Feedback
</CardTitle> </CardTitle>
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" asChild>
@@ -358,17 +420,53 @@ export default function ApplicantDashboardPage() {
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-3">
<p className="text-sm text-muted-foreground"> {evaluations?.map((round) => {
{totalEvaluations} evaluation{totalEvaluations !== 1 ? 's' : ''} available from{' '} const scores = round.evaluations
{evaluations?.length ?? 0} round{(evaluations?.length ?? 0) !== 1 ? 's' : ''}. .map((ev) => ev.globalScore)
</p> .filter((s): s is number => s !== null)
const avgScore = scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length
: null
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100
const pct = avgScore !== null ? (avgScore / maxScore) * 100 : 0
const roundIcon = round.roundType === 'LIVE_FINAL'
? <Trophy className="h-3.5 w-3.5 text-amber-500" />
: round.roundType === 'DELIBERATION'
? <Vote className="h-3.5 w-3.5 text-violet-500" />
: <Star className="h-3.5 w-3.5 text-yellow-500" />
return (
<div key={round.roundId} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium">
{roundIcon}
{round.roundName}
</span>
<Badge variant="secondary" className="text-xs">
{round.evaluationCount} review{round.evaluationCount !== 1 ? 's' : ''}
</Badge>
</div>
{avgScore !== null && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Avg Score</span>
<span className="font-semibold text-foreground tabular-nums">
{avgScore.toFixed(1)}<span className="text-muted-foreground font-normal"> / {maxScore}</span>
</span>
</div>
<Progress value={pct} className="h-1.5" />
</div>
)}
</div>
)
})}
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard> </AnimatedCard>
)} )}
{/* Team overview */} {/* Team overview — proper cards */}
<AnimatedCard index={5}> <AnimatedCard index={5}>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -384,27 +482,25 @@ export default function ApplicantDashboardPage() {
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-2">
{project.teamMembers.length > 0 ? ( {project.teamMembers.length > 0 ? (
project.teamMembers.slice(0, 5).map((member) => ( project.teamMembers.slice(0, 5).map((member) => (
<div key={member.id} className="flex items-center gap-3"> <div key={member.id} className="flex items-center gap-3 rounded-lg border p-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted shrink-0">
{member.role === 'LEAD' ? ( {member.role === 'LEAD' ? (
<Crown className="h-4 w-4 text-yellow-500" /> <Crown className="h-4 w-4 text-amber-500" />
) : ( ) : (
<span className="text-xs font-medium"> <UserCircle className="h-4 w-4 text-muted-foreground" />
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate"> <p className="text-sm font-medium truncate">
{member.user.name || member.user.email} {member.user.name || member.user.email}
</p> </p>
<p className="text-xs text-muted-foreground">
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</p>
</div> </div>
<Badge variant="outline" className="shrink-0 text-[10px] px-1.5 py-0">
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</Badge>
</div> </div>
)) ))
) : ( ) : (
@@ -474,3 +570,69 @@ export default function ApplicantDashboardPage() {
</div> </div>
) )
} }
function EditableDescription({ projectId, initialDescription }: { projectId: string; initialDescription: string }) {
const [isEditing, setIsEditing] = useState(false)
const [description, setDescription] = useState(initialDescription)
const utils = trpc.useUtils()
const mutation = trpc.applicant.updateDescription.useMutation({
onSuccess: () => {
utils.applicant.getMyDashboard.invalidate()
setIsEditing(false)
toast.success('Description updated')
},
onError: (e) => toast.error(e.message),
})
const handleSave = () => {
mutation.mutate({ projectId, description })
}
const handleCancel = () => {
setDescription(initialDescription)
setIsEditing(false)
}
if (!isEditing) {
return (
<div>
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium text-muted-foreground">Description</p>
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs gap-1" onClick={() => setIsEditing(true)}>
<Pencil className="h-3 w-3" />
Edit
</Button>
</div>
<p className="whitespace-pre-wrap">{initialDescription || <span className="text-muted-foreground italic">No description yet. Click Edit to add one.</span>}</p>
</div>
)
}
return (
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">Description</p>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={6}
className="mb-2"
disabled={mutation.isPending}
/>
<div className="flex items-center gap-2 justify-end">
<Button variant="ghost" size="sm" onClick={handleCancel} disabled={mutation.isPending}>
<X className="h-3.5 w-3.5 mr-1" />
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={mutation.isPending}>
{mutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Check className="h-3.5 w-3.5 mr-1" />
)}
Save
</Button>
</div>
</div>
)
}

View File

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

View File

@@ -68,8 +68,10 @@ import {
GraduationCap, GraduationCap,
Heart, Heart,
Calendar, Calendar,
Pencil,
} from 'lucide-react' } from 'lucide-react'
import { formatDateOnly } from '@/lib/utils' import { formatDateOnly } from '@/lib/utils'
import { CountryDisplay } from '@/components/shared/country-display'
const inviteSchema = z.object({ const inviteSchema = z.object({
name: z.string().min(1, 'Name is required'), name: z.string().min(1, 'Name is required'),
@@ -243,14 +245,31 @@ export default function ApplicantProjectPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Project logo */} {/* Project logo — clickable for any team member to change */}
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden"> <ProjectLogoUpload
{logoUrl ? ( projectId={projectId}
<img src={logoUrl} alt={project.title} className="h-full w-full object-cover" /> currentLogoUrl={logoUrl}
) : ( onUploadComplete={() => refetchLogo()}
<FolderOpen className="h-7 w-7 text-muted-foreground/60" /> >
)} <button
</div> type="button"
className="group relative shrink-0 flex flex-col items-center gap-1 cursor-pointer"
>
<div className="relative h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden hover:ring-2 hover:ring-primary/30 transition-all">
{logoUrl ? (
<img src={logoUrl} alt={project.title} className="h-full w-full object-cover" />
) : (
<FolderOpen className="h-7 w-7 text-muted-foreground/60" />
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<Pencil className="h-4 w-4 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
<span className="text-[10px] text-primary/70 group-hover:text-primary transition-colors">
{logoUrl ? 'Change' : 'Add logo'}
</span>
</button>
</ProjectLogoUpload>
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
{project.title} {project.title}
@@ -314,7 +333,7 @@ export default function ApplicantProjectPage() {
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" /> <MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
<div> <div>
<p className="text-sm font-medium text-muted-foreground">Location</p> <p className="text-sm font-medium text-muted-foreground">Location</p>
<p className="text-sm">{project.geographicZone || project.country}</p> <p className="text-sm">{project.geographicZone}{project.geographicZone && project.country ? ', ' : ''}{project.country ? <CountryDisplay country={project.country} /> : null}</p>
</div> </div>
</div> </div>
)} )}
@@ -365,7 +384,7 @@ export default function ApplicantProjectPage() {
</Card> </Card>
{/* Project Logo */} {/* Project Logo */}
{isTeamLead && projectId && ( {projectId && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <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' type InviteState = 'loading' | 'valid' | 'accepting' | 'error'
function ErrorRedirectCard({
errorContent,
redirectTarget,
}: {
errorContent: { icon: React.ReactNode; title: string; description: string; redirect?: string }
redirectTarget: string
}) {
const router = useRouter()
useEffect(() => {
const timer = setTimeout(() => {
router.push(redirectTarget)
}, 4000)
return () => clearTimeout(timer)
}, [redirectTarget, router])
return (
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100">
{errorContent.icon}
</div>
<CardTitle className="text-xl">{errorContent.title}</CardTitle>
<CardDescription className="text-base">
{errorContent.description}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button
variant="outline"
className="w-full"
onClick={() => router.push(redirectTarget)}
>
Go to Login
</Button>
<p className="text-xs text-center text-muted-foreground">
Redirecting to login in a few seconds...
</p>
</CardContent>
</Card>
</AnimatedCard>
)
}
function AcceptInviteContent() { function AcceptInviteContent() {
const [state, setState] = useState<InviteState>('loading') const [state, setState] = useState<InviteState>('loading')
const [errorType, setErrorType] = useState<string | null>(null) const [errorType, setErrorType] = useState<string | null>(null)
@@ -105,18 +151,21 @@ function AcceptInviteContent() {
icon: <XCircle className="h-6 w-6 text-red-600" />, icon: <XCircle className="h-6 w-6 text-red-600" />,
title: 'Invalid Invitation', title: 'Invalid Invitation',
description: 'This invitation link is not valid. It may have already been used or the link is incorrect.', description: 'This invitation link is not valid. It may have already been used or the link is incorrect.',
redirect: '/login?expired=1',
} }
case 'EXPIRED_TOKEN': case 'EXPIRED_TOKEN':
return { return {
icon: <Clock className="h-6 w-6 text-amber-600" />, icon: <Clock className="h-6 w-6 text-amber-600" />,
title: 'Invitation Expired', title: 'Invitation Expired',
description: 'This invitation has expired. Please contact your administrator to receive a new invitation.', description: 'This invitation has expired. Please contact your administrator to receive a new invitation.',
redirect: '/login?expired=1',
} }
case 'ALREADY_ACCEPTED': case 'ALREADY_ACCEPTED':
return { return {
icon: <CheckCircle2 className="h-6 w-6 text-blue-600" />, icon: <CheckCircle2 className="h-6 w-6 text-blue-600" />,
title: 'Already Accepted', title: 'Already Accepted',
description: 'This invitation has already been accepted. You can sign in with your credentials.', description: 'This invitation has already been accepted. You can sign in with your credentials.',
redirect: '/login',
} }
case 'AUTH_FAILED': case 'AUTH_FAILED':
return { return {
@@ -148,34 +197,12 @@ function AcceptInviteContent() {
) )
} }
// Error state // Error state — auto-redirect to login after 4 seconds for known errors
if (state === 'error') { if (state === 'error') {
const errorContent = getErrorContent() const errorContent = getErrorContent()
return ( const redirectTarget = errorContent.redirect || '/login'
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden"> return <ErrorRedirectCard errorContent={errorContent} redirectTarget={redirectTarget} />
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100">
{errorContent.icon}
</div>
<CardTitle className="text-xl">{errorContent.title}</CardTitle>
<CardDescription className="text-base">
{errorContent.description}
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="outline"
className="w-full"
onClick={() => router.push('/login')}
>
Go to Login
</Button>
</CardContent>
</Card>
</AnimatedCard>
)
} }
// Valid invitation - show welcome // Valid invitation - show welcome

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,7 @@ import {
AlertTriangle, AlertTriangle,
Search, Search,
} from 'lucide-react' } from 'lucide-react'
import { CountryDisplay } from '@/components/shared/country-display'
type AwardShortlistProps = { type AwardShortlistProps = {
awardId: string awardId: string
@@ -342,7 +343,13 @@ export function AwardShortlist({
</a> </a>
</p> </p>
<p className="text-xs text-muted-foreground"> <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> </p>
</div> </div>
</td> </td>

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,71 +3,19 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
type SubmissionConfigProps = { type SubmissionConfigProps = {
config: Record<string, unknown> config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void 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) { export function SubmissionConfig({ config, onChange }: SubmissionConfigProps) {
const update = (key: string, value: unknown) => { const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value }) 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 ( return (
<div className="space-y-6"> <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> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Notifications & Locking</CardTitle> <CardTitle className="text-base">Notifications & Locking</CardTitle>

View File

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

View File

@@ -6,6 +6,7 @@ import type { Route } from 'next'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { directSessionUpdate } from '@/lib/session-update'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
@@ -71,7 +72,7 @@ function getRoleHomePath(role: string): string {
export function UserActions({ userId, userEmail, userStatus, userRole, userRoles, currentUserRole }: UserActionsProps) { export function UserActions({ userId, userEmail, userStatus, userRole, userRoles, currentUserRole }: UserActionsProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isSending, setIsSending] = useState(false) const [isSending, setIsSending] = useState(false)
const { data: session, update } = useSession() const { data: session } = useSession()
const router = useRouter() const router = useRouter()
const utils = trpc.useUtils() const utils = trpc.useUtils()
@@ -125,10 +126,13 @@ export function UserActions({ userId, userEmail, userStatus, userRole, userRoles
const handleImpersonate = async () => { const handleImpersonate = async () => {
try { try {
const result = await startImpersonation.mutateAsync({ targetUserId: userId }) const result = await startImpersonation.mutateAsync({ targetUserId: userId })
await update({ impersonate: userId }) // Direct POST to session endpoint — bypasses useSession().update()'s loading gate
toast.success(`Now impersonating ${userEmail}`) const ok = await directSessionUpdate({ impersonate: userId })
router.push(getRoleHomePath(result.targetRole) as Route) if (!ok) {
router.refresh() toast.error('Failed to update session for impersonation')
return
}
window.location.href = getRoleHomePath(result.targetRole)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to start impersonation') toast.error(error instanceof Error ? error.message : 'Failed to start impersonation')
} }
@@ -279,7 +283,7 @@ export function UserMobileActions({
currentUserRole, currentUserRole,
}: UserMobileActionsProps) { }: UserMobileActionsProps) {
const [isSending, setIsSending] = useState(false) const [isSending, setIsSending] = useState(false)
const { data: session, update } = useSession() const { data: session } = useSession()
const router = useRouter() const router = useRouter()
const utils = trpc.useUtils() const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation() const sendInvitation = trpc.user.sendInvitation.useMutation()
@@ -301,10 +305,12 @@ export function UserMobileActions({
const handleImpersonateMobile = async () => { const handleImpersonateMobile = async () => {
try { try {
const result = await startImpersonation.mutateAsync({ targetUserId: userId }) const result = await startImpersonation.mutateAsync({ targetUserId: userId })
await update({ impersonate: userId }) const ok = await directSessionUpdate({ impersonate: userId })
toast.success(`Now impersonating ${userEmail}`) if (!ok) {
router.push(getRoleHomePath(result.targetRole) as Route) toast.error('Failed to update session for impersonation')
router.refresh() return
}
window.location.href = getRoleHomePath(result.targetRole)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to start impersonation') 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? // Is this entry after the elimination point?
const isAfterElimination = eliminationIndex >= 0 && index > eliminationIndex const isAfterElimination = eliminationIndex >= 0 && index > eliminationIndex
// Is this the current round the project is in (regardless of round status)? // Is this the current round? Either has an active project state,
const isCurrent = !!entry.projectState && entry.projectState !== 'PASSED' && entry.projectState !== 'COMPLETED' && entry.projectState !== 'REJECTED' // 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' let connectorColor = 'bg-border'
if ((isPassed || isCompleted) && !isAfterElimination) connectorColor = 'bg-emerald-400' const nextEntry = data.entries[index + 1]
else if (isRejected) connectorColor = 'bg-destructive/30' 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 // Dot inner content
let dotInner: React.ReactNode = null let dotInner: React.ReactNode = null
@@ -222,7 +244,7 @@ export function CompetitionTimelineSidebar() {
} else if (isGrandFinale && (isCompleted || isPassed)) { } else if (isGrandFinale && (isCompleted || isPassed)) {
dotClasses = 'bg-yellow-500 border-2 border-yellow-500' dotClasses = 'bg-yellow-500 border-2 border-yellow-500'
dotInner = <Trophy className="h-3.5 w-3.5 text-white" /> 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' dotClasses = 'bg-emerald-500 border-2 border-emerald-500'
dotInner = <Check className="h-3.5 w-3.5 text-white" /> dotInner = <Check className="h-3.5 w-3.5 text-white" />
} else if (isCurrent) { } else if (isCurrent) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ import {
} from '@/components/ui/card' } from '@/components/ui/card'
import { StatusBadge } from '@/components/shared/status-badge' import { StatusBadge } from '@/components/shared/status-badge'
import { ProjectLogo } from '@/components/shared/project-logo' 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' import { formatDateOnly, truncate, formatRelativeTime } from '@/lib/utils'
type BaseProject = { type BaseProject = {
@@ -133,13 +133,18 @@ export function ProjectListCompact({
)} )}
</> </>
) : ( ) : (
[ <>
project.teamName, {[
project.country ? getCountryName(project.country) : null, project.teamName,
formatDateOnly(project.submittedAt || project.createdAt), formatDateOnly(project.submittedAt || project.createdAt),
] ].filter(Boolean).join(' \u00b7 ')}
.filter(Boolean) {project.country && (() => {
.join(' \u00b7 ') 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> </p>
</div> </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, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
@@ -315,26 +318,6 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
)} )}
</nav> </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 */} {/* User Profile Section */}
<div className="border-t p-3"> <div className="border-t p-3">
<DropdownMenu> <DropdownMenu>
@@ -393,23 +376,41 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
{switchableRoles.length > 0 && ( {switchableRoles.length > 0 && (
<> <>
<DropdownMenuSeparator className="my-1" /> <DropdownMenuSeparator className="my-1" />
<div className="px-2 py-1.5"> {switchableRoles.length <= 2 ? (
<p className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60"> // Flat list for 1-2 roles
<ArrowRightLeft className="h-3 w-3" /> switchableRoles.map(([, opt]) => (
Switch View <DropdownMenuItem key={opt.path} asChild>
</p> <Link
</div> href={opt.path as Route}
{switchableRoles.map(([, opt]) => ( className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
<DropdownMenuItem key={opt.path} asChild> >
<Link <opt.icon className="h-4 w-4 text-muted-foreground" />
href={opt.path as Route} <span>{opt.label}</span>
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2" </Link>
> </DropdownMenuItem>
<opt.icon className="h-4 w-4 text-muted-foreground" /> ))
<span>{opt.label}</span> ) : (
</Link> // Submenu for 3+ roles
</DropdownMenuItem> <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
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>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</> </>
)} )}

View File

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

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { signOut, useSession } from 'next-auth/react' import { signOut, useSession } from 'next-auth/react'
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { UserAvatar } from '@/components/shared/user-avatar' import { UserAvatar } from '@/components/shared/user-avatar'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { directSessionUpdate } from '@/lib/session-update'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -71,17 +72,40 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { data: session, status: sessionStatus, update: updateSession } = useSession() const { data: session, status: sessionStatus, update: updateSession } = useSession()
const isAuthenticated = sessionStatus === 'authenticated' const isAuthenticated = sessionStatus === 'authenticated'
const isImpersonating = !!session?.user?.impersonating
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, { const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated, enabled: isAuthenticated,
}) })
const endImpersonation = trpc.user.endImpersonation.useMutation()
const logNavClick = trpc.learningResource.logNavClick.useMutation()
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), []) 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(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated && !sessionRefreshedRef.current) {
updateSession() 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated]) }, [isAuthenticated])
@@ -115,7 +139,12 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
) )
if (item.external) { if (item.external) {
return ( 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.icon className="h-4 w-4" />
{item.name} {item.name}
<ExternalLinkIcon className="h-3 w-3 opacity-50" /> <ExternalLinkIcon className="h-3 w-3 opacity-50" />
@@ -211,11 +240,11 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })} onClick={handleSignOut}
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
Sign Out {isImpersonating ? 'Return to Admin' : 'Sign Out'}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -257,7 +286,12 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
) )
if (item.external) { if (item.external) {
return ( 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.icon className="h-4 w-4" />
{item.name} {item.name}
<ExternalLinkIcon className="h-3 w-3 opacity-50" /> <ExternalLinkIcon className="h-3 w-3 opacity-50" />
@@ -299,10 +333,10 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
<Button <Button
variant="ghost" variant="ghost"
className="w-full justify-start text-destructive hover:text-destructive" className="w-full justify-start text-destructive hover:text-destructive"
onClick={() => signOut({ callbackUrl: '/login' })} onClick={handleSignOut}
> >
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
Sign Out {isImpersonating ? 'Return to Admin' : 'Sign Out'}
</Button> </Button>
</div> </div>
</nav> </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