Compare commits

...

66 Commits

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

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

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

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

Migration: 20260308000000_drop_dead_models_and_stale_columns

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:42:21 +01:00
214 changed files with 20220 additions and 8549 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}
@@ -50,6 +50,7 @@ services:
condition: service_healthy condition: service_healthy
networks: networks:
- mopc-network - mopc-network
- minio-external
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:7600/api/health').then(r=>{if(!r.ok)throw r;process.exit(0)}).catch(()=>process.exit(1))"] test: ["CMD", "node", "-e", "fetch('http://localhost:7600/api/health').then(r=>{if(!r.ok)throw r;process.exit(0)}).catch(()=>process.exit(1))"]
interval: 30s interval: 30s
@@ -82,3 +83,6 @@ volumes:
networks: networks:
mopc-network: mopc-network:
driver: bridge driver: bridge
minio-external:
external: true
name: minio_mopc-minio

View File

@@ -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,2 @@
-- AlterTable
ALTER TABLE "AwardEligibility" ADD COLUMN "notifiedAt" TIMESTAMP(3);

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,10 @@ generator client {
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
// connection_limit and pool_timeout are set via query params in DATABASE_URL:
// ?connection_limit=10&pool_timeout=30
// Defaults: connection_limit = num_cpus * 2 + 1, pool_timeout = 10s.
// Override in .env for production to prevent connection exhaustion.
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
@@ -130,13 +134,6 @@ enum PartnerType {
OTHER OTHER
} }
enum OverrideReasonCode {
DATA_CORRECTION
POLICY_EXCEPTION
JURY_CONFLICT
SPONSOR_DECISION
ADMIN_DISCRETION
}
// ============================================================================= // =============================================================================
// COMPETITION / ROUND ENGINE ENUMS // COMPETITION / ROUND ENGINE ENUMS
@@ -175,13 +172,6 @@ enum ProjectRoundStateValue {
WITHDRAWN WITHDRAWN
} }
enum AdvancementRuleType {
AUTO_ADVANCE
SCORE_THRESHOLD
TOP_N
ADMIN_SELECTION
AI_RECOMMENDED
}
enum CapMode { enum CapMode {
HARD HARD
@@ -302,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
@@ -638,6 +630,7 @@ model Project {
deliberationVotes DeliberationVote[] deliberationVotes DeliberationVote[]
deliberationResults DeliberationResult[] deliberationResults DeliberationResult[]
submissionPromotions SubmissionPromotionEvent[] submissionPromotions SubmissionPromotionEvent[]
notificationLogs NotificationLog[]
@@index([programId]) @@index([programId])
@@index([status]) @@index([status])
@@ -754,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])
@@ -763,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 {
@@ -780,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
@@ -931,22 +919,35 @@ model AIUsageLog {
model NotificationLog { model NotificationLog {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String?
channel NotificationChannel channel NotificationChannel @default(EMAIL)
provider String? // META, TWILIO, SMTP provider String? // META, TWILIO, SMTP
type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION, ADVANCEMENT_NOTIFICATION, etc.
status String // PENDING, SENT, DELIVERED, FAILED status String // PENDING, SENT, DELIVERED, FAILED
externalId String? // Message ID from provider externalId String? // Message ID from provider
errorMsg String? @db.Text errorMsg String? @db.Text
// Bulk notification tracking
email String? // Recipient email address
roundId String?
projectId String?
batchId String? // Groups emails from same send operation
createdAt DateTime @default(now()) createdAt DateTime @default(now())
// Relations // Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
@@index([userId]) @@index([userId])
@@index([status]) @@index([status])
@@index([createdAt]) @@index([createdAt])
@@index([roundId, type])
@@index([projectId])
@@index([batchId])
@@index([email])
@@index([type, status])
} }
// ============================================================================= // =============================================================================
@@ -1477,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
@@ -1622,6 +1624,9 @@ model AwardEligibility {
confirmedAt DateTime? confirmedAt DateTime?
confirmedBy String? confirmedBy String?
// Pool notification tracking
notifiedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -1702,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
@@ -1720,6 +1724,8 @@ model ConflictOfInterest {
@@index([userId]) @@index([userId])
@@index([hasConflict]) @@index([hasConflict])
@@index([projectId])
@@index([userId, hasConflict])
} }
// ============================================================================= // =============================================================================
@@ -2082,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.
@@ -2117,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)
// ============================================================================= // =============================================================================
@@ -2207,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[]
@@ -2230,6 +2202,7 @@ model Round {
evaluationSummaries EvaluationSummary[] evaluationSummaries EvaluationSummary[]
evaluationDiscussions EvaluationDiscussion[] evaluationDiscussions EvaluationDiscussion[]
messages Message[] messages Message[]
notificationLogs NotificationLog[]
cohorts Cohort[] cohorts Cohort[]
liveCursor LiveProgressCursor? liveCursor LiveProgressCursor?
@@ -2262,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])
} }
// ============================================================================= // =============================================================================
@@ -2458,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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

@@ -93,7 +93,27 @@ import {
Layers, Layers,
Info, Info,
Mail, Mail,
GripVertical,
ArrowRight,
} from 'lucide-react' } from 'lucide-react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { CountryDisplay } from '@/components/shared/country-display'
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = { const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
DRAFT: 'secondary', DRAFT: 'secondary',
@@ -116,6 +136,199 @@ function getStepIndex(status: string): number {
return idx >= 0 ? idx : (status === 'ARCHIVED' ? 3 : 0) return idx >= 0 ? idx : (status === 'ARCHIVED' ? 3 : 0)
} }
const ROUND_TYPE_COLORS: Record<string, string> = {
EVALUATION: 'bg-violet-100 text-violet-700',
FILTERING: 'bg-amber-100 text-amber-700',
SUBMISSION: 'bg-blue-100 text-blue-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-rose-100 text-rose-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
const ROUND_STATUS_COLORS: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-600',
ACTIVE: 'bg-emerald-100 text-emerald-700',
CLOSED: 'bg-blue-100 text-blue-700',
ARCHIVED: 'bg-muted text-muted-foreground',
}
function SortableRoundCard({
round,
index,
isFirst,
onDelete,
isDeleting,
}: {
round: any
index: number
isFirst: boolean
onDelete: (roundId: string) => void
isDeleting: boolean
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: round.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
const projectCount = round._count?.projectRoundStates ?? 0
const assignmentCount = round._count?.assignments ?? 0
const statusLabel = round.status.replace('ROUND_', '')
return (
<Card
ref={setNodeRef}
style={style}
className={`hover:shadow-md transition-shadow ${isDragging ? 'opacity-50 shadow-lg z-50' : ''}`}
>
<CardContent className="pt-4 pb-3 space-y-3">
<div className="flex items-start gap-2.5">
<button
className="cursor-grab touch-none text-muted-foreground hover:text-foreground mt-1 shrink-0"
aria-label="Drag to reorder"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
{index + 1}
</div>
<div className="min-w-0 flex-1">
<Link href={`/admin/rounds/${round.id}` as any} className="text-sm font-semibold truncate hover:underline">
{round.name}
</Link>
<div className="flex flex-wrap gap-1.5 mt-1">
<Badge variant="secondary" className={`text-[10px] ${ROUND_TYPE_COLORS[round.roundType] ?? 'bg-gray-100 text-gray-700'}`}>
{round.roundType.replace('_', ' ')}
</Badge>
<Badge variant="outline" className={`text-[10px] ${ROUND_STATUS_COLORS[statusLabel]}`}>
{statusLabel}
</Badge>
{isFirst && (
<Badge variant="outline" className="text-[10px] border-amber-300 bg-amber-50 text-amber-700">
Entry point
</Badge>
)}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Layers className="h-3.5 w-3.5" />
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
</div>
{assignmentCount > 0 && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<ListChecks className="h-3.5 w-3.5" />
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
</div>
)}
</div>
{round.status === 'ROUND_DRAFT' && (
<div className="flex justify-end pt-1">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
<Trash2 className="h-3.5 w-3.5 mr-1" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Round</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{round.name}&quot;. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(round.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</CardContent>
</Card>
)
}
function RoundsDndGrid({
rounds,
awardId,
onReorder,
onDelete,
isDeleting,
}: {
rounds: any[]
awardId: string
onReorder: (roundIds: string[]) => void
onDelete: (roundId: string) => void
isDeleting: boolean
}) {
const [items, setItems] = useState(rounds.map((r: any) => r.id))
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
// Sync if server data changes
useEffect(() => {
setItems(rounds.map((r: any) => r.id))
}, [rounds])
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = items.indexOf(active.id as string)
const newIndex = items.indexOf(over.id as string)
const newItems = arrayMove(items, oldIndex, newIndex)
setItems(newItems)
onReorder(newItems)
}
const roundMap = new Map(rounds.map((r: any) => [r.id, r]))
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{items.map((id, index) => {
const round = roundMap.get(id)
if (!round) return null
return (
<SortableRoundCard
key={id}
round={round}
index={index}
isFirst={index === 0}
onDelete={onDelete}
isDeleting={isDeleting}
/>
)
})}
</div>
</SortableContext>
</DndContext>
)
}
function ConfidenceBadge({ confidence }: { confidence: number }) { function ConfidenceBadge({ confidence }: { confidence: number }) {
if (confidence > 0.8) { if (confidence > 0.8) {
return ( return (
@@ -286,6 +499,18 @@ export default function AwardDetailPage({
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
const reorderRounds = trpc.specialAward.reorderAwardRounds.useMutation({
onSuccess: () => refetchRounds(),
onError: (err) => toast.error(err.message),
})
const assignToFirstRound = trpc.specialAward.assignToFirstRound.useMutation({
onSuccess: (result) => {
toast.success(`Assigned ${result.totalAssigned} projects to first round (${result.createdCount} new, ${result.movedCount} moved)`)
refetchRounds()
refetch()
},
onError: (err) => toast.error(err.message),
})
const notifyPreview = trpc.specialAward.previewAwardSelectionEmail.useQuery( const notifyPreview = trpc.specialAward.previewAwardSelectionEmail.useQuery(
{ awardId, customMessage: notifyCustomMessage }, { awardId, customMessage: notifyCustomMessage },
@@ -439,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>
@@ -494,7 +717,7 @@ export default function AwardDetailPage({
open={notifyDialogOpen} open={notifyDialogOpen}
onOpenChange={setNotifyDialogOpen} onOpenChange={setNotifyDialogOpen}
title="Notify Eligible Projects" title="Notify Eligible Projects"
description={`Send "Selected for ${award.name}" emails to all ${award.eligibleCount} eligible projects.`} description={`Send "Under consideration for ${award.name}" emails to all ${award.eligibleCount} eligible projects.`}
recipientCount={notifyPreview.data?.recipientCount ?? 0} recipientCount={notifyPreview.data?.recipientCount ?? 0}
previewHtml={notifyPreview.data?.html} previewHtml={notifyPreview.data?.html}
isPreviewLoading={notifyPreview.isLoading} isPreviewLoading={notifyPreview.isLoading}
@@ -502,13 +725,26 @@ export default function AwardDetailPage({
isSending={notifyEligible.isPending} isSending={notifyEligible.isPending}
onRefreshPreview={(msg) => setNotifyCustomMessage(msg)} onRefreshPreview={(msg) => setNotifyCustomMessage(msg)}
/> />
<Button {award.eligibilityMode === 'SEPARATE_POOL' ? (
onClick={() => handleStatusChange('VOTING_OPEN')} <Button
disabled={updateStatus.isPending} onClick={() => assignToFirstRound.mutate({ awardId })}
> disabled={assignToFirstRound.isPending || award.eligibleCount === 0}
<Play className="mr-2 h-4 w-4" /> >
Open Voting {assignToFirstRound.isPending ? (
</Button> <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Assigning...</>
) : (
<><ArrowRight className="mr-2 h-4 w-4" />Assign to First Round</>
)}
</Button>
) : (
<Button
onClick={() => handleStatusChange('VOTING_OPEN')}
disabled={updateStatus.isPending}
>
<Play className="mr-2 h-4 w-4" />
Open Voting
</Button>
)}
</> </>
)} )}
{award.status === 'VOTING_OPEN' && ( {award.status === 'VOTING_OPEN' && (
@@ -787,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"
@@ -953,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</>}
@@ -1100,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'}
@@ -1243,99 +1479,13 @@ export default function AwardDetailPage({
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3"> <RoundsDndGrid
{awardRounds.map((round: any, index: number) => { rounds={awardRounds}
const projectCount = round._count?.projectRoundStates ?? 0 awardId={awardId}
const assignmentCount = round._count?.assignments ?? 0 onReorder={(roundIds) => reorderRounds.mutate({ awardId, roundIds })}
const statusLabel = round.status.replace('ROUND_', '') onDelete={(roundId) => deleteRound.mutate({ roundId })}
const statusColors: Record<string, string> = { isDeleting={deleteRound.isPending}
DRAFT: 'bg-gray-100 text-gray-600', />
ACTIVE: 'bg-emerald-100 text-emerald-700',
CLOSED: 'bg-blue-100 text-blue-700',
ARCHIVED: 'bg-muted text-muted-foreground',
}
const roundTypeColors: Record<string, string> = {
EVALUATION: 'bg-violet-100 text-violet-700',
FILTERING: 'bg-amber-100 text-amber-700',
SUBMISSION: 'bg-blue-100 text-blue-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-rose-100 text-rose-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
return (
<Card key={round.id} className="hover:shadow-md transition-shadow h-full">
<CardContent className="pt-4 pb-3 space-y-3">
<div className="flex items-start gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
{index + 1}
</div>
<div className="min-w-0 flex-1">
<Link href={`/admin/rounds/${round.id}` as any} className="text-sm font-semibold truncate hover:underline">
{round.name}
</Link>
<div className="flex flex-wrap gap-1.5 mt-1">
<Badge variant="secondary" className={`text-[10px] ${roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}`}>
{round.roundType.replace('_', ' ')}
</Badge>
<Badge variant="outline" className={`text-[10px] ${statusColors[statusLabel]}`}>
{statusLabel}
</Badge>
{index === 0 && (
<Badge variant="outline" className="text-[10px] border-amber-300 bg-amber-50 text-amber-700">
Entry point
</Badge>
)}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Layers className="h-3.5 w-3.5" />
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
</div>
{assignmentCount > 0 && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<ListChecks className="h-3.5 w-3.5" />
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
</div>
)}
</div>
{round.status === 'ROUND_DRAFT' && (
<div className="flex justify-end pt-1">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
<Trash2 className="h-3.5 w-3.5 mr-1" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Round</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{round.name}&quot;. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteRound.mutate({ roundId: round.id })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</CardContent>
</Card>
)
})}
</div>
)} )}
</TabsContent> </TabsContent>

View File

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

View File

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

View File

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

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,
@@ -23,6 +24,29 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { FileViewer } from '@/components/shared/file-viewer' import { FileViewer } from '@/components/shared/file-viewer'
import { FileUpload } from '@/components/shared/file-upload' import { FileUpload } from '@/components/shared/file-upload'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url' import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
@@ -37,7 +61,6 @@ import {
Users, Users,
FileText, FileText,
Calendar, Calendar,
Clock,
BarChart3, BarChart3,
ThumbsUp, ThumbsUp,
ThumbsDown, ThumbsDown,
@@ -50,9 +73,13 @@ import {
Loader2, Loader2,
ScanSearch, ScanSearch,
Eye, Eye,
Plus,
X,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatDate, formatDateOnly } from '@/lib/utils' import { formatDateOnly } from '@/lib/utils'
import { getCountryName, getCountryFlag } from '@/lib/countries'
import { CountryDisplay } from '@/components/shared/country-display'
interface PageProps { interface PageProps {
params: Promise<{ id: string }> params: Promise<{ id: string }>
@@ -77,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 },
@@ -121,6 +149,52 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null) const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null)
// State for add member dialog
const [addMemberOpen, setAddMemberOpen] = useState(false)
const [addMemberForm, setAddMemberForm] = useState({
email: '',
name: '',
role: 'MEMBER' as 'LEAD' | 'MEMBER' | 'ADVISOR',
title: '',
sendInvite: true,
})
// State for remove member confirmation
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
const addTeamMember = trpc.project.addTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member added')
setAddMemberOpen(false)
setAddMemberForm({ email: '', name: '', role: 'MEMBER', title: '', sendInvite: true })
utils.project.getFullDetail.invalidate({ id: projectId })
},
onError: (err) => {
toast.error(err.message || 'Failed to add team member')
},
})
const updateTeamMemberRole = trpc.project.updateTeamMemberRole.useMutation({
onSuccess: () => {
toast.success('Role updated')
utils.project.getFullDetail.invalidate({ id: projectId })
},
onError: (err) => {
toast.error(err.message || 'Failed to update role')
},
})
const removeTeamMember = trpc.project.removeTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member removed')
setRemovingMemberId(null)
utils.project.getFullDetail.invalidate({ id: projectId })
},
onError: (err) => {
toast.error(err.message || 'Failed to remove team member')
},
})
if (isLoading) { if (isLoading) {
return <ProjectDetailSkeleton /> return <ProjectDetailSkeleton />
} }
@@ -128,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>
@@ -152,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>
@@ -166,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">
@@ -184,9 +255,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
{project.title} {project.title}
</h1> </h1>
<Badge variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}> {(() => {
{(project.status ?? 'SUBMITTED').replace('_', ' ')} const prs = (project as any).projectRoundStates ?? []
</Badge> if (!prs.length) return <Badge variant="secondary">Submitted</Badge>
if (prs.some((p: any) => p.state === 'REJECTED')) return <Badge variant="destructive">Rejected</Badge>
const latest = prs[0]
return <Badge variant={latest.state === 'PASSED' ? 'default' : 'secondary'}>{latest.round.name}</Badge>
})()}
</div> </div>
{project.teamName && ( {project.teamName && (
<p className="text-muted-foreground">{project.teamName}</p> <p className="text-muted-foreground">{project.teamName}</p>
@@ -299,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>
)} )}
@@ -430,53 +505,229 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</AnimatedCard> </AnimatedCard>
{/* Team Members Section */} {/* Team Members Section */}
{project.teamMembers && project.teamMembers.length > 0 && ( <AnimatedCard index={2}>
<AnimatedCard index={2}> <Card>
<Card> <CardHeader>
<CardHeader> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <CardTitle className="flex items-center gap-2.5 text-lg">
<CardTitle className="flex items-center gap-2.5 text-lg"> <div className="rounded-lg bg-violet-500/10 p-1.5">
<div className="rounded-lg bg-violet-500/10 p-1.5"> <Users className="h-4 w-4 text-violet-500" />
<Users className="h-4 w-4 text-violet-500" /> </div>
</div> Team Members ({project.teamMembers?.length ?? 0})
Team Members ({project.teamMembers.length}) </CardTitle>
</CardTitle> <Button variant="outline" size="sm" onClick={() => setAddMemberOpen(true)}>
</div> <Plus className="mr-2 h-4 w-4" />
</CardHeader> Add Member
<CardContent> </Button>
</div>
</CardHeader>
<CardContent>
{project.teamMembers && project.teamMembers.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null } }) => ( {project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null; nationality?: string | null; country?: string | null; institution?: string | null } }) => {
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border"> const isLastLead =
{member.role === 'LEAD' ? ( member.role === 'LEAD' &&
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted"> project.teamMembers.filter((m: { role: string }) => m.role === 'LEAD').length <= 1
<Crown className="h-5 w-5 text-yellow-500" /> const details = [
</div> member.user.nationality ? `${getCountryFlag(member.user.nationality)} ${getCountryName(member.user.nationality)}` : null,
) : ( member.user.institution,
<UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" /> member.user.country && member.user.country !== member.user.nationality ? `${getCountryFlag(member.user.country)} ${getCountryName(member.user.country)}` : null,
)} ].filter(Boolean)
<div className="flex-1 min-w-0"> return (
<div className="flex items-center gap-2"> <div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
<p className="font-medium text-sm truncate"> {member.role === 'LEAD' ? (
{member.user.name || 'Unnamed'} <div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
</p> <Crown className="h-5 w-5 text-yellow-500" />
<Badge variant="outline" className="text-xs"> </div>
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'} ) : (
</Badge> <UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" />
</div>
<p className="text-xs text-muted-foreground truncate">
{member.user.email}
</p>
{member.title && (
<p className="text-xs text-muted-foreground">{member.title}</p>
)} )}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Link href={`/admin/members/${member.user.id}`} className="font-medium text-sm truncate hover:underline text-primary">
{member.user.name || 'Unnamed'}
</Link>
<Select
value={member.role}
onValueChange={(value) =>
updateTeamMemberRole.mutate({
projectId: project.id,
userId: member.user.id,
role: value as 'LEAD' | 'MEMBER' | 'ADVISOR',
})
}
>
<SelectTrigger className="h-6 w-auto text-xs px-2 py-0 border-dashed gap-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LEAD">Lead</SelectItem>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground truncate">
{member.user.email}
</p>
{member.title && (
<p className="text-xs text-muted-foreground">{member.title}</p>
)}
{details.length > 0 && (
<p className="text-xs text-muted-foreground truncate">
{details.join(' · ')}
</p>
)}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
disabled={isLastLead}
onClick={() => setRemovingMemberId(member.user.id)}
>
<X className="h-4 w-4" />
</Button>
</span>
</TooltipTrigger>
{isLastLead && (
<TooltipContent>
Cannot remove the last team lead
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div> </div>
</div> )
))} })}
</div> </div>
</CardContent> ) : (
</Card> <p className="text-sm text-muted-foreground">No team members yet.</p>
</AnimatedCard> )}
)} </CardContent>
</Card>
</AnimatedCard>
{/* Add Member Dialog */}
<Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Team Member</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="member-email">Email</Label>
<Input
id="member-email"
type="email"
placeholder="member@example.com"
value={addMemberForm.email}
onChange={(e) => setAddMemberForm((f) => ({ ...f, email: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="member-name">Name</Label>
<Input
id="member-name"
placeholder="Full name"
value={addMemberForm.name}
onChange={(e) => setAddMemberForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="member-role">Role</Label>
<Select
value={addMemberForm.role}
onValueChange={(v) => setAddMemberForm((f) => ({ ...f, role: v as 'LEAD' | 'MEMBER' | 'ADVISOR' }))}
>
<SelectTrigger id="member-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LEAD">Lead</SelectItem>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="member-title">Title (optional)</Label>
<Input
id="member-title"
placeholder="e.g. CEO, Co-founder"
value={addMemberForm.title}
onChange={(e) => setAddMemberForm((f) => ({ ...f, title: e.target.value }))}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="member-invite"
checked={addMemberForm.sendInvite}
onCheckedChange={(checked) =>
setAddMemberForm((f) => ({ ...f, sendInvite: checked === true }))
}
/>
<Label htmlFor="member-invite" className="font-normal cursor-pointer">
Send invite email
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddMemberOpen(false)}>
Cancel
</Button>
<Button
onClick={() =>
addTeamMember.mutate({
projectId,
email: addMemberForm.email,
name: addMemberForm.name,
role: addMemberForm.role,
title: addMemberForm.title || undefined,
sendInvite: addMemberForm.sendInvite,
})
}
disabled={addTeamMember.isPending || !addMemberForm.email || !addMemberForm.name}
>
{addTeamMember.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Add Member
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Remove Member Confirmation Dialog */}
<Dialog open={!!removingMemberId} onOpenChange={(open) => { if (!open) setRemovingMemberId(null) }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Remove Team Member</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Are you sure you want to remove this team member? This action cannot be undone.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setRemovingMemberId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (removingMemberId) {
removeTeamMember.mutate({ projectId, userId: removingMemberId })
}
}}
disabled={removeTeamMember.isPending}
>
{removeTeamMember.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Mentor Assignment Section */} {/* Mentor Assignment Section */}
{project.wantsMentorship && ( {project.wantsMentorship && (
@@ -564,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,
@@ -72,6 +73,7 @@ import {
ArrowRightCircle, ArrowRightCircle,
LayoutGrid, LayoutGrid,
LayoutList, LayoutList,
Bell,
} from 'lucide-react' } from 'lucide-react'
import { import {
Select, Select,
@@ -90,8 +92,10 @@ import {
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { truncate } from '@/lib/utils' import { truncate } from '@/lib/utils'
import { ProjectLogo } from '@/components/shared/project-logo' import { ProjectLogo } from '@/components/shared/project-logo'
import { StatusBadge } from '@/components/shared/status-badge' import { BulkNotificationDialog } from '@/components/admin/projects/bulk-notification-dialog'
import { Pagination } from '@/components/shared/pagination' import { Pagination } from '@/components/shared/pagination'
import { SortableHeader } from '@/components/shared/sortable-header'
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries' import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
import { CountryFlagImg } from '@/components/ui/country-select' import { CountryFlagImg } from '@/components/ui/country-select'
import { import {
@@ -113,6 +117,25 @@ const statusColors: Record<
WINNER: 'success', WINNER: 'success',
REJECTED: 'destructive', REJECTED: 'destructive',
WITHDRAWN: 'secondary', WITHDRAWN: 'secondary',
// Round-state-based statuses
PENDING: 'secondary',
IN_PROGRESS: 'default',
COMPLETED: 'default',
PASSED: 'success',
}
type ProjectRoundStateInfo = {
state: string
round: { name: string; sortOrder: number }
}
function deriveProjectStatus(prs: ProjectRoundStateInfo[]): { label: string; variant: 'default' | 'success' | 'secondary' | 'destructive' | 'warning' } {
if (!prs.length) return { label: 'Submitted', variant: 'secondary' }
if (prs.some((p) => p.state === 'REJECTED')) return { label: 'Rejected', variant: 'destructive' }
// prs is already sorted by sortOrder desc — first item is the latest round
const latest = prs[0]
if (latest.state === 'PASSED') return { label: latest.round.name, variant: 'success' }
return { label: latest.round.name, variant: 'default' }
} }
function parseFiltersFromParams( function parseFiltersFromParams(
@@ -123,6 +146,9 @@ function parseFiltersFromParams(
statuses: searchParams.get('status') statuses: searchParams.get('status')
? searchParams.get('status')!.split(',') ? searchParams.get('status')!.split(',')
: [], : [],
roundStates: searchParams.get('roundState')
? searchParams.get('roundState')!.split(',')
: [],
roundId: searchParams.get('round') || '', roundId: searchParams.get('round') || '',
competitionCategory: searchParams.get('category') || '', competitionCategory: searchParams.get('category') || '',
oceanIssue: searchParams.get('issue') || '', oceanIssue: searchParams.get('issue') || '',
@@ -157,6 +183,8 @@ function filtersToParams(
if (filters.search) params.set('q', filters.search) if (filters.search) params.set('q', filters.search)
if (filters.statuses.length > 0) if (filters.statuses.length > 0)
params.set('status', filters.statuses.join(',')) params.set('status', filters.statuses.join(','))
if (filters.roundStates.length > 0)
params.set('roundState', filters.roundStates.join(','))
if (filters.roundId) params.set('round', filters.roundId) if (filters.roundId) params.set('round', filters.roundId)
if (filters.competitionCategory) if (filters.competitionCategory)
params.set('category', filters.competitionCategory) params.set('category', filters.competitionCategory)
@@ -182,6 +210,7 @@ export default function ProjectsPage() {
const [filters, setFilters] = useState<ProjectFilters>({ const [filters, setFilters] = useState<ProjectFilters>({
search: parsed.search, search: parsed.search,
statuses: parsed.statuses, statuses: parsed.statuses,
roundStates: parsed.roundStates,
roundId: parsed.roundId, roundId: parsed.roundId,
competitionCategory: parsed.competitionCategory, competitionCategory: parsed.competitionCategory,
oceanIssue: parsed.oceanIssue, oceanIssue: parsed.oceanIssue,
@@ -194,6 +223,8 @@ export default function ProjectsPage() {
const [perPage, setPerPage] = useState(parsed.perPage || 20) const [perPage, setPerPage] = useState(parsed.perPage || 20)
const [searchInput, setSearchInput] = useState(parsed.search) const [searchInput, setSearchInput] = useState(parsed.search)
const [viewMode, setViewMode] = useState<'table' | 'card'>('table') const [viewMode, setViewMode] = useState<'table' | 'card'>('table')
const [sortBy, setSortBy] = useState<string | undefined>(undefined)
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
// Fetch display settings // Fetch display settings
const { data: displaySettings } = trpc.settings.getMultiple.useQuery({ const { data: displaySettings } = trpc.settings.getMultiple.useQuery({
@@ -239,6 +270,16 @@ export default function ProjectsPage() {
setPage(1) setPage(1)
} }
const handleSort = (column: string) => {
if (sortBy === column) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortBy(column)
setSortDir('asc')
}
setPage(1)
}
// Build tRPC query input // Build tRPC query input
const queryInput = { const queryInput = {
search: filters.search || undefined, search: filters.search || undefined,
@@ -275,8 +316,16 @@ export default function ProjectsPage() {
wantsMentorship: filters.wantsMentorship, wantsMentorship: filters.wantsMentorship,
hasFiles: filters.hasFiles, hasFiles: filters.hasFiles,
hasAssignments: filters.hasAssignments, hasAssignments: filters.hasAssignments,
roundStates:
filters.roundStates.length > 0
? (filters.roundStates as Array<
'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'PASSED' | 'REJECTED' | 'WITHDRAWN'
>)
: undefined,
page, page,
perPage, perPage,
sortBy: sortBy as 'title' | 'category' | 'program' | 'assignments' | 'status' | 'createdAt' | undefined,
sortDir: sortBy ? sortDir : undefined,
} }
const utils = trpc.useUtils() const utils = trpc.useUtils()
@@ -290,6 +339,7 @@ export default function ProjectsPage() {
const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null) const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null)
const [assignRoundId, setAssignRoundId] = useState('') const [assignRoundId, setAssignRoundId] = useState('')
const [bulkNotifyOpen, setBulkNotifyOpen] = useState(false)
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false) const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round') const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('') const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
@@ -619,6 +669,13 @@ export default function ProjectsPage() {
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => setBulkNotifyOpen(true)}
>
<Bell className="mr-2 h-4 w-4" />
Send Notifications
</Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => setAiTagDialogOpen(true)} onClick={() => setAiTagDialogOpen(true)}
@@ -708,23 +765,51 @@ export default function ProjectsPage() {
/> />
{/* Stats Summary + View Toggle */} {/* Stats Summary + View Toggle */}
{data && data.projects.length > 0 && ( {data && (Object.keys(data.statusCounts ?? {}).length > 0 || data.projects.length > 0) && (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
{Object.entries(data.statusCounts ?? {}) {Object.entries(data.statusCounts ?? {})
.sort(([a], [b]) => { .sort(([a], [b]) => {
const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN'] const order = ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN']
return order.indexOf(a) - order.indexOf(b) return order.indexOf(a) - order.indexOf(b)
}) })
.map(([status, count]) => ( .map(([status, count]) => {
<Badge const isActive = filters.roundStates.includes(status)
key={status} return (
variant={statusColors[status] || 'secondary'} <button
className="text-xs font-normal" key={status}
> type="button"
{count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')} onClick={() => {
</Badge> const next = isActive
))} ? filters.roundStates.filter((s) => s !== status)
: [...filters.roundStates, status]
handleFiltersChange({ ...filters, roundStates: next })
}}
className="inline-flex items-center"
>
<Badge
variant={statusColors[status] || 'secondary'}
className={cn(
'text-xs font-normal cursor-pointer transition-all',
isActive && 'ring-2 ring-offset-1 ring-primary',
!isActive && filters.roundStates.length > 0 && 'opacity-50'
)}
>
{count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')}
</Badge>
</button>
)
})}
{filters.roundStates.length > 0 && (
<button
type="button"
onClick={() => handleFiltersChange({ ...filters, roundStates: [] })}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors ml-1"
>
<X className="h-3 w-3" />
Clear
</button>
)}
{data.total > data.projects.length && ( {data.total > data.projects.length && (
<span className="text-xs text-muted-foreground ml-1"> <span className="text-xs text-muted-foreground ml-1">
(page {data.page} of {data.totalPages}) (page {data.page} of {data.totalPages})
@@ -847,6 +932,17 @@ export default function ProjectsPage() {
</Card> </Card>
) : data ? ( ) : data ? (
<> <>
{/* Top Pagination */}
{data.totalPages > 1 && (
<Pagination
page={data.page}
totalPages={data.totalPages}
total={data.total}
perPage={perPage}
onPageChange={setPage}
/>
)}
{/* Table View */} {/* Table View */}
{viewMode === 'table' ? ( {viewMode === 'table' ? (
<> <>
@@ -862,18 +958,18 @@ export default function ProjectsPage() {
aria-label="Select all projects" aria-label="Select all projects"
/> />
</TableHead> </TableHead>
<TableHead className="min-w-[280px]">Project</TableHead> <SortableHeader label="Project" column="title" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} className="min-w-[280px]" />
<TableHead>Category</TableHead> <SortableHeader label="Category" column="category" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<TableHead>Program</TableHead> <SortableHeader label="Program" column="program" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<TableHead>Tags</TableHead> <TableHead>Tags</TableHead>
<TableHead>Assignments</TableHead> <SortableHeader label="Assignments" column="assignments" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<TableHead>Status</TableHead> <SortableHeader label="Status" column="status" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.projects.map((project) => { {data.projects.map((project) => {
const isEliminated = project.status === 'REJECTED' const isEliminated = (project.projectRoundStates ?? []).some((p: ProjectRoundStateInfo) => p.state === 'REJECTED')
return ( return (
<TableRow <TableRow
key={project.id} key={project.id}
@@ -894,6 +990,7 @@ export default function ProjectsPage() {
> >
<ProjectLogo <ProjectLogo
project={project} project={project}
logoUrl={project.logoUrl}
size="sm" size="sm"
fallback="initials" fallback="initials"
/> />
@@ -972,7 +1069,10 @@ export default function ProjectsPage() {
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<StatusBadge status={project.status ?? 'SUBMITTED'} /> {(() => {
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
return <Badge variant={derived.variant}>{derived.label}</Badge>
})()}
</TableCell> </TableCell>
<TableCell className="relative z-10 text-right"> <TableCell className="relative z-10 text-right">
<DropdownMenu> <DropdownMenu>
@@ -1042,13 +1142,16 @@ export default function ProjectsPage() {
<Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md"> <Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start gap-3 pl-8"> <div className="flex items-start gap-3 pl-8">
<ProjectLogo project={project} size="md" fallback="initials" /> <ProjectLogo project={project} logoUrl={project.logoUrl} size="md" fallback="initials" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}> <CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
{project.title} {project.title}
</CardTitle> </CardTitle>
<StatusBadge status={project.status ?? 'SUBMITTED'} className="shrink-0" /> {(() => {
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
return <Badge variant={derived.variant} className="shrink-0">{derived.label}</Badge>
})()}
</div> </div>
<CardDescription>{project.teamName}</CardDescription> <CardDescription>{project.teamName}</CardDescription>
</div> </div>
@@ -1096,7 +1199,7 @@ export default function ProjectsPage() {
/* Card View */ /* Card View */
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{data.projects.map((project) => { {data.projects.map((project) => {
const isEliminated = project.status === 'REJECTED' const isEliminated = (project.projectRoundStates ?? []).some((p: ProjectRoundStateInfo) => p.state === 'REJECTED')
return ( return (
<div key={project.id} className="relative"> <div key={project.id} className="relative">
<div className="absolute left-3 top-3 z-10"> <div className="absolute left-3 top-3 z-10">
@@ -1110,7 +1213,7 @@ export default function ProjectsPage() {
<Card className={`transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md h-full ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}> <Card className={`transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md h-full ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start gap-3 pl-7"> <div className="flex items-start gap-3 pl-7">
<ProjectLogo project={project} size="lg" fallback="initials" /> <ProjectLogo project={project} logoUrl={project.logoUrl} size="lg" fallback="initials" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}> <CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
@@ -1177,7 +1280,10 @@ export default function ProjectsPage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-3 pt-0"> <CardContent className="space-y-3 pt-0">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<StatusBadge status={project.status ?? 'SUBMITTED'} /> {(() => {
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
return <Badge variant={derived.variant}>{derived.label}</Badge>
})()}
{project.competitionCategory && ( {project.competitionCategory && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
@@ -1846,6 +1952,8 @@ export default function ProjectsPage() {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<BulkNotificationDialog open={bulkNotifyOpen} onOpenChange={setBulkNotifyOpen} />
</div> </div>
) )
} }

View File

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

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState, useMemo, useCallback, useRef, useEffect } from 'react' import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
import { useParams, useSearchParams } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
@@ -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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import { useState, useEffect } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
@@ -13,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">
@@ -111,24 +149,42 @@ export default function ApplicantDashboardPage() {
) )
} }
const { project, timeline, currentStatus, openRounds, hasPassedIntake } = data const { project, timeline, currentStatus, openRounds, hasPassedIntake, isRejected } = data
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">
@@ -221,51 +328,20 @@ export default function ApplicantDashboardPage() {
</Card> </Card>
</AnimatedCard> </AnimatedCard>
{/* Quick actions */} {/* Rejected banner */}
<AnimatedCard index={1}> {isRejected && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <AnimatedCard index={1}>
<Link href={"/applicant/documents" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5"> <Card className="border-destructive/50 bg-destructive/5">
<div className="rounded-xl bg-blue-500/10 p-2.5 transition-colors group-hover:bg-blue-500/20"> <CardContent className="flex items-center gap-3 py-4">
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" /> <AlertCircle className="h-5 w-5 text-destructive shrink-0" />
</div> <p className="text-sm text-destructive">
<div className="flex-1 min-w-0"> Your project was not selected to advance. Your project space is now read-only.
<p className="text-sm font-medium">Documents</p>
<p className="text-xs text-muted-foreground">
{openRounds.length > 0 ? `${openRounds.length} round(s) open` : 'View uploads'}
</p> </p>
</div> </CardContent>
<ArrowRight className="h-4 w-4 text-muted-foreground" /> </Card>
</Link> </AnimatedCard>
)}
<Link href={"/applicant/team" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-purple-500/30 hover:bg-purple-500/5">
<div className="rounded-xl bg-purple-500/10 p-2.5 transition-colors group-hover:bg-purple-500/20">
<Users className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Team</p>
<p className="text-xs text-muted-foreground">
{project.teamMembers.length} member(s)
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
{project.mentorAssignment && (
<Link href={"/applicant/mentor" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-green-500/30 hover:bg-green-500/5">
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Mentor</p>
<p className="text-xs text-muted-foreground">
{project.mentorAssignment.mentor?.name || 'Assigned'}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
)}
</div>
</AnimatedCard>
{/* Document Completeness */} {/* Document Completeness */}
{docCompleteness && docCompleteness.length > 0 && ( {docCompleteness && docCompleteness.length > 0 && (
@@ -302,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>
@@ -314,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
@@ -332,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>
@@ -342,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>
@@ -368,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>
)) ))
) : ( ) : (
@@ -458,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'),
@@ -123,6 +125,7 @@ export default function ApplicantProjectPage() {
const project = dashboardData?.project const project = dashboardData?.project
const projectId = project?.id const projectId = project?.id
const isIntakeOpen = dashboardData?.isIntakeOpen ?? false const isIntakeOpen = dashboardData?.isIntakeOpen ?? false
const isRejected = dashboardData?.isRejected ?? false
const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery( const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
{ projectId: projectId! }, { projectId: projectId! },
@@ -242,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}
@@ -313,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>
)} )}
@@ -364,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">
@@ -398,7 +418,7 @@ export default function ApplicantProjectPage() {
Everyone on this list can view and collaborate on this project. Everyone on this list can view and collaborate on this project.
</CardDescription> </CardDescription>
</div> </div>
{isTeamLead && ( {isTeamLead && !isRejected && (
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}> <Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm"> <Button size="sm">
@@ -578,7 +598,7 @@ export default function ApplicantProjectPage() {
</div> </div>
</div> </div>
{isTeamLead && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && ( {isTeamLead && !isRejected && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-destructive"> <Button variant="ghost" size="icon" className="text-destructive">

View File

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

View File

@@ -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

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

View File

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

View File

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

View File

@@ -36,17 +36,9 @@ export default function SetPasswordPage() {
setIsSuccess(true) setIsSuccess(true)
// Update the session to reflect the password has been set // Update the session to reflect the password has been set
await updateSession() await updateSession()
// Redirect after a short delay // Redirect after a short delay — all roles go to onboarding first
setTimeout(() => { setTimeout(() => {
if (session?.user?.role === 'JURY_MEMBER') { router.push('/onboarding')
router.push('/jury')
} else if (session?.user?.role === 'SUPER_ADMIN' || session?.user?.role === 'PROGRAM_ADMIN') {
router.push('/admin')
} else if (session?.user?.role === 'APPLICANT') {
router.push('/onboarding')
} else {
router.push('/')
}
}, 2000) }, 2000)
}, },
onError: (err) => { onError: (err) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,16 @@ function makeQueryClient() {
queries: { queries: {
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: (failureCount, error) => {
// Retry up to 3 times on server errors (503 cold-start, etc.)
if (failureCount >= 3) return false
const msg = (error as Error)?.message ?? ''
// Retry on JSON parse errors (HTML 503 from nginx) and server errors
if (msg.includes('is not valid JSON') || msg.includes('Unexpected token')) return true
if (msg.includes('500') || msg.includes('502') || msg.includes('503')) return true
return failureCount < 2
},
retryDelay: (attemptIndex) => Math.min(2000 * (attemptIndex + 1), 8000),
}, },
}, },
}) })
@@ -47,6 +57,21 @@ export function Providers({ children }: { children: React.ReactNode }) {
httpBatchLink({ httpBatchLink({
url: `${getBaseUrl()}/api/trpc`, url: `${getBaseUrl()}/api/trpc`,
transformer: superjson, transformer: superjson,
async fetch(url, options) {
const res = await globalThis.fetch(url, options)
// Detect nginx 503 / HTML error pages before tRPC tries to JSON.parse
if (!res.ok) {
const ct = res.headers.get('content-type') ?? ''
if (ct.includes('text/html') || !ct.includes('json')) {
throw new Error(
res.status >= 500
? 'Server is starting up — please wait a moment and try again.'
: `Server error (${res.status})`
)
}
}
return res
},
}), }),
], ],
}) })

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>
@@ -432,6 +480,7 @@ export function MembersContent() {
userEmail={user.email} userEmail={user.email}
userStatus={user.status} userStatus={user.status}
userRole={user.role as RoleValue} userRole={user.role as RoleValue}
userRoles={(user as unknown as { roles?: RoleValue[] }).roles}
currentUserRole={currentUserRole} currentUserRole={currentUserRole}
/> />
</TableCell> </TableCell>
@@ -459,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'}>
@@ -493,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>
@@ -524,6 +596,7 @@ export function MembersContent() {
userEmail={user.email} userEmail={user.email}
userStatus={user.status} userStatus={user.status}
userRole={user.role as RoleValue} userRole={user.role as RoleValue}
userRoles={(user as unknown as { roles?: RoleValue[] }).roles}
currentUserRole={currentUserRole} currentUserRole={currentUserRole}
/> />
</CardContent> </CardContent>
@@ -679,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>
@@ -693,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>
@@ -707,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 ? (
@@ -719,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

@@ -0,0 +1,498 @@
'use client'
import { useState, useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
ChevronDown,
ChevronRight,
Send,
Loader2,
CheckCircle2,
XCircle,
Trophy,
Ban,
Award,
Eye,
X,
} from 'lucide-react'
interface BulkNotificationDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function BulkNotificationDialog({ open, onOpenChange }: BulkNotificationDialogProps) {
// Section states
const [passedOpen, setPassedOpen] = useState(true)
const [rejectedOpen, setRejectedOpen] = useState(false)
const [awardOpen, setAwardOpen] = useState(false)
// Passed section
const [passedEnabled, setPassedEnabled] = useState(true)
const [passedMessage, setPassedMessage] = useState('')
const [passedFullCustom, setPassedFullCustom] = useState(false)
const [selectedRoundIds, setSelectedRoundIds] = useState<Set<string>>(new Set())
// Preview
const [previewOpen, setPreviewOpen] = useState(false)
const [previewRoundId, setPreviewRoundId] = useState<string | null>(null)
// Rejected section
const [rejectedEnabled, setRejectedEnabled] = useState(false)
const [rejectedMessage, setRejectedMessage] = useState('')
const [rejectedFullCustom, setRejectedFullCustom] = useState(false)
const [rejectedIncludeInvite, setRejectedIncludeInvite] = useState(false)
// Award section
const [selectedAwardId, setSelectedAwardId] = useState<string | null>(null)
const [awardMessage, setAwardMessage] = useState('')
// Global
const [skipAlreadySent, setSkipAlreadySent] = useState(true)
// Loading states
const [sendingPassed, setSendingPassed] = useState(false)
const [sendingRejected, setSendingRejected] = useState(false)
const [sendingAward, setSendingAward] = useState(false)
const [sendingAll, setSendingAll] = useState(false)
const summary = trpc.project.getBulkNotificationSummary.useQuery(undefined, {
enabled: open,
})
const preview = trpc.project.previewAdvancementEmail.useQuery(
{
roundId: previewRoundId!,
customMessage: passedMessage || undefined,
fullCustomBody: passedFullCustom,
},
{ enabled: previewOpen && !!previewRoundId }
)
const sendPassed = trpc.project.sendBulkPassedNotifications.useMutation()
const sendRejected = trpc.project.sendBulkRejectionNotifications.useMutation()
const sendAward = trpc.project.sendBulkAwardNotifications.useMutation()
const toggleRound = useCallback((roundId: string) => {
setSelectedRoundIds((prev) => {
const next = new Set(prev)
if (next.has(roundId)) {
next.delete(roundId)
} else {
next.add(roundId)
}
return next
})
}, [])
const selectedPassedCount = summary.data?.passed
.filter((g) => selectedRoundIds.has(g.roundId))
.reduce((sum, g) => sum + g.projectCount, 0) ?? 0
const totalPassed = summary.data?.passed.reduce((sum, g) => sum + g.projectCount, 0) ?? 0
const handleSendPassed = async () => {
if (selectedRoundIds.size === 0) {
toast.error('Select at least one round to notify')
return
}
setSendingPassed(true)
try {
const result = await sendPassed.mutateAsync({
customMessage: passedMessage || undefined,
fullCustomBody: passedFullCustom,
skipAlreadySent,
roundIds: Array.from(selectedRoundIds),
})
toast.success(`Advancement: ${result.sent} sent, ${result.failed} failed, ${result.skipped} skipped`)
summary.refetch()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to send')
} finally {
setSendingPassed(false)
}
}
const handleSendRejected = async () => {
setSendingRejected(true)
try {
const result = await sendRejected.mutateAsync({
customMessage: rejectedMessage || undefined,
fullCustomBody: rejectedFullCustom,
includeInviteLink: rejectedIncludeInvite,
skipAlreadySent,
})
toast.success(`Rejection: ${result.sent} sent, ${result.failed} failed, ${result.skipped} skipped`)
summary.refetch()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to send')
} finally {
setSendingRejected(false)
}
}
const handleSendAward = async (awardId: string) => {
setSendingAward(true)
try {
const result = await sendAward.mutateAsync({
awardId,
customMessage: awardMessage || undefined,
skipAlreadySent,
})
toast.success(`Award: ${result.sent} sent, ${result.failed} failed`)
summary.refetch()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to send')
} finally {
setSendingAward(false)
}
}
const handleSendAll = async () => {
setSendingAll(true)
try {
if (passedEnabled && selectedPassedCount > 0) {
await handleSendPassed()
}
if (rejectedEnabled && (summary.data?.rejected.count ?? 0) > 0) {
await handleSendRejected()
}
toast.success('All enabled notifications sent')
} catch {
// Individual handlers already toast errors
} finally {
setSendingAll(false)
}
}
const handleOpenPreview = () => {
// Use first selected round for preview context
const firstRoundId = Array.from(selectedRoundIds)[0]
if (!firstRoundId) {
toast.error('Select at least one round to preview')
return
}
setPreviewRoundId(firstRoundId)
setPreviewOpen(true)
}
const isSending = sendingPassed || sendingRejected || sendingAward || sendingAll
// Find round name for preview
const previewRoundName = summary.data?.passed.find((g) => g.roundId === previewRoundId)?.roundName
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bulk Notifications</DialogTitle>
<DialogDescription>
Send advancement, rejection, and award pool notifications to project teams.
</DialogDescription>
</DialogHeader>
{summary.isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : summary.error ? (
<div className="text-destructive text-sm py-4">
Failed to load summary: {summary.error.message}
</div>
) : (
<div className="space-y-4">
{/* Global settings */}
<div className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
<div className="flex items-center gap-2">
<Switch
id="skip-already-sent"
checked={skipAlreadySent}
onCheckedChange={setSkipAlreadySent}
/>
<Label htmlFor="skip-already-sent" className="text-sm">
Skip already notified
</Label>
</div>
<div className="text-xs text-muted-foreground">
{summary.data?.alreadyNotified.advancement ?? 0} advancement + {summary.data?.alreadyNotified.rejection ?? 0} rejection already sent
</div>
</div>
{/* PASSED section */}
<Collapsible open={passedOpen} onOpenChange={setPassedOpen}>
<div className="rounded-lg border">
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">
{passedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<Trophy className="h-4 w-4 text-green-600" />
<span className="font-medium">Passed / Advanced</span>
<Badge variant="secondary">
{selectedRoundIds.size > 0
? `${selectedPassedCount} of ${totalPassed} selected`
: `${totalPassed} projects`}
</Badge>
</div>
<Switch
checked={passedEnabled}
onCheckedChange={setPassedEnabled}
onClick={(e) => e.stopPropagation()}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t px-4 pb-4 pt-3 space-y-3">
{summary.data?.passed.map((g) => (
<label
key={g.roundId}
className="text-sm flex items-center gap-2 cursor-pointer hover:bg-muted/30 rounded px-1 py-0.5 -mx-1"
>
<Checkbox
checked={selectedRoundIds.has(g.roundId)}
onCheckedChange={() => toggleRound(g.roundId)}
/>
<span className="text-muted-foreground">{g.roundName}</span>
<span className="font-medium">{g.projectCount}</span>
<span className="text-xs text-muted-foreground">&rarr; {g.nextRoundName}</span>
</label>
))}
{(summary.data?.passed.length ?? 0) > 0 && selectedRoundIds.size === 0 && (
<p className="text-xs text-amber-600">Select rounds above to enable sending.</p>
)}
<div className="space-y-2 pt-2">
<Label className="text-xs">Custom message (optional)</Label>
<Textarea
value={passedMessage}
onChange={(e) => setPassedMessage(e.target.value)}
placeholder="Add a personal note to the advancement email..."
rows={2}
className="text-sm"
/>
<div className="flex items-center gap-2">
<Switch
id="passed-full-custom"
checked={passedFullCustom}
onCheckedChange={setPassedFullCustom}
/>
<Label htmlFor="passed-full-custom" className="text-xs">
Full custom body (replace default template)
</Label>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={handleOpenPreview}
disabled={selectedRoundIds.size === 0 || isSending}
>
<Eye className="mr-2 h-3.5 w-3.5" />
Preview Email
</Button>
<Button
size="sm"
onClick={handleSendPassed}
disabled={!passedEnabled || selectedRoundIds.size === 0 || isSending}
>
{sendingPassed ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Send className="mr-2 h-3.5 w-3.5" />}
Send Advancement ({selectedPassedCount})
</Button>
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
{/* REJECTED section */}
<Collapsible open={rejectedOpen} onOpenChange={setRejectedOpen}>
<div className="rounded-lg border">
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">
{rejectedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<Ban className="h-4 w-4 text-red-600" />
<span className="font-medium">Rejected / Filtered Out</span>
<Badge variant="destructive">{summary.data?.rejected.count ?? 0} projects</Badge>
</div>
<Switch
checked={rejectedEnabled}
onCheckedChange={setRejectedEnabled}
onClick={(e) => e.stopPropagation()}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t px-4 pb-4 pt-3 space-y-3">
<div className="space-y-2">
<Label className="text-xs">Custom message (optional)</Label>
<Textarea
value={rejectedMessage}
onChange={(e) => setRejectedMessage(e.target.value)}
placeholder="Add a personal note to the rejection email..."
rows={2}
className="text-sm"
/>
<div className="flex items-center gap-2">
<Switch
id="rejected-full-custom"
checked={rejectedFullCustom}
onCheckedChange={setRejectedFullCustom}
/>
<Label htmlFor="rejected-full-custom" className="text-xs">
Full custom body (replace default template)
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="rejected-include-invite"
checked={rejectedIncludeInvite}
onCheckedChange={setRejectedIncludeInvite}
/>
<Label htmlFor="rejected-include-invite" className="text-xs">
Include platform invite link for rejected teams
</Label>
</div>
</div>
<Button
size="sm"
variant="destructive"
onClick={handleSendRejected}
disabled={!rejectedEnabled || (summary.data?.rejected.count ?? 0) === 0 || isSending}
>
{sendingRejected ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Send className="mr-2 h-3.5 w-3.5" />}
Send Rejections
</Button>
</div>
</CollapsibleContent>
</div>
</Collapsible>
{/* AWARD POOLS section */}
<Collapsible open={awardOpen} onOpenChange={setAwardOpen}>
<div className="rounded-lg border">
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">
{awardOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<Award className="h-4 w-4 text-amber-600" />
<span className="font-medium">Award Pools</span>
<Badge variant="outline">{summary.data?.awardPools.length ?? 0} awards</Badge>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t px-4 pb-4 pt-3 space-y-3">
{(summary.data?.awardPools ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">No award pools configured.</p>
) : (
<>
{summary.data?.awardPools.map((a) => (
<div key={a.awardId} className="flex items-center justify-between rounded border p-3">
<div className="flex items-center gap-2">
<Award className="h-3.5 w-3.5 text-amber-500" />
<span className="text-sm font-medium">{a.awardName}</span>
<Badge variant="secondary" className="text-xs">{a.eligibleCount} eligible</Badge>
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
setSelectedAwardId(a.awardId)
handleSendAward(a.awardId)
}}
disabled={a.eligibleCount === 0 || isSending}
>
{sendingAward && selectedAwardId === a.awardId ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
)}
Notify
</Button>
</div>
))}
<div className="space-y-2 pt-1">
<Label className="text-xs">Custom message for awards (optional)</Label>
<Textarea
value={awardMessage}
onChange={(e) => setAwardMessage(e.target.value)}
placeholder="Add a note to the award notification..."
rows={2}
className="text-sm"
/>
</div>
</>
)}
</div>
</CollapsibleContent>
</div>
</Collapsible>
{/* Send All button */}
<div className="flex justify-end pt-2 border-t">
<Button
onClick={handleSendAll}
disabled={(!passedEnabled && !rejectedEnabled) || isSending}
>
{sendingAll ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
Send All Enabled
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Email Preview Dialog */}
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Email Preview</DialogTitle>
<DialogDescription>
Preview for: {previewRoundName ?? 'Selected round'}
</DialogDescription>
</DialogHeader>
{preview.isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : preview.error ? (
<div className="text-destructive text-sm py-4">
Failed to load preview: {preview.error.message}
</div>
) : preview.data ? (
<div className="space-y-3">
<div className="text-sm">
<span className="font-medium">Subject:</span>{' '}
<span className="text-muted-foreground">{preview.data.subject}</span>
</div>
<div className="rounded border bg-white">
<iframe
srcDoc={preview.data.html}
className="w-full border-0 rounded"
style={{ minHeight: 500 }}
title="Email preview"
sandbox="allow-same-origin"
/>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
</>
)
}

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

@@ -4,6 +4,8 @@ import { useState } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Trophy } from 'lucide-react' import { Trophy } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { EmailPreviewDialog } from './email-preview-dialog' import { EmailPreviewDialog } from './email-preview-dialog'
interface NotifyAdvancedButtonProps { interface NotifyAdvancedButtonProps {
@@ -14,9 +16,10 @@ interface NotifyAdvancedButtonProps {
export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedButtonProps) { export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedButtonProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [customMessage, setCustomMessage] = useState<string | undefined>() const [customMessage, setCustomMessage] = useState<string | undefined>()
const [fullCustomBody, setFullCustomBody] = useState(false)
const preview = trpc.round.previewAdvancementEmail.useQuery( const preview = trpc.round.previewAdvancementEmail.useQuery(
{ roundId, targetRoundId, customMessage }, { roundId, targetRoundId, customMessage, fullCustomBody },
{ enabled: open } { enabled: open }
) )
@@ -32,18 +35,31 @@ export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedB
return ( return (
<> <>
<button <div className="space-y-2">
onClick={() => setOpen(true)} <button
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left" onClick={() => setOpen(true)}
> className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full"
<Trophy className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" /> >
<div> <Trophy className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
<p className="text-sm font-medium">Notify Advanced Teams</p> <div>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-sm font-medium">Notify Advanced Teams</p>
Send advancement emails to passed projects <p className="text-xs text-muted-foreground mt-0.5">
</p> Send advancement emails to passed projects
</p>
</div>
</button>
<div className="flex items-center gap-2 px-1">
<Switch
id="advancement-full-custom-body"
checked={fullCustomBody}
onCheckedChange={setFullCustomBody}
/>
<Label htmlFor="advancement-full-custom-body" className="text-xs cursor-pointer">
<span className="font-medium">Full custom body</span>
<span className="text-muted-foreground ml-1"> only your message is sent (no standard text)</span>
</Label>
</div> </div>
</button> </div>
<EmailPreviewDialog <EmailPreviewDialog
open={open} open={open}
@@ -53,7 +69,7 @@ export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedB
recipientCount={preview.data?.recipientCount ?? 0} recipientCount={preview.data?.recipientCount ?? 0}
previewHtml={preview.data?.html} previewHtml={preview.data?.html}
isPreviewLoading={preview.isLoading} isPreviewLoading={preview.isLoading}
onSend={(msg) => sendMutation.mutate({ roundId, targetRoundId, customMessage: msg })} onSend={(msg) => sendMutation.mutate({ roundId, targetRoundId, customMessage: msg, fullCustomBody })}
isSending={sendMutation.isPending} isSending={sendMutation.isPending}
onRefreshPreview={(msg) => setCustomMessage(msg)} onRefreshPreview={(msg) => setCustomMessage(msg)}
/> />

View File

@@ -4,6 +4,8 @@ import { useState } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { XCircle } from 'lucide-react' import { XCircle } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { EmailPreviewDialog } from './email-preview-dialog' import { EmailPreviewDialog } from './email-preview-dialog'
interface NotifyRejectedButtonProps { interface NotifyRejectedButtonProps {
@@ -13,9 +15,10 @@ interface NotifyRejectedButtonProps {
export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) { export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [customMessage, setCustomMessage] = useState<string | undefined>() const [customMessage, setCustomMessage] = useState<string | undefined>()
const [fullCustomBody, setFullCustomBody] = useState(false)
const preview = trpc.round.previewRejectionEmail.useQuery( const preview = trpc.round.previewRejectionEmail.useQuery(
{ roundId, customMessage }, { roundId, customMessage, fullCustomBody },
{ enabled: open } { enabled: open }
) )
@@ -31,18 +34,31 @@ export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
return ( return (
<> <>
<button <div className="space-y-2">
onClick={() => setOpen(true)} <button
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-red-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left" onClick={() => setOpen(true)}
> className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-red-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full"
<XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" /> >
<div> <XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
<p className="text-sm font-medium">Notify Non-Advanced</p> <div>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-sm font-medium">Notify Non-Advanced</p>
Send rejection emails to non-advanced projects <p className="text-xs text-muted-foreground mt-0.5">
</p> Send rejection emails to non-advanced projects
</p>
</div>
</button>
<div className="flex items-center gap-2 px-1">
<Switch
id="rejection-full-custom-body"
checked={fullCustomBody}
onCheckedChange={setFullCustomBody}
/>
<Label htmlFor="rejection-full-custom-body" className="text-xs cursor-pointer">
<span className="font-medium">Full custom body</span>
<span className="text-muted-foreground ml-1"> only your message is sent (no standard text)</span>
</Label>
</div> </div>
</button> </div>
<EmailPreviewDialog <EmailPreviewDialog
open={open} open={open}
@@ -52,7 +68,7 @@ export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
recipientCount={preview.data?.recipientCount ?? 0} recipientCount={preview.data?.recipientCount ?? 0}
previewHtml={preview.data?.html} previewHtml={preview.data?.html}
isPreviewLoading={preview.isLoading} isPreviewLoading={preview.isLoading}
onSend={(msg) => sendMutation.mutate({ roundId, customMessage: msg })} onSend={(msg) => sendMutation.mutate({ roundId, customMessage: msg, fullCustomBody })}
isSending={sendMutation.isPending} isSending={sendMutation.isPending}
onRefreshPreview={(msg) => setCustomMessage(msg)} onRefreshPreview={(msg) => setCustomMessage(msg)}
/> />

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