Compare commits

...

131 Commits

Author SHA1 Message Date
Matt
03526fca97 fix(mentor): defer in-app-notification emails when mentoring round is draft
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m14s
Mentor-assignment flows (mentor.assign, autoAssign, bulkAssign,
bulkAutoAssign, autoAssignBulkForRound) call createNotification and
notifyProjectTeam for MENTEE_ASSIGNED / MENTOR_ASSIGNED. Both
notification types have NotificationEmailSetting.sendEmail = true, so
the notification system fires its own styled email in addition to the
explicit mentor-team / coalesced emails on the same code path. The
earlier defer-emails-until-round-open fix only gated the explicit
sendMentorBulkAssignmentEmail / sendMentorTeamAssignmentEmail calls;
this parallel email path kept firing immediately at every assignment.

Result on prod 2026-05-26: Camille Lopez (assigned to 9 projects via
two bulk_assigns) received 7 emails at 15:04 + 1 at 15:32 from the
notification-system path during draft, plus 1 coalesced email at the
18:20 round activation = 9 sends instead of 1. Every PEARL team
member (and equivalents on other teams) received 3 emails for the
same reason.

Fix
- Add `skipEmail?: boolean` to CreateNotificationParams,
  createNotification, createBulkNotifications, and (via spread)
  notifyProjectTeam. When true the in-app notification row still
  fires but the parallel email send is suppressed; the coalesced
  mentor email and team intro at activateRound time remain the
  single source of email truth.
- Wire it up in every mentor-assignment site: compute the existing
  shouldDeferEmailsForProject gate once before the createNotification
  / notifyProjectTeam calls and pass `skipEmail: deferThisEmail`.
  bulkAssign precomputes draftProjectIds for the whole batch.
  autoAssignBulkForRound uses the round's status directly.
- New regression suite (mentor-email-deferral.test.ts, 3 cases):
  vi.mocks @/lib/email, asserts zero outbound sends when round is
  ROUND_DRAFT, confirms in-app notification rows still get written,
  and re-verifies the ACTIVE-round path still emails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:12:41 +02:00
Matt
61dfc608cd fix(mentor): restore Add Project on mentoring rounds + gate mentor assignment
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m15s
Three related bugs around the mentoring-round Projects tab:

1. Add Project to Round was unreachable on MENTORING rounds — the table swap
   in the prior commit lost the button. Export AddProjectDialog from
   project-states-table and render it inside MentoringProjectsTable with an
   "Add" button in the filter row and a CTA in the empty state.
2. The "Assign Projects" quick action on the round overview linked to the
   global pool with an opaque filter; on MENTORING rounds it now switches
   to the Projects tab where the new Add Project button + auto-fill +
   per-team picker all live. Non-mentoring rounds keep the old behavior.
3. mentor.assign and mentor.bulkAssign now refuse projects that aren't
   enrolled in any MENTORING round (any status). The single-assign throws
   BAD_REQUEST with a guidance message; the bulk path filters them out and
   reports ineligibleProjectCount in the result so the UI can warn the
   admin instead of silently skipping.

Tests: the multi-mentor-assignment suite now sets up a MENTORING round +
ProjectRoundState for each project it tests against, matching the new gate.
2026-05-26 15:20:01 +02:00
Matt
c4f7216bc1 feat(mentor): defer all assignment emails until round opens + per-project bulk UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m7s
Email policy
- mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress
  outbound email entirely when the project's MENTORING round is still
  ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications
  still fire), but notificationSentAt and teamIntroducedAt remain null so
  activateRound can pick them up later.
- activateRound, when activating a MENTORING round, now does a coalesced
  mentor-side email pass in addition to the existing team-side intro pass.
  Every (mentorId) bucket of pending assignments in this round gets exactly
  one combined email; the row stamps prevent duplicates on re-activation.
- The "send immediately" path is preserved for assignments made while the
  round is already ROUND_ACTIVE — mentors and teams stay in the loop in
  real time, but staging during draft is silent.

Per-project bulk UI
- The /admin/projects/[id]/mentor manual picker now has a checkbox column,
  header select-all, and a primary-tinted action toolbar that appears when
  one or more candidates are selected. Submitting calls mentor.bulkAssign
  with the single projectId so the cartesian server path handles dedup,
  coalesced emails, and team intros uniformly with the round-page bulk.
2026-05-26 14:48:38 +02:00
Matt
cb2a864b7f feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m27s
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.

Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.

Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +02:00
Matt
195fc787a9 feat(mentor): bulk assignment + coalesced emails + team intros on round open
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
  toolbar that appears when 1+ rows are selected with an "Assign mentor…"
  CTA and Clear. Dialog lists the mentor pool with search (name/email/
  country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
  and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
  one mentor to many projects in a transaction; idempotent on the per-pair
  `(projectId, mentorId)` unique; per-project in-app notifications still
  fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
  getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
  so the page reflects the new state without a refresh.

Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
  assigned project + workspace links) used by `mentor.bulkAssign` and
  `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
  now emails mentors at the end of the batch, one combined email per
  mentor regardless of how many projects they received.

Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
  name + email and a link to the workspace, so teams can reach out
  directly.
- `activateRound` (round-engine) fires the introduction for every project
  in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
  fire the introduction immediately when the project's MENTORING round is
  already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
  (migration 20260526114936) — independent from `notificationSentAt` so
  pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
Matt
921019aaa4 fix(mentor): unbreak the mentor pipeline end-to-end
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m42s
Adding the MENTOR role from /admin/members/[id] only updated React state — the
AlertDialog "Add role" confirmation never called the server, so prod ended up
with zero users in MENTOR roles[] and /admin/mentors showed "No mentors yet".
The dialog now awaits updateUser.mutateAsync({ roles }) before closing.

Other corrections in the same area:

- DialogContent uses flex flex-col with max-h-[90vh] overflow-y-auto so tall
  modals (e.g. Add Project to Round) scroll internally instead of overflowing
  past their own rounded background.
- getProjectsNeedingMentor now matches autoAssignBulkForRound exactly: both
  filter mentorAssignments by droppedAt: null and require
  finalistConfirmation: CONFIRMED, so the toolbar count never exceeds what
  auto-fill actually processes. The toolbar surfaces hasNoMentors /
  hasNoEligible / count / all-assigned as distinct states instead of one
  misleading "All eligible projects have a mentor" line.
- New per-team table (MentoringProjectsTable) replaces ProjectStatesTable on
  the Projects tab of MENTORING rounds. Lists every project with its active
  mentors (multi-mentor aware), filter pills, search, finalist-confirmation
  badge, and a per-row link to /admin/projects/[id]/mentor for assigning.
- Applicant team page now lists ALL active mentors (PR8 Task 7) instead of
  just mentorAssignments[0].
- Hard guard in src/lib/email.ts short-circuits sendEmail when NODE_ENV=test
  or VITEST=true so test runs can never emit real notifications again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 13:01:05 +02:00
Matt
5b99d6a530 refactor(ui): strip all dark: Tailwind classes (single-theme product)
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m17s
Mechanical sweep of 41 files via `perl -i -pe 's{\s+dark:[\w:/\[\]\.\-]+}{}g'`.
All dark: variants were paired with light-mode counterparts already; no
elements relied on a dark:-only style.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:45:42 +02:00
Matt
6969b9c2bc chore(deps): drop next-themes; remove ThemeProvider + theme toggle UI 2026-05-22 18:43:25 +02:00
Matt
3bc9c11a51 merge: PR10 — applicant nationality stats card 2026-05-22 18:42:51 +02:00
Matt
8d4b62a602 feat(reports): applicant nationality breakdown card with scope filter (PR10)
- stats.getApplicantNationalities procedure aggregates User.nationality
  across team members of projects in the selected scope (round/program
  /global)
- New Applicant Nationalities card on /admin/reports, top-10 with
  Show all expansion, country names from the existing ISO map
- Handles the ~30% null case explicitly ("Not declared: N")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:38:52 +02:00
Matt
f64e68e751 merge: PR8 — multi-mentor per team + change-requests + inline previews
Schema: MentorAssignment becomes M:N (composite unique on (projectId, mentorId)).
MentorFile re-scopes to projectId (team-wide); mentorAssignmentId becomes a
nullable audit FK with SetNull. New MentorChangeRequest model + status enum.

Behavior:
- mentor.assign stacks mentors per team; per-team assignment email fires
  once per row (idempotent via notificationSentAt).
- mentor.requestChange / listChangeRequests / resolveChangeRequest provide
  the change-request inbox; mentors are NOT notified, only admins.
- Workspace files re-scoped to project so all co-mentors and team members
  share one file list and chat.
- New inline FilePreview support in the mentor workspace.
- mentor.getProjectMentors surfaces co-mentors on the mentor workspace.

Migration: hand-written, idempotent guards, two-phase backfill on
MentorFile.projectId. Verified against May 7 prod dump with rollback.sql.

PRE-DEPLOY: pull a fresh prod DB dump and re-run the dry-run before
applying the migration to prod (the May 7 snapshot may not include
mentors added since by another admin).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:26:37 +02:00
Matt
48e48f058d feat(mentor-workspace): inline document preview matching applicant docs pattern
- Eye toggle expands the row below to embed FilePreview from
  @/components/shared/file-viewer (PDF iframe, image, video, Office docs)
- Download button uses explicit Content-Disposition: attachment via a
  new `disposition` input on workspaceGetFileDownloadUrl
- getPresignedUrl learns `inline: true` and optional `response-content-type`
  override so PDFs/images don't get force-downloaded by MinIO's default
- Eye button only renders for previewable mime types

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:26:20 +02:00
Matt
ec92b03006 test(mentor): cover multi-mentor stacking + change-request procedures (PR8 Task 10)
- multi-mentor-assignment.test.ts: stacking, P2002 dup-pair, per-team email
  idempotency via notificationSentAt, requestChange/list/resolve auth +
  conflict semantics
- mentor-file-scope.test.ts: schema invariant (projectId required, dropping
  the originating assignment leaves the file in place via SetNull)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:20:01 +02:00
Matt
349671f37c merge: PR8 Task 8 — admin multi-mentor UI + change-request inbox 2026-05-22 17:13:02 +02:00
Matt
4f444a1baa merge: PR8 Task 7 — applicant mentor list + request-change dialog 2026-05-22 17:12:58 +02:00
Matt
d47db17027 merge: PR8 Task 9 — mentor co-mentor visibility 2026-05-22 17:12:54 +02:00
Matt
83e950bb67 feat(admin): multi-mentor stacking UI + change-request inbox (PR8 Task 8)
- /admin/projects/[id]/mentor renders all co-mentors as a list with per-row
  Unassign (confirm dialog) and a stacking "Add a mentor" flow that no longer
  hides when at least one mentor is assigned. Candidates and AI suggestions
  filter out already-assigned mentors.
- Pending change-requests panel appears above the mentor list when there are
  open requests for the project, with per-card Mark Resolved / Dismiss actions
  routed through mentor.resolveChangeRequest (optional resolution note).
- MentoringRoundOverview gains a "Pending change requests" row showing the
  PENDING count across the program; the Review link deep-links to the first
  pending request's project mentor page.
- mentor.unassign now accepts { assignmentId } so the admin UI can target a
  specific co-mentor (legacy { projectId }-only callers still work and remove
  the most-recent assignment).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:11:31 +02:00
Matt
ba115f71a0 feat(applicant): mentor list + request-change dialog (PR8 Task 7)
- /applicant/mentor renders all co-mentors as cards
- New "Request a mentor change" dialog opens a free-form reason + optional
  per-mentor target; calls mentor.requestChange and shows admin-routed
  confirmation toast
- Pending-request guard disables the button until the admin resolves
2026-05-22 17:09:06 +02:00
Matt
d440b5f274 feat(mentor): show co-mentors on workspace page (PR8 Task 9)
- Adds mentor.getProjectMentors({ projectId }) — returns all active
  MentorAssignment rows for a project, authorized to any mentor on it
- Workspace page header surfaces "You + N co-mentor(s): names…" so each
  mentor knows the team composition without having to ask the admin

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:07:11 +02:00
Matt
ee47c0305f feat(mentor): add change-request procedures + admin email notification
- mentor.requestChange: applicants/admins open a PENDING MentorChangeRequest
  with a reason; one open request per (user, project) enforced
- mentor.listChangeRequests: admin-only inbox listing
- mentor.resolveChangeRequest: admin marks RESOLVED or DISMISSED with optional
  resolution note
- sendMentorChangeRequestEmail: notifies all SUPER_ADMIN/PROGRAM_ADMIN users
  when a request is opened (try/catch — never throws)
- Mentors are NOT notified of change requests, even after resolution
  (per design decision in PR8 plan)
- Audit log entries for create + resolve; raw reason redacted from audit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:59:23 +02:00
Matt
3a1eb149b6 feat(mentor-workspace): re-scope files from assignment to project for team-wide visibility
- MentorFile.projectId is the new access boundary; mentorAssignmentId stays
  as informational audit FK (nullable).
- uploadFile derives projectId from the assignment; getFiles takes projectId
  directly; deleteFile/addFileComment auth checks any mentor on the project
  OR a project team member.
- HMAC upload token now binds to projectId (in addition to assignmentId).
- promoteFile reads file.projectId directly (no more mentorAssignment null
  navigation).
- Removes 3 placeholder NOT_FOUND guards added in Task 4.
2026-05-22 16:53:07 +02:00
Matt
a5ad11a1b5 feat(mentor): allow stacking mentors per team; send per-team assignment email
- mentor.assign no longer rejects on existing mentor; rejects only on
  duplicate (projectId, mentorId) via P2002 catch.
- After successful create, sendMentorTeamAssignmentEmail fires once and
  stamps MentorAssignment.notificationSentAt for idempotency.
- All existing behavior preserved: audit log, in-app notifications,
  MENTORING round auto-transition.
- mentor.getSuggestions no longer short-circuits when a mentor is already
  assigned — the suggestions list is now informational and the per-pair
  unique constraint enforces correctness at assign time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:38:14 +02:00
Matt
66110598a0 refactor(schema-cascade): rename Project.mentorAssignment → mentorAssignments
Schema dropped @unique on MentorAssignment.projectId in PR8 Task 1 →
back-relation becomes a list. Mechanical rename of Prisma queries and
consumer accessors. Legacy single-mentor callers use [0] with a TODO for
PR8 Task 8 to surface the full list. mentor-workspace.ts is left as Task 5.

- routers (mentor, project, applicant, finalist, round) and smart-assignment
  service: include/where/select keys renamed; `mentorAssignment: null` →
  `mentorAssignments: { none: {} }`; `{ isNot: null }` → `{ some: {} }`.
- UI consumers (mentor + applicant pages): `project.mentorAssignment` →
  `project.mentorAssignments[0]` with TODO markers.
- Tests: `findUnique({ projectId })` → `findFirst({ projectId })` since the
  composite key now requires both projectId+mentorId. MentorFile.create gains
  the new required projectId.
- Workspace endpoints in mentor.ts now guard null mentorAssignmentId until
  Task 5 re-scopes them to project.
- finalist.unconfirm now cascades to ALL active mentor assignments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:37:37 +02:00
Matt
9152ebb399 feat(email): add sendMentorTeamAssignmentEmail for per-team mentor notifications
Fires when a mentor is added to a specific project team — distinct from the
one-time onboarding email keyed by User.mentorOnboardingSentAt. Idempotency
for this new email is enforced at the call site in Task 4 via
MentorAssignment.notificationSentAt. Wrapped in try/catch — never throws.
2026-05-22 16:16:28 +02:00
Matt
a26e486ab5 chore(migration): include manual rollback.sql for PR8 multi-mentor
Tested against the 2026-05-07 prod dump: restore → forward → rollback restores
the schema to its pre-migration state. Safe to run only BEFORE any project
gets a second mentor — re-adding UNIQUE(projectId) will fail otherwise
(intended safety signal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:13:28 +02:00
Matt
e89dca24c3 feat(schema): multi-mentor per team + change-requests + per-assignment email field
- MentorAssignment: drop projectId @unique -> composite (projectId, mentorId)
- MentorAssignment: add notificationSentAt for idempotent per-team email
- MentorFile: add projectId (primary scope); mentorAssignmentId becomes nullable audit FK
- MentorChangeRequest: new model + status enum
- Migration hand-written with IF EXISTS guards (safe for docker-entrypoint retry)
2026-05-22 16:05:25 +02:00
Matt
3bcbf72ad6 fix(members): replace flat role checkbox grid with assigned-only dropdown + confirm modal
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m55s
The previous Additional Roles grid laid every role option out as a row of
checkboxes regardless of assignment, which made unchecked roles look like
roles the user already had — admins almost toggled the wrong role on the
wrong user (e.g. nearly granting JURY_MEMBER when looking at an
AWARD_MASTER).

New layout shows only the roles a user actually has, as removable badges
with an X. A "Manage roles" dropdown next to them surfaces the full role
list as DropdownMenuCheckboxItems (assigned ones are checked, the
primary role is excluded). Toggling any item opens an AlertDialog with
add/remove-specific copy that names the user and the dashboard being
granted/revoked, so the click is impossible to misread.

The change is staged into local additionalRoles state — same flow as
before — and persisted on Save. Modal copy spells this out so the admin
knows the action isn't applied until they click Save below.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:27:15 +02:00
Matt
47746d79dd feat(auth): admin access link doubles as magic-login for users with passwords
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m7s
The original generateAccessLink branched on user state and minted either
an invite URL (forces password setup) or a reset URL (forces password
change). Both required the user to set/change a password — fine for new
users, painful for tech-illiterate sponsor jurors who already have a
working password and just need a fresh login because their JWT went
stale or their email is bouncing.

This adapts the existing invite-token flow to behave as a magic-login
when the user already has a password:

  - auth.ts credentials.authorize: only set mustSetPassword=true if the
    user has no passwordHash. Users who already set one keep it, the
    invite token is consumed, JWT is issued with their current role,
    they're signed in.
  - accept-invite/page.tsx: redirect to / after accept (was hardcoded
    to /set-password). The middleware already enforces the
    /set-password detour when mustSetPassword is true, so users who
    need it still land there; everyone else routes by role.
  - generateAccessLink: drop the reset-password branch. Always emits an
    /accept-invite URL. The flow naturally adapts: setup for new users,
    magic-login for active ones. Audit log records which behavior fired
    (kind: 'setup' | 'magic_login').
  - dialog copy: clearer description for each kind.

Net behavior: Didier (active, has password, stale JWT after role
migration) clicks his link → instant login on /jury, password preserved.
Magali (no password yet) clicks hers → /set-password → onboarding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:35:22 +02:00
Matt
44c7accf62 feat(admin): generate access link for users when email isn't reaching them
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Adds a "Copy Access Link" button on the member detail page that mints a
one-time URL the admin can share over Slack, WhatsApp, or any other
channel. Solves the "we sent them an invite three weeks ago and it
silently dropped into spam" failure mode that left jurors stranded.

Server: user.generateAccessLink (adminProcedure) inspects the target
user's state and picks the right flow:
  - INVITED / NONE / mustSetPassword / no password ever set → invite-flow
    URL (/accept-invite?token=…); the existing flow takes them through
    accept → set password → onboarding without further admin help.
  - Active user with a password → password-reset URL
    (/reset-password?token=…); they pick a new password and middleware
    bounces them to onboarding if it's still pending.

Both flows already exist; this just exposes a way to mint a fresh token
without sending an email. The token has a 24h hard expiry and is consumed
on successful completion of the flow, so a leaked or screenshot link
can't be replayed against a different user later in the day. Each
generation is audit-logged with the admin's id, the target user's id +
email, and the link kind.

UI: button next to Resend Invite on /admin/members/[id]; opens a dialog
with a read-only input pre-selected, a one-click copy button, expiry
timestamp, and a warning not to paste in public channels.

Side benefit: users like Didier who have stale JWTs from a recent role
change can use a fresh access link to force a re-login that picks up
their updated role.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:28:43 +02:00
Matt
9a9a73dde2 fix(docker): query _prisma_migrations directly for failed-migration auto-resolve
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m9s
The previous regex against `prisma migrate status` output silently drifted
out of sync with Prisma 6's wording, so today's failed migration on prod
was never auto-resolved — the container crash-looped and required a
manual DELETE on _prisma_migrations to recover.

Truth lives in the table: a row with `finished_at IS NULL AND
rolled_back_at IS NULL` is an unresolved failure. Query that directly via
the Prisma client (already shelled out for the user-count check below)
and loop until none remain (with a 5-iteration safety bound).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:10:44 +02:00
Matt
cad5b3fc28 fix(migration): drop default on User.roles before altering type
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m3s
The 20260507151706_drop_award_master_role migration failed on prod with
'default for column "roles" cannot be cast automatically to type
"UserRole_new"[]'. Postgres won't auto-cast the @default([]) binding
through an enum-type swap. Same DROP DEFAULT / SET DEFAULT dance the
singular `role` column already had.

The original migration ran in a transaction that fully rolled back, so
the DB is unchanged — the fixed migration can be applied as-is once the
failed record is resolved (DELETE FROM _prisma_migrations WHERE
migration_name='20260507151706_drop_award_master_role').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:31:08 +02:00
Matt
7bc2b84d1d refactor(awards): remove AWARD_MASTER role, fold features into jury chair flow
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m5s
The AWARD_MASTER role split sponsor jurors into a parallel UI that hid
project files (only showed when the award was anchored to an evaluation
round) and duplicated the jury voting path with no real difference in
authority — tie-break and finalize were already governed by AwardJuror.isChair
regardless of the user's global role. Inviting a juror via the award page
defaulted to AWARD_MASTER, randomly fragmenting jury panels.

This collapses the role into JURY_MEMBER + isChair:

- specialAward.getMyAwardDetail now returns evaluation scores, chair
  visibility into other jurors' votes, and juror roster
- specialAward.submitVote accepts an optional justification per vote
- specialAward.confirmWinner moves from awardMasterProcedure to
  protectedProcedure (juror+chair check inside)
- bulkInviteJurors creates JURY_MEMBER accounts and, when the award has
  a juryGroupId, also adds them to that JuryGroup so they appear on
  the round-page jury panel
- jury award page renders justification, eval-score badges, and a
  chair tools panel with vote tally + finalize-winner CTA
- juryGroup.list includes attached SpecialAwards; the jury-list UI
  shows a trophy pill alongside round pills
- (award-master) route group, awardMasterProcedure, AWARD_MASTER role
  enum value, and AWARD_MASTER_DECISION decisionMode are deleted
- migration demotes any residual AWARD_MASTER users to JURY_MEMBER and
  recreates the UserRole enum without the value

Coup de Coeur on prod: Didier (the sponsor juror added today as
AWARD_MASTER by the buggy invite form) was migrated to JURY_MEMBER and
attached to the existing "Coup de Coeur" JuryGroup; the SpecialAward
itself was linked to that group (juryGroupId was NULL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:21:09 +02:00
Matt
a9116b5833 fix(applicant-feedback): correct dashboard card scale + visible criterion bars
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m39s
- Dashboard summary card: globalScore is /10 (was /100) and DELIBERATION
  rounds skip the avg-score row (rank, not score)
- Per-criterion progress bars on full evaluations page: bg-brand-dark is
  not a defined class and rendered invisible; switched to bg-brand-blue

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:34:45 +02:00
Matt
b7a4eac2b1 fix(applicant-feedback): correct scales, hide jury-internal criteria, declutter UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m11s
- globalScore is /10 (was hardcoded /100); use real round.name (was 'Round N')
- Render criteria by type: numeric uses parsed scale (1-10/0-10/1-5),
  text shows as quoted block, boolean/advance hidden as jury-internal
- Drop redundant cross-round stat strip and per-round Score Comparison
- Plain language: 'Lowest/Highest' instead of 'Range', 'reviews' not 'evaluations'
- Settings toggles update optimistically (was waiting for refresh)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:21:52 +02:00
Matt
55e6abc161 feat(finalization): winner email + UI for terminal rounds
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m7s
When finalizing a round with no further round to advance to, passing teams
are winners — not advancers. Detected for both special-award terminal rounds
(label = award name) and the main competition's terminal round (label =
competition name). Wording uses "a winner" so it works for both single-winner
awards and top-N main-track outcomes.

Adds AWARD_WINNER_NOTIFICATION email type + template ("Your project has won!"
with "our team will reach out about next steps" copy). Routes through the
notification dispatch table the same way ADVANCEMENT_NOTIFICATION does.

The FinalizationSummary gains a `winnerContext` field; the admin finalization
tab uses it to swap "X projects will advance to Y" → "X winners will be
notified for [label]" and renames "Advancement Message" → "Winner Message"
in the custom-message field. The email-preview button shows the winner
template when applicable.

In-app notification (bell icon) gets matching winner copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:30:35 +02:00
Matt
e8d0bb050f fix(finalization): skip MENTORING rounds in advancement display copy
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m10s
The mentoring round is opt-in (eligibility: requested_only) and only a subset
of advancing teams enter it; the rest auto-pass through. Showing it as the
"next round" in the finalization summary and advancement emails was misleading
since Grand Finale is the shared destination for all advancing teams.

Routing is unchanged — targetRoundId still points to the next round by sortOrder
(may be MENTORING) so opt-in handling is preserved. Only the user-facing label
skips MENTORING.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:02:35 +02:00
Matt
6e36704bb1 feat(awards): notify jurors on assignment + admin reminder button
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m41s
The previous addJuror / bulkAddJurors / bulkInviteJurors flows silently
created AwardJuror rows with no notification when the user already had
an account. The result: assigned jurors had no idea they were assigned
unless they happened to log in and check /jury/awards manually.

Three changes:

1. New email template + sender (sendAwardJurorNotificationEmail). Tells
   the juror what the award is, how many projects are eligible, when
   voting closes, and links straight to /jury/awards/<id>. Reused for
   both the initial assignment notification and admin reminders.

2. Auto-send on assignment. addJuror / bulkAddJurors / bulkInviteJurors
   now send the email to newly-attached jurors. bulkInviteJurors checks
   for a prior AwardJuror row before sending so duplicate "Bulk Invite"
   clicks don't spam jurors who were already assigned. addJuror /
   bulkAddJurors accept a `sendEmail` flag so admin tooling can opt out.

3. New admin procedure specialAward.notifyJurors(awardId, userIds?,
   customMessage?). Surfaced in the Jurors tab as a "Send reminder to
   all" button at the top and a per-row mail icon for individual
   reminders. Audit-logged with action: 'JUROR_REMINDER'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:17:29 +02:00
Matt
7d72ee271f fix(security): route ai-shortlist through canonical anonymization pipeline
ai-shortlist was sending raw project.description, raw juror feedback
text (feedbackGeneral / feedbackText), and full extracted file text
content directly to OpenAI as part of the user prompt. Its only
"anonymization" was renaming `id` to `anonymousId`. This bypassed the
GDPR contract documented in the file's own header comment ("All project
data is anonymized before AI processing — No personal identifiers in
prompts") and in CLAUDE.md ("All AI calls anonymize data before sending
to OpenAI").

A juror writing "Contact applicant Jane Doe at jane@example.com" in
feedback would ship that PII to OpenAI verbatim every time an admin
generated a shortlist. Same for any names / emails / phone numbers
embedded in extracted PDF text.

generateCategoryShortlist now mirrors the pattern used by ai-filtering /
ai-tagging / ai-award-eligibility:

- toProjectWithRelations + anonymizeProjectsForAI(_, 'FILTERING')
- validateAnonymizedProjects gate that aborts on detected PII
- Aggregates (avgScore, evaluationCount, feedbackSamples) computed
  separately and merged onto the anonymized projects; each feedback
  sample passes through sanitizeText (strips email/phone/url/ssn) and
  is truncated to 1000 chars.

Defense-in-depth fix in the shared helper: anonymizeProjectForAI now
also runs sanitizeText over each file's text_content before emitting it
to AI services. Previously the helper passed extracted file text
through unchanged, which would have leaked PII from PDF body text via
ai-filtering / ai-tagging / ai-award-eligibility too if those services
turn on aiParseFiles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 04:14:58 +02:00
Matt
fbc42f11fd fix(security): defang CSV formula injection in all exports
CSV cells whose first character is one of `=`, `+`, `-`, `@`, `\t`, `\r`
are interpreted as formulas by Excel and LibreOffice when the file is
opened. `=HYPERLINK(...)` and `=WEBSERVICE(...)` execute on cell focus
with no prompt and can exfiltrate row data to an attacker URL; DDE
(`=cmd|...`) reaches RCE behind the "enable content" prompt.

The platform exposes anonymous-attacker reachable sinks:

- `application.submit` is publicProcedure with `projectName` as
  `z.string().min(2).max(200)` — no character filter — so a project
  titled `=HYPERLINK("https://evil/?d="&A1,"Click")` lands in every
  admin export that includes Project.title.
- `userAgent` from any unauthenticated request is persisted to
  `AuditLog.userAgent` and dumped verbatim into the audit-log CSV.

Three independent CSV builders all only escaped commas/quotes/newlines
and missed the formula-prefix class:

- `src/components/shared/csv-export-dialog.tsx` — used by
  export.evaluations, export.assignments, export.filteringResults,
  export.auditLogs, export.projectScores
- `src/components/admin/round/ranking-dashboard.tsx`
- `src/server/routers/lunch.ts` (lunch.exportManifestCsv)

Centralized the fix in a new `src/lib/csv.ts` `csvCell` helper that
prefixes a single quote when the value starts with a formula trigger,
then applies the standard quote/escape rules. Wired into all three
builders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 04:14:42 +02:00
Matt
9d0beed02f fix(security): file storage authorization hardening
Three separate issues in the file storage layer:

1. IDOR via client-controlled object key in applicant.saveFileMetadata
   and file.replaceFile. Both procedures accepted `bucket` and `objectKey`
   from the client and stored them on a new ProjectFile row attached to
   the caller's own project. Because file.getDownloadUrl authorizes via
   `findFirst({ bucket, objectKey })` -> projectId, an attacker could
   bind another team's storage object to their own project row and then
   download the foreign object through the legitimate authorization
   path. Now both procedures require `bucket === BUCKET_NAME` and the
   `objectKey` to start with the project's sanitized title prefix
   (matches the prefix that generateObjectKey produces server-side).

   New helper `objectKeyBelongsToProject` exported from src/lib/minio.ts;
   `sanitizePath` is now exported as well so the helper can reuse it.

2. Missing per-round scope on file.getBulkDownloadUrls. The single-file
   getDownloadUrl restricts a juror to files in rounds with sortOrder
   <= their assigned round, but the bulk variant only checked that an
   Assignment row existed for the project. A juror assigned only to
   EVALUATION could pull URLs for LIVE_FINAL/DELIBERATION confidential
   files via this endpoint. Now applies the same per-round filter when
   the caller's access to the project is jury-only (mentors / team
   members / award jurors retain unrestricted access, matching
   getDownloadUrl semantics).

3. Same omission on the standalone /api/files/bulk-download REST route.
   Same fix applied there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:30:00 +02:00
Matt
89e637843a fix(security): harden user router role guards + drop self-service email change
Three high-severity issues in user router:

1. user.update accepted both `role` and `roles[]` from input but only
   guarded the singular `role`. A PROGRAM_ADMIN could pass `roles:
   ['SUPER_ADMIN']` and self-escalate. Now applies the same guards to the
   array field and uses both fields when checking the target's current
   admin tier.

2. user.updateRoles only blocked SUPER_ADMIN grants; PROGRAM_ADMIN could
   grant PROGRAM_ADMIN laterally and could pass `roles: []` against any
   existing SUPER_ADMIN to silently demote them. Now blocks PROGRAM_ADMIN
   grants and refuses to mutate any target who currently holds SUPER_ADMIN
   or PROGRAM_ADMIN unless the caller is SUPER_ADMIN.

3. user.bulkUpdateRoles had the same omission and additionally let a
   PROGRAM_ADMIN strip SUPER_ADMIN from every peer admin in one call. Now
   requires SUPER_ADMIN for any add/remove of admin-tier roles, blocks
   modifying admin targets entirely from non-super-admins, and adds a
   PROGRAM_ADMIN self-demote guard.

Plus: user.updateProfile previously let any authenticated user silently
overwrite their own email with no verification or notification — turning
any short-lived session compromise into permanent account takeover via
password reset on the new address. Email is removed from the input
schema; the profile page email field is now read-only with a "contact
an administrator" hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:29:09 +02:00
Matt
a1c293028a fix(security): per-role visibility on project.list and project.get
project.list previously gated only JURY_MEMBER to assigned projects;
APPLICANT, MENTOR, OBSERVER, AUDIENCE, AWARD_MASTER fell through with
full access to every project across every program (team-member PII,
files, mentor identities). project.get had the same flaw.

Now: SUPER_ADMIN/PROGRAM_ADMIN see all (existing); OBSERVER/AWARD_MASTER
see all (these roles exist for cross-program oversight); JURY_MEMBER
sees only their assignments; MENTOR sees only their mentorAssignments;
APPLICANT sees only their team's projects; AUDIENCE sees nothing.

For users holding multiple roles, the access check uses an OR over the
applicable relationships (e.g. a mentor who is also an applicant sees
both their mentor projects and their team projects).

Existing admin/jury/mentor UIs continue to work because their access
paths are still satisfied. Audience users were not expected to use
project.list in the first place; they now correctly receive an empty
list rather than the full database.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:13:19 +02:00
Matt
765bdf9f9e fix(security): restrict file.replaceFile to admins + team members only
Replace was previously accepted from anyone with a relationship to the
project: jury (assignment), mentor (mentorAssignment), or team member.
That allowed jurors and mentors to swap a team's submission, with the
attacker-supplied bucket+objectKey pointing at any object they had
uploaded elsewhere.

Now only admins and the team itself (submitter or TeamMember) can
replace files. Jurors and mentors remain read-only on submissions.
The legitimate UI flow (team-lead replacing files from the applicant
dashboard) is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:13:11 +02:00
Matt
48d29d4a6b fix(security): assignment check on getDiscussion/addComment/getCOIStatus
evaluation.getDiscussion and evaluation.addComment were juryProcedure
that took projectId+roundId from input but never verified the caller
had an Assignment for that project+round. A juror could read foreign
deliberations and inject comments into them.

evaluation.getCOIStatus was protectedProcedure with no ownership check,
returning the full ConflictOfInterest record (including the free-text
description that captures personal/financial relationships) for any
assignmentId.

Both now check that admins are allowed always and otherwise require
assignment ownership. getCOIStatus loads the assignment to verify
caller ownership before returning the COI record.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:13:06 +02:00
Matt
90dcb47c25 fix(security): assertWorkspaceAccess on mentor workspace messaging
workspaceSendMessage, workspaceGetMessages, workspaceMarkRead, and
workspaceAddFileComment previously trusted the caller-supplied ID and
only checked workspaceEnabled. Any user with the MENTOR role could
read/post in any workspace, impersonating the assigned mentor and
inserting comments under any team's deliverables.

All four now run assertWorkspaceAccess (assigned mentor or team member
of the project), mirroring the file-handling procedures in the same
router. workspaceMarkRead resolves the message -> workspaceId first,
and additionally short-circuits when the caller is the sender so unread
state stays honest. workspaceAddFileComment resolves the file ->
mentorAssignmentId before the access check.

Procedures downgraded from mentorProcedure to protectedProcedure since
assertWorkspaceAccess is the real gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:13:01 +02:00
Matt
35f46c3e34 fix(security): require jury membership for liveVoting.vote
Prevents non-jury authenticated users from casting votes that get
counted in the jury aggregate. Admins are still allowed; everyone else
must be a JuryGroupMember of the round's jury group. Also explicitly
sets isAudienceVote=false on the upsert so audience votes can't be
laundered into jury votes via this path. Audience voting continues to
flow through the existing castAudienceVote publicProcedure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:12:54 +02:00
Matt
e0f6b7e741 chore: drop lunch placeholder from edition settings coming-soon card
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:50:39 +02:00
Matt
31b98f6f1e feat: read-only external attendees strip on applicant dashboard
Adds lunch.getProjectExternals (team-member guarded). Strip auto-hides
when no externals attached to the team.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:50:15 +02:00
Matt
df95867465 feat: lunch picker on attending-members card + admin slide-over
LunchPickForm shared between applicant dashboard rows (member-self /
team-lead context) and the admin manifest's edit-pencil slide-over.
Adds lunch.getMemberPick read for the per-row hydration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:49:08 +02:00
Matt
ec24d404c5 feat: lunch banner on applicant dashboard
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:46:02 +02:00
Matt
618def6174 feat: lunch recap actions card with preview + send + resend confirm
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:45:12 +02:00
Matt
bbfe2d8097 feat: external lunch attendees card + dialog
Adds program.listFinalistProjects helper. Externals dialog supports
both standalone and project-attached entries; manifest's external row
edit-pencil opens this dialog via forwardRef.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:44:38 +02:00
Matt
051dea4d0e feat: lunch manifest card with filters + CSV export
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:42:49 +02:00
Matt
939a13c0e8 feat: lunch dishes card with create/edit/delete
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:42:07 +02:00
Matt
ec00942620 feat: lunch event configuration card
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:41:34 +02:00
Matt
6fcabc89d7 feat: lunch tab scaffold + un-disable trigger
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:40:32 +02:00
Matt
d4e5d54de2 feat: lunch cron endpoints — reminders + recap
Both endpoints follow the existing GET + x-cron-secret pattern. Per-event
try/catch ensures one failing event does not poison the sweep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:39:51 +02:00
Matt
829a7e457a feat: lunch recap aggregation + sendRecap with forceUpdate gate
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:38:00 +02:00
Matt
05b0412534 feat: lunch reminder + recap email templates
Adds sendLunchReminderEmail and sendLunchRecapEmail. Templates use
Intl.DateTimeFormat with Europe/Monaco zone. Reuses existing
escapeHtml helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:37:17 +02:00
Matt
a671bb853c feat: lunch manifest query + CSV export
Adds buildManifest service shared between getManifest and the recap.
CSV escaper handles commas/quotes/newlines for safe spreadsheet import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:34:24 +02:00
Matt
d779959e54 feat: lunch member reads — getEventForMember + getTeamPicks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:33:24 +02:00
Matt
9e14775f08 feat: lunch.upsertPick with role-aware guard + cutoff
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:32:42 +02:00
Matt
06b171b0d4 feat: external lunch attendees CRUD
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:31:28 +02:00
Matt
1f24f5539c feat: dish CRUD on lunch router
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:30:49 +02:00
Matt
7da4200e72 feat: lunch.getEvent + lunch.updateEvent procedures
Lazy-creates LunchEvent on first read or update. Audit-logs every
update with the patched fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:30:06 +02:00
Matt
1a0afd8c6e feat: auto-create MemberLunchPick on attendee writes
Adds ensureLunchPickForAttendingMember helper called from confirm,
adminConfirm, and editAttendees attendee-creation paths. No-ops when
the program has no LunchEvent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:28:51 +02:00
Matt
cdb18cc3d1 feat: schema for lunch event, dishes, picks, externals
Adds LunchEvent (1:1 per Program), Dish, MemberLunchPick (1:1 per
AttendingMember), ExternalAttendee + DietaryTag/Allergen enums.
Allergens use the EU 14 regulated list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:23:55 +02:00
Matt
e16039142e docs: implementation plan for PR 6 — lunch event
Bite-sized TDD tasks covering schema migration, auto-create hook,
lunch router (admin CRUD + mixed-permission upsertPick + member reads
+ manifest + CSV export + recap), email templates, two cron endpoints,
five-card admin UI on Logistics → Lunch tab, applicant dashboard
banner + picker, project-page externals strip, and the edition-settings
cleanup. Cross-references the design spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:20:07 +02:00
Matt
1a58b3db1a docs: design spec for PR 6 — lunch event
Locked-in design covering data model (LunchEvent, Dish, MemberLunchPick,
ExternalAttendee + DietaryTag/Allergen enums), tRPC API surface,
admin/team-lead/member UI on Logistics → Lunch tab and applicant
dashboard, reminder + recap email/cron flows, edge cases, and testing
strategy. Ready for implementation plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:06:28 +02:00
Matt
eb19cb11a1 chore: drop dead Logistics tabs + move visa toggle to settings
- Remove the Documents tab — visa documents are out of scope for this
  edition and there is no other concrete document need.
- Remove the Logistics > Settings disabled tab — every per-edition
  configuration knob now lives at /admin/settings > Edition.
- Replace the inline "Visible to teams" toggle on the Visas tab with a
  small "Edition settings" button that links straight to the
  consolidated settings page. The toggle itself moved to that page in
  the previous commit.
- Drop the now-unused getVisaVisibility / setVisaVisibility wiring
  inside VisasTab. (The procedures still exist server-side; the new
  Edition tab uses program.updateEditionSettings instead.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:09:50 +02:00
Matt
2f59b87e4f feat: Edition tab on /admin/settings
New top-level Edition tab in the admin settings sidebar (under General,
between Defaults and Branding). Driven by the EditionSettingsTab
component which uses the EditionContext to scope to the current edition
and calls program.getEditionSettings / updateEditionSettings.

Three sub-sections:
  - Grand-finale logistics: defaultAttendeeCap, confirmationWindowHours,
    attendeeEditCutoffHours.
  - Visa: visaStatusVisibleToMembers toggle (will be removed from the
    Logistics > Visas tab in the next commit).
  - Coming soon: placeholders for Lunch and Email Templates.

Each numeric input commits on blur; the visa toggle commits immediately.
All writes invalidate the query so the rest of the UI reflects changes
without a refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:01:48 +02:00
Matt
78992a493a feat: program.getEditionSettings + updateEditionSettings
Backs the new consolidated Edition tab on /admin/settings.

getEditionSettings returns a merged view of Program-level fields
(defaultAttendeeCap, visaStatusVisibleToMembers) plus LIVE_FINAL round
config (attendeeEditCutoffHours, confirmationWindowHours, with
sensible defaults). Round-derived values are null when the round
doesn't exist yet.

updateEditionSettings is partial — only supplied fields are written.
Round config writes merge into the existing configJson so other keys
are preserved. Audit-logged as PROGRAM_EDITION_SETTINGS_UPDATE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:59:31 +02:00
Matt
62ab27a05a feat: mentor detail side sheet + Teams column
The mentor list now ends with a Teams column showing chips of each
mentor's active assignments (truncated at 2 + overflow badge). Clicking
any row opens a right-side Sheet with the mentor's profile (expertise,
country, joined date, max assignments) and a per-team activity feed —
project, status (active / completed / dropped), assignment date, and
counts of messages / files / milestones with their last timestamp.

Stat cards on both the Mentor and Mentee panels were stale and not
particularly informative, so they're gone — the table itself is now
the focal element on each panel.

getMentorPool gained an activeTeams[] field; new getMentorDetail query
backs the side sheet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:52:17 +02:00
Matt
030db533e1 fix: size logistics tab bar to fit buttons + horizontal scrollbar
The fixed h-10 wasn't tall enough to fit a 32px tab button plus the
overflow-x scrollbar, so buttons clipped and a vertical scroll appeared
inside the bar. Switching to h-auto + pb-2 lets the bar size naturally
and reserves space below the row for the scrollbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:45:52 +02:00
Matt
7824b00ff4 fix: horizontal scroll on logistics tab bar instead of wrapping
Stacking 8 tabs onto two rows looked rough. Switching the TabsList to
w-full + justify-start + overflow-x-auto keeps every tab on one line
and lets the bar scroll horizontally on narrower viewports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:44:38 +02:00
Matt
46a78c3a74 feat: render visa status + next date on AttendingMembersCard
Each member now sees their own visa status (status badge + next
upcoming date) on the applicant dashboard, sourced from
applicant.getMyVisaApplications. Other teammates' rows still show the
generic "Visa support" badge if they need a visa, since the platform
deliberately scopes visa visibility to the caller. The whole visa
surface auto-hides if the program toggle is off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:40:25 +02:00
Matt
fe630e0e2d feat: admin Visas tab — table + edit dialog + visibility toggle
Activates the previously-disabled Visas tab on /admin/logistics.

VisasTab renders a flat table joined per attendee per project, sorted
by status priority. Status filter pills mirror the Confirmations tab.
The header carries a "Visible to teams" Switch backed by a new
logistics.getVisaVisibility query and the existing setVisaVisibility
mutation; toggling it controls whether members see their own status.

VisaEditDialog is a per-row editor with a status dropdown,
nationality input, three native date inputs (invitation / appointment
/ decision), and a notes textarea. No file uploads — the platform
deliberately holds zero document artifacts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:37:55 +02:00
Matt
7c86e42413 feat: applicant.getMyVisaApplications gated by program toggle
Returns the caller's visa application rows when the program's
visaStatusVisibleToMembers toggle is on; returns null when it's off
(so the UI can hide the section entirely); returns an empty array
when the toggle is on but the caller has no needsVisa attendees.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:33:40 +02:00
Matt
0e104e0b6f feat: admin visa CRUD procedures
logistics router gains three procedures for the Visas tab:
  - listVisaApplications: program-scoped, joined with project + attendee,
    sorted by status priority (REQUESTED first → NOT_NEEDED last).
  - updateVisaApplication: partial update of status / dates / nationality /
    notes; clears nullable fields on null. Audit-logged as VISA_UPDATE
    with previous + next snapshots.
  - setVisaVisibility: flips Program.visaStatusVisibleToMembers. Audit-
    logged as VISA_VISIBILITY_SET.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:32:52 +02:00
Matt
bdfd99874a feat: auto-create/sync VisaApplication on attendee writes
confirm and adminConfirm now create REQUESTED VisaApplication rows for
every attendee with needsVisa=true, in the same Prisma transaction as
the AttendingMember inserts. editAttendees was extended into a fully
diff-aware sync: existing attendees whose needsVisa flips on get a new
VisaApp; flipping off deletes it; staying true preserves the row (and
its status / notes / dates). Removed attendees cascade automatically
via the FK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:31:28 +02:00
Matt
289903c8bd feat: schema for visa tracking (additive)
Adds VisaStatus enum, VisaApplication model 1:1 with AttendingMember
(cascade-delete), and Program.visaStatusVisibleToMembers Boolean
toggle. The model intentionally stores process metadata only — status,
optional nationality, key dates, free-text notes. Sensitive documents
(passport scans, invitation letters, decision papers) continue to flow
over email and are never persisted in the platform.

Migration is purely additive: CREATE TYPE / CREATE TABLE / ADD COLUMN /
ADD FK. No DROP / ALTER on existing data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:28:53 +02:00
Matt
6e5f607425 feat: admin can confirm/decline attendance on team behalf
This edition is being handled manually via email — admins need to
record what each finalist replied. Adds:
  - finalist.adminConfirm — flips PENDING → CONFIRMED with attendees +
    visa flags. Same cap and team-membership checks as the public flow,
    audit-logged as FINALIST_ADMIN_CONFIRM.
  - finalist.adminDecline — flips PENDING → DECLINED with optional
    reason and triggers waitlist promotion. Audit-logged as
    FINALIST_ADMIN_DECLINE.
  - finalist.getConfirmationDetail — feeds the admin attendee picker.
  - Per-row Confirm / Decline actions on the Logistics > Confirmations
    table (PENDING rows only) backed by a shared dialog that switches
    between attendee-picker and reason-input modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:03:01 +02:00
Matt
ff355ee10e feat: gate mentor auto-assign on CONFIRMED finalist status
mentor.autoAssignBulkForRound now skips any project whose finalist
confirmation isn't CONFIRMED — there's no point assigning a mentor to
a team that won't be at the grand finale. Other eligibility rules
(wantsMentorship, admin_selected, already-assigned) are preserved.

Updated existing requested_only and skip-already-assigned tests to seed
CONFIRMED confirmations so they continue to isolate their target gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:57:18 +02:00
Matt
903ec2401f chore: bump version to 1.0.0
The platform now covers the full 2026 edition lifecycle — invite-driven
auth, multi-round jury voting, mentor workspace + self-drop, AI
services, finalist confirmation + waitlist, logistics + travel + visa
self-declare, and a public confirmation flow. Marking 1.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:55:12 +02:00
Matt
a6284e5c66 feat: edit-attendees dialog + roster card on applicant dashboard
Adds applicant.getMyFinalistConfirmation query (returns roster + cutoff
metadata for the team's confirmation, or null). New AttendingMembersCard
shows the confirmed attendee list and surfaces an Edit dialog to the
team lead — disabled past the editable cutoff. Card auto-hides until the
confirmation reaches CONFIRMED status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:54:40 +02:00
Matt
5b642c3d50 feat: finalist.editAttendees with cutoff and diff-based update
Team-lead-only mutation that replaces the AttendingMember roster on a
CONFIRMED finalist confirmation. Diffs the requested user list against
existing rows: kept rows are updated in place (preserving FlightDetail),
removed rows are deleted, added rows are created. Enforces:
  - team-lead role
  - CONFIRMED status
  - defaultAttendeeCap
  - team-membership of every supplied userId
  - cutoff = LIVE_FINAL.windowOpenAt − attendeeEditCutoffHours (default 48)

Audit-logged as FINALIST_EDIT_ATTENDEES with the diff payload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:50:52 +02:00
Matt
3d8aab46f1 feat: mentor self-drop dialog on project detail page
DropAssignmentDialog with required reason (10-1000 chars) calls
mentor.dropAssignment, redirects to /mentor on success. Button surfaces
in the project header only when the viewer is the assigned mentor and
the assignment is neither dropped nor completed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:48:09 +02:00
Matt
3bc1cc14c7 feat: mentor self-drop with required reason
Adds mentor.dropAssignment mutation (mentor-only, ownership-checked, reason
min 10 chars). Filters dropped MentorAssignment rows out of getMyProjects,
getCandidates mentor count, getMentorPool, and getMenteeActivity so they
no longer surface in the mentor or admin UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:44:45 +02:00
Matt
5bdb65181d feat: finalist.unconfirm with mentor cascade
Admin can un-confirm a CONFIRMED finalist (e.g. to allow a category
quota decrease). Sets status to SUPERSEDED, cascades to drop the active
mentor assignment (if any) with droppedBy='finalist_unconfirmed' and
the reason embedded. Mentor receives a MENTEE_DROPPED notification.
Already-completed assignments are preserved untouched.
2026-04-28 18:37:34 +02:00
Matt
e706913a57 feat: add MENTEE_DROPPED + MENTOR_DROPPED notification types 2026-04-28 18:35:50 +02:00
Matt
6487f4b209 feat: schema — mentor assignment drop tracking
Adds 3 nullable fields to MentorAssignment for drop lifecycle:
- droppedAt: timestamp of drop (null while active)
- droppedReason: free text (required when droppedAt is set)
- droppedBy: 'mentor' | 'admin' | 'finalist_unconfirmed'

Migration is purely additive: no DROP, no ALTER COLUMN, no RENAME.
All existing rows automatically get NULL for the new columns.
2026-04-28 18:34:49 +02:00
Matt
57ec28edad feat: logistics page shell + Confirmations/Travel/Hotels tabs
- /admin/logistics page with shadcn Tabs (3 active + 5 disabled "(soon)"
  placeholder tabs for Visas / Lunch / Documents / Email Templates / Settings).
- Sidebar entry "Logistics" between Mentors and Awards (Plane icon).
- Confirmations tab: read-only table with status filter pills, browser-
  local-time deadline display, attendee count, decline reason snippet.
- Hotels tab: single-hotel form (name/address/link/notes) with live
  email-preview card showing what teams will see.
- Travel tab: per-attendee flight tracker with arrival/departure
  datetimes, flight numbers, IATA airports, click-to-toggle status badge,
  edit Sheet, and unfilled/pending/confirmed filter pills.

Smoke-tested end-to-end: navigation, sidebar entry, all three tabs
render, hotel save persists to DB and renders in preview card.
2026-04-28 18:25:29 +02:00
Matt
d1f29a149a feat: list-confirmations admin query 2026-04-28 18:20:40 +02:00
Matt
b1e6eb81eb feat: flight-detail CRUD on logistics router 2026-04-28 18:19:39 +02:00
Matt
497145b983 feat: hotel CRUD on logistics router 2026-04-28 18:18:16 +02:00
Matt
88548cbea3 feat: schema for logistics hotels + flight tracking
Adds 2 new models for grand-finale logistics PR 2:
- Hotel: 1:1 with Program (one per edition); name + address + link + notes
- FlightDetail: 1:1 with AttendingMember; arrival + departure datetimes,
  flight numbers, airports, admin status (PENDING/CONFIRMED), admin notes

Migration is purely additive: no DROP/ALTER COLUMN/RENAME. FKs point
FROM new tables TO existing tables (Program, AttendingMember) with
ON DELETE CASCADE only firing on parent deletion.
2026-04-28 18:17:09 +02:00
Matt
95055e0dae feat: admin UI for finalist slot quotas + waitlist on grand-finale round
- New components/admin/grand-finale/finalist-slots-card: per-category
  quota editor with confirmed/pending counts, dirty-tracking, save button.
  Renders an empty editor for both Startup and Business Concept categories
  even when no quota exists yet.
- New components/admin/grand-finale/waitlist-card: per-category ranked
  waitlist display with status badges + manual-promote AlertDialog
  (audit-logged via FINALIST_MANUAL_PROMOTE).
- Round detail page: embeds both cards conditionally when
  roundType === 'LIVE_FINAL'.
- New finalist router queries: listQuotas, listCategoryCounts (groupBy
  on category+status), listWaitlist (rank-ordered with project relation).

Smoke-tested: setting Startup quota to 3 persists to DB; UI renders
quota editor and waitlist card cleanly with empty state.
2026-04-28 18:07:55 +02:00
Matt
437bed2326 feat: public finalist confirmation page UI
- /finalist/confirm/[token] under (public) route group
- Browser-local-time deadline + zone label + live countdown
- Default-selects up to defaultAttendeeCap team members
- Per-member "Needs visa?" toggle that surfaces only when selected
- Decline AlertDialog with optional reason textarea
- Distinct friendly states for invalid / expired / already-confirmed /
  already-declined / superseded tokens (not generic errors)
- Smoke-tested end-to-end against live dev server: confirmation row
  flipped to CONFIRMED, AttendingMember row created with correct visa flag
2026-04-28 18:04:25 +02:00
Matt
14a81cd6ec feat: auto-cascade cron + admin waitlist management procedures
- expirePendingPastDeadline service: scans PENDING confirmations past
  deadline, marks each EXPIRED + audit-logs, then promotes the next
  waitlist entry per affected category (using each program's grand-final
  round configJson for windowHours).
- /api/cron/finalist-confirmations: hourly cron entrypoint (CRON_SECRET
  header gate), wraps the service.
- finalist.addToWaitlist: insert at a specific rank, shifting later
  entries down (transactional).
- finalist.reorderWaitlist: rewrite a category's rank order in one go,
  using a temp-rank trick to avoid unique-constraint conflicts mid-update.
- finalist.manualPromote: out-of-rank-order admin promote with audit log
  (FINALIST_MANUAL_PROMOTE) + fresh confirmation email.

2 new tests. Suite at 14/14 for finalist-confirmation.
2026-04-28 18:00:47 +02:00
Matt
19ef364c71 feat: public confirm/decline procedures with waitlist auto-promotion
- finalist.getByToken: public lookup of a confirmation by signed token,
  with all the data the public page needs (project, team members, current
  state). Throws on expired/tampered tokens.
- finalist.confirm: validates team membership of every selected user,
  checks against program.defaultAttendeeCap, atomically writes
  status=CONFIRMED + AttendingMember rows in a transaction.
- finalist.decline: captures optional reason, then promotes the next
  WAITING waitlist entry in the same category (no-op if waitlist empty).
  Resolves the new windowHours from the LIVE_FINAL round configJson.
- promoteNextWaitlistEntry service: encapsulates the cascade (mark
  PROMOTED, create fresh PENDING confirmation, send email).
2026-04-28 17:58:31 +02:00
Matt
895be93678 feat: selectFinalists creates PENDING confirmations and sends emails
- New service module createPendingConfirmation: writes a PENDING
  FinalistConfirmation row with a signed token whose exp matches the
  computed deadline.
- selectFinalists admin mutation: reads windowHours from the round's
  configJson.confirmationWindowHours (default 24), validates category
  match + quota, then creates one confirmation per selected project
  and sends a notification email to the team lead. Email failures are
  logged but never roll back the row creation.
- New email helpers: getFinalistConfirmationTemplate +
  sendFinalistConfirmationEmail.
2026-04-28 17:55:09 +02:00
Matt
3ea36296b9 feat: per-category finalist slot quotas with confirmed-count guard 2026-04-28 17:52:22 +02:00
Matt
53a1e62614 feat: HMAC-signed finalist confirmation token 2026-04-28 17:50:17 +02:00
Matt
dff18b17f7 feat: schema for finalist confirmation flow + per-category quotas
Adds 4 new models for grand-finale logistics PR 1:
- FinalistSlotQuota: per-category mutable quotas
- WaitlistEntry: ranked per-category waitlist
- FinalistConfirmation: token-gated confirmation lifecycle (PENDING /
  CONFIRMED / DECLINED / EXPIRED / SUPERSEDED) with optional decline reason
- AttendingMember: who from each team is attending, with visa flag

Plus Program.defaultAttendeeCap (default 3) for the per-edition team
attendance cap.

Migration is purely additive: no DROP/ALTER COLUMN/RENAME on existing
schema. All FKs ON DELETE CASCADE only fire on parent deletion.
2026-04-28 17:49:26 +02:00
Matt
d0058b46ed feat: Mentees & Activity tab on /admin/mentors
Adds a project-centric ops view for mentor management:
- New mentor.getMenteeActivity tRPC procedure aggregates every project
  with wantsMentorship=true and derives a status (unassigned / assigned
  / active / stalled) from the latest message + file activity.
- /admin/mentors becomes a tabbed page: existing Mentor list +
  new Mentees & Activity table with status pills, search, and a
  per-row Assign/Open CTA linking to /admin/projects/[id]/mentor.
- Includes 2 unit tests covering classification + program scoping.

Also: ignore .remember/ (plugin scratch dir).
2026-04-28 16:47:53 +02:00
Matt
11ab0943f6 feat: name current view in role-switcher pill, add Mentors sidebar entry
- Switcher trigger now shows the current view's icon + label with a
  chevron (e.g. "Admin View ⌄") instead of the vague "Switch View".
  Dropdown adds a header, marks the current view with a checkmark,
  and lists each accessible alternative explicitly.
- Adds a "Mentors" entry to the admin sidebar between Juries and
  Awards so the existing /admin/mentors page is reachable from nav.
2026-04-28 16:32:51 +02:00
Matt
e37f3a5874 fix: render enum labels as proper title case
formatEnumLabel was leaving inputs uppercase ("TECHNOLOGY_INNOVATION"
became "TECHNOLOGY INNOVATION"); lowercasing first yields proper
title case ("Technology Innovation") and improves labels app-wide.
Apply it on the project mentor page for Ocean Issue + Category.
2026-04-28 16:28:30 +02:00
Matt
26ff8ed111 feat(workspace): mentor + applicant message previews (§F.2)
mentor.getRecentMessages: last N unread messages from teams across all
of a mentor's assignments. Drives a Recent Messages card on /mentor.

applicant.getMentorConversationPreview: last 3 messages + unread count
for a given project. Drives a 'Conversation with [Mentor]' card on
/applicant — auto-hides when no mentor is assigned.

Both procedures use the existing MentorMessage(projectId, createdAt)
composite index — no new index needed.

Plan: docs/superpowers/plans/2026-04-28-pr6-multi-role-and-workspace-previews.md
2026-04-28 16:14:11 +02:00
Matt
70a9752d73 refactor(layouts): shared RoleSwitcherPill across dashboards (§D.6)
Extract ROLE_SWITCH_OPTIONS + switchableRoles computation from the two
duplicated copies (role-nav.tsx + admin-sidebar.tsx) into a single
src/components/layouts/role-switcher.tsx module.

Adds a RoleSwitcherPill component placed top-right of every dashboard:
  - Hidden for single-role users
  - Hidden during impersonation
  - Same visual + click target across /jury, /mentor, /applicant,
    /observer, /award-master AND /admin (admin layout gains a small
    top-bar to host the pill)

Removes the duplicate role-switcher items from the admin sidebar's
bottom user-menu — one source of truth instead of three.

Plan: docs/superpowers/plans/2026-04-28-pr6-multi-role-and-workspace-previews.md
2026-04-28 16:09:40 +02:00
Matt
6475d5c418 fix(impersonation): pointer-events + show all roles (§D.4-5)
Banner wrapper now uses pointer-events-none so it doesn't intercept clicks
on the user-menu dropdown sitting underneath; the 'Return to Admin' button
re-enables pointer events on itself only.

Banner also lists every role the impersonated user holds (e.g.
'JURY MEMBER, MENTOR') instead of just the primary role, matching how
multi-role users are surfaced everywhere else.

Plan: docs/superpowers/plans/2026-04-28-pr6-multi-role-and-workspace-previews.md
2026-04-28 16:05:51 +02:00
Matt
432470083c feat(admin): bulk role updates + mentor-onboarding email (§D.2-3)
user.bulkUpdateRoles({userIds, addRole?, removeRole?}) batches role
changes across up to 200 users with a SUPER_ADMIN self-demote guard.
When MENTOR is freshly added, fires sendMentorOnboardingEmail once per
user, gated by User.mentorOnboardingSentAt for idempotency. Audit log
entry per user changed.

UI: 'Add MENTOR role' button surfaces in the existing /admin/members
bulk-selection toolbar when ≥1 user is selected. Other roles
(OBSERVER / AWARD_MASTER) supported by the procedure but not yet wired
to UI; one button keeps the toolbar minimal until a clear need arises.

Tests cover happy path, idempotency on second call, removeRole semantics,
and the SUPER_ADMIN self-demote guard.

Plan: docs/superpowers/plans/2026-04-28-pr6-multi-role-and-workspace-previews.md
2026-04-28 16:05:16 +02:00
Matt
0c2b2d1f96 feat(user): context-aware default dashboard (§D.1)
user.getDefaultDashboard returns the highest-priority role for which the
user has actionable work right now — pending eval in active round, active
mentoring assignment, applicant project in active round, etc. — falling
back to static priority order if nothing is actionable.

src/app/page.tsx now reads roles[] (multi-role array) instead of just the
primary role, fixing the bug where mentor+juror users always landed on
their primary role's dashboard. Uses static priority for simplicity in
the server component; the context-aware procedure remains available for
client surfaces.

Tests cover six cases: super-admin, juror with active eval, juror+observer
fallback, mentor+juror in mentoring round, both-active-priority-tiebreak,
observer-only.

Plan: docs/superpowers/plans/2026-04-28-pr6-multi-role-and-workspace-previews.md
2026-04-28 16:00:56 +02:00
Matt
cedd188328 feat(email): mentor-onboarding email template + sender (§D)
One-shot email sent when a user is first granted the MENTOR role.
Subject: 'Welcome to MOPC mentoring'. Includes a CTA to /mentor and
a hint about the Switch View pill for multi-role users.

Idempotency lives at the call site (User.mentorOnboardingSentAt
checked in user.bulkUpdateRoles / user.updateRoles).

Plan: docs/superpowers/plans/2026-04-28-pr6-multi-role-and-workspace-previews.md
2026-04-28 15:58:17 +02:00
Matt
75c8829c3f feat(db): add User.mentorOnboardingSentAt for one-shot onboarding email (§D)
Single nullable DateTime column. No backfill. Catalog-only ALTER TABLE —
sub-millisecond on PostgreSQL regardless of table size. The column is
unused until the bulk role-update flow wires it up as an idempotency
stamp for the mentor-onboarding email.

Plan: docs/superpowers/plans/2026-04-28-pr6-multi-role-and-workspace-previews.md
Spec: docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md §D
2026-04-28 15:57:09 +02:00
Matt
08829df54d fix(mentor): show non-suspended users in pool + picker (§B/§C)
mentor.getCandidates and mentor.getMentorPool both filtered on
status='ACTIVE', which excluded every seeded mentor (status=NONE)
and any INVITED mentor. Production scenario: PR 4's Manual Picker and
PR 5's pool counts both rendered empty against real data.

Filter changed to status != SUSPENDED — admins want to see all mentors
they manage (including INVITED + NONE), but not suspended ones.

Found via Playwright smoke of PR 5: pool count read 0 against 4 seeded
mentors with roles[]=['MENTOR'], status='NONE'.
2026-04-28 15:32:28 +02:00
Matt
34bd267c32 feat(admin): real /admin/mentors list page (§B)
Replaces the redirect-to-/admin/members stub with a sortable, searchable
list of all MENTOR-role users powered by mentor.getMentorPool. Columns:
name, expertise tags, country, active count, completed count, capacity
remaining, last activity. Header summary cards show pool size, total
active assignments, and average load.

Row links continue to /admin/members/[id]; /admin/mentors/[id] remains
a redirect (mentor-detail view deferred to a future PR).

Plan: docs/superpowers/plans/2026-04-28-pr5-mentor-round-overview.md
2026-04-28 15:28:09 +02:00
Matt
a0a2c5f06a feat(mentor): mentoring-specific Round Overview card grid (§B)
Renders above Round Details when round.roundType === 'MENTORING':
  - Top-line counts: requested + assigned (with awaiting badge)
  - Request window: countdown pill (amber <48h, red <12h)
  - Mentor pool: size + avg load + 'View all' link to /admin/mentors
  - Workspace activity: msgs / files / milestones / last activity

Round Details panel now shows 'Mentor Pool: N members' (linked) instead
of an always-empty 'Jury Group' row on MENTORING rounds.

Plan: docs/superpowers/plans/2026-04-28-pr5-mentor-round-overview.md
2026-04-28 15:26:31 +02:00
Matt
f9bffabf05 feat(mentor): getRoundStats + getMentorPool procedures (§B)
- getRoundStats(roundId): totals + requested/assigned/awaiting counts +
  request-window deadline (windowOpenAt + mentoringRequestDeadlineDays) +
  workspace activity (msgs / files / milestones / lastActivityAt).
- getMentorPool({programId?}): all MENTOR-role users with current/completed
  assignment counts, capacity remaining, last activity. Drives both the
  round-overview pool card and the /admin/mentors list page.
- Tests cover empty rounds, mixed-state rounds, and capacity arithmetic.

Plan: docs/superpowers/plans/2026-04-28-pr5-mentor-round-overview.md
Spec: docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md §B
2026-04-28 15:24:07 +02:00
Matt
64668b047e chore: one-shot script to remove leaked test data from dev DB
Test runs that crash before reaching afterAll leave orphan @test.local
users + programs (Test Program / getCandidates- / bulk- / source-flag-
/ mentor-files- name patterns). Mirrors tests/helpers.ts cleanupTestData
cascade order. Idempotent — safe to re-run any time the dev DB picks up
test pollution.

Run: npx tsx scripts/cleanup-test-pollution.ts
2026-04-28 15:15:56 +02:00
Matt
2b07c12c18 feat(mentor): round-level auto-fill toolbar on Projects tab (§C)
Adds an 'Auto-fill remaining' button above ProjectStatesTable on the
MENTORING round Projects tab. Calls mentor.autoAssignBulkForRound,
respecting the round's configJson.eligibility:
  - requested_only / all_advancing: enabled, count from new
    round.getProjectsNeedingMentor query
  - admin_selected: disabled with explanatory copy

Plan: docs/superpowers/plans/2026-04-28-pr4-mentor-assignment-ux.md
2026-04-28 14:58:32 +02:00
Matt
ddae34c8f5 feat(mentor): rewrite project mentor-assignment page (§C)
Replaces single-section AI-only stub with three sections (Project Context,
Currently Assigned, Pick a Mentor). Pick a Mentor is a tab strip:
  - Manual Picker (default): all MENTOR-role users sorted by expertise
    overlap %, with search + load/capacity columns. Assign sends
    method=MANUAL.
  - AI Suggestions: existing pane, with an amber 'AI matching unavailable'
    banner + 'Tag overlap' pills when OPENAI_API_KEY is unset.

Plan: docs/superpowers/plans/2026-04-28-pr4-mentor-assignment-ux.md
2026-04-28 14:56:46 +02:00
Matt
4874491b18 feat(mentor): getCandidates + autoAssignBulkForRound procedures (§C)
- getCandidates: lists MENTOR-role users with expertise-overlap %, load,
  capacity. Drives the manual picker on /admin/projects/[id]/mentor.
- autoAssignBulkForRound: round-scoped bulk auto-fill respecting the
  round's configJson.eligibility (requested_only / all_advancing /
  admin_selected). Skips already-assigned projects.
- getSuggestions returns source: 'ai' | 'fallback' so the UI can label
  the AI tab when OPENAI_API_KEY is missing.
- Tests cover ordering, skip-already-assigned, eligibility refusal, and
  the source flag.

Plan: docs/superpowers/plans/2026-04-28-pr4-mentor-assignment-ux.md
Spec: docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md §C
2026-04-28 14:54:43 +02:00
Matt
c29410fd4e refactor(mentor): extract computeExpertiseOverlap helper (§C prep)
Pure function reused by upcoming mentor.getCandidates + AI fallback path.
Refactors getAlgorithmicMatches to call it. No behavior change.

Plan: docs/superpowers/plans/2026-04-28-pr4-mentor-assignment-ux.md
2026-04-28 14:50:50 +02:00
Matt
b867c45114 feat: Email Team button + custom-email dialog on project page
Adds a PROJECT_TEAM recipient type to the message router (resolver
returns team members + project lead) and an "Email Team" button on
the admin project detail page that opens a self-contained dialog
matching the look of /admin/messages: subject, body (pre-filled
with "Hello [Project Title] team,\n\n"), live HTML preview iframe,
"Send test to me" + "Send to N" actions.

The composer reuses the existing message.previewEmail and
message.send tRPC procedures end-to-end — no parallel email
infrastructure introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:29:42 +02:00
Matt
16156111a6 feat: complete MENTORING round config form (§A)
Surfaces every MentoringConfigSchema field on the round Config tab:

- Adds "Mentoring Request Window" card with mentoringRequestDeadlineDays
  numeric input (1-90 days, default 14) and passThroughIfNoRequest toggle
  (default ON; OFF holds projects PENDING until manual mentor assignment).
- Adds inline help-text for the Eligibility dropdown explaining each
  option's effect on auto-PASS behavior.
- Hides the General Settings card on MENTORING rounds (it only renders
  Advancement Targets, which don't apply to a pass-through round).
- Relaxes the Launch Readiness "File requirements set" gate for MENTORING
  rounds without filePromotionEnabled + a target window — file requirements
  only matter when files will be promoted to a downstream submission window.

Spec: docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md §A
Plan: docs/superpowers/plans/2026-04-28-pr3-mentoring-config-completeness.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:25:23 +02:00
Matt
2e7b545a1b feat: mentor workspace files end-to-end with secure presign
Adds generateMentorObjectKey helper producing
<projectName>/mentorship/<timestamp>-<file>. Replaces the
client-supplied bucket/objectKey on workspaceUploadFile with an
HMAC-signed upload token that binds bucket, objectKey, uploader,
and a 1h expiry — paths can no longer be forged from the client.

Adds workspaceGetUploadUrl, workspaceGetFiles,
workspaceGetFileDownloadUrl, workspaceDeleteFile procedures with
mentor-or-team-member auth. Builds <WorkspaceFilesPanel> and
wires it into the mentor workspace Files tab and the applicant
/applicant/mentor page. Replaces the file-promotion-panel mock
array with a real workspaceGetFiles query.

Tests cover token sign/verify (5), key construction (5), and
end-to-end procedure flow including auth + tampered tokens (7).

Spec: docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md §F.1
Plan: docs/superpowers/plans/2026-04-28-pr2-mentor-workspace-files.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:33:18 +02:00
Matt
dd48db5eea docs: PR 2 plan — mentor workspace files end-to-end (security + UI)
Bundle backend security (HMAC-signed upload tokens, server-built
objectKeys, mentor-or-team-member auth) with the actual file UI
that didn't exist yet (Files tab placeholder, file-promotion-panel
mock array, and applicant-side gap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:27:45 +02:00
Matt
0222da79e0 fix: filter juror preferences banner to review-round groups
The "Confirm Your Evaluation Preferences" banner was including jury
group memberships whose only rounds are LIVE_FINAL or DELIBERATION.
Those ceremonies don't use cap+category preferences, so the sliders
were meaningless. Filter getOnboardingContext to memberships in
groups with at least one INTAKE/FILTERING/EVALUATION/SUBMISSION/
MENTORING round.

Spec: docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md §E
Plan: docs/superpowers/plans/2026-04-28-pr1-jury-preferences-filter.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:08:21 +02:00
Matt
6ef0e50081 docs: PR 1 implementation plan — jury preferences filter
Step-by-step plan for §E. Single-procedure change to filter
getOnboardingContext memberships by linked-round type, plus a
new test file covering review-only, LIVE_FINAL-only, and mixed
group cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:04:29 +02:00
Matt
0c35531b87 docs: extend §D — context-aware default dashboard + standardized role switcher
After review, two additions to the multi-role UX section:

1. Replace static-priority post-login redirect with context-aware
   "go where the work is" via new user.getDefaultDashboard() — a
   juror+observer landing during an active jury round goes to /jury
   even though observer has no work; falls back to static priority
   when no role has actionable work.

2. Standardize the role switcher's location across all dashboards.
   Extract shared useRoleSwitcher hook + new RoleSwitcherPill that
   renders in the top-right of every layout, including admin (which
   currently puts switching in the bottom-left sidebar pill).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:00:20 +02:00
Matt
305b35f3a8 docs: mentor round readiness design spec
Comprehensive spec for upcoming MENTORING round (R6): config form
completeness, mentor-specific admin views, manual + auto-fill
assignment UX, multi-role juror→mentor flow, juror preferences filter,
workspace messaging/file UX with server-side path enforcement, and
test coverage. Phased into six independently-shippable PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:54:50 +02:00
206 changed files with 31964 additions and 2584 deletions

1
.gitignore vendored
View File

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

View File

@@ -6,15 +6,38 @@ MIGRATION_RETRY_DELAY_SECONDS="${MIGRATION_RETRY_DELAY_SECONDS:-2}"
ATTEMPT=1 ATTEMPT=1
# Auto-resolve any previously failed migrations so deploy can proceed. # Auto-resolve any previously failed migrations so deploy can proceed.
# This handles the case where a migration partially applied and was fixed # This handles the case where a migration failed mid-flight and was then
# in a subsequent deploy — without this, Prisma refuses to run anything. # fixed in a subsequent deploy — without this, Prisma refuses to run
# anything else (P3009).
#
# We query `_prisma_migrations` directly rather than parsing the output of
# `prisma migrate status`, because that output's wording has shifted between
# Prisma versions and any drift means failed migrations slip through and
# the container crash-loops. Truth lives in the table: a row with
# `finished_at IS NULL AND rolled_back_at IS NULL` is an unresolved failure.
echo "==> Checking for failed migrations..." echo "==> Checking for failed migrations..."
MIGRATE_STATUS=$(npx prisma migrate status 2>&1 || true) RESOLVE_ATTEMPTS=0
FAILED=$(echo "$MIGRATE_STATUS" | sed -n 's/.*The `\([^`]*\)` migration.*failed.*/\1/p' | head -1) while [ "$RESOLVE_ATTEMPTS" -lt 5 ]; do
if [ -n "$FAILED" ]; then FAILED=$(node -e "
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient();
p.\$queryRaw\`
SELECT migration_name FROM _prisma_migrations
WHERE finished_at IS NULL AND rolled_back_at IS NULL
ORDER BY started_at ASC LIMIT 1
\`.then(r => { console.log(r[0]?.migration_name || ''); p.\$disconnect(); })
.catch(() => { console.log(''); p.\$disconnect(); });
" 2>/dev/null || echo "")
if [ -z "$FAILED" ]; then
break
fi
echo "==> Found failed migration: $FAILED — marking as rolled back..." echo "==> Found failed migration: $FAILED — marking as rolled back..."
npx prisma migrate resolve --rolled-back "$FAILED" npx prisma migrate resolve --rolled-back "$FAILED" || {
fi echo "WARNING: prisma migrate resolve failed for $FAILED"
break
}
RESOLVE_ATTEMPTS=$((RESOLVE_ATTEMPTS + 1))
done
echo "==> Running database migrations (with retry)..." echo "==> Running database migrations (with retry)..."
until npx prisma migrate deploy; do until npx prisma migrate deploy; do

View File

@@ -0,0 +1,399 @@
# PR 1 — Jury Preferences Filter (§E)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Filter the juror "Confirm Your Evaluation Preferences" banner so it only shows jury group memberships whose linked rounds include at least one review-type round (INTAKE/FILTERING/EVALUATION/SUBMISSION/MENTORING). Memberships in groups whose only rounds are LIVE_FINAL or DELIBERATION must be hidden — those ceremonies don't use cap+category preferences.
**Architecture:** Single-procedure change. `getOnboardingContext` in `src/server/routers/user.ts` adds a Prisma `juryGroup.rounds: { some: { roundType: { in: [...] } } }` filter to the `juryGroupMember.findMany` query. No schema migration. No frontend change (the banner consumes the same return shape).
**Tech Stack:** Prisma 6, tRPC 11, Vitest 4. Tests use `prisma` directly + `createCaller(userRouter, user)` from `tests/setup.ts`.
**Spec:** `docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md` §E.
---
## File map
| File | Action | Responsibility |
|------|--------|----------------|
| `src/server/routers/user.ts` (`getOnboardingContext`, lines 1395-1422) | Modify | Add `juryGroup.rounds.some` filter to membership query |
| `tests/unit/jury-preferences-filter.test.ts` | Create | Three test cases covering the filter behavior |
No new files beyond the test. No schema changes. No client change.
---
## Task 1: Orient on the current implementation
**Files:**
- Read: `src/server/routers/user.ts:1395-1422`
- Read: `src/components/jury/preferences-banner.tsx:17-62`
- Read: `prisma/schema.prisma` (lines 2249-2280 for `JuryGroup`, lines 2149-2200 for `Round`)
- [ ] **Step 1: Read the current procedure**
```bash
sed -n '1395,1425p' /Users/matt/Repos/MOPC/src/server/routers/user.ts
```
Expected: see the `getOnboardingContext: protectedProcedure.query(...)` definition that calls `prisma.juryGroupMember.findMany({ where: { userId: ctx.user.id }, include: { juryGroup: { select: ... } } })`.
- [ ] **Step 2: Confirm the JuryGroup ↔ Round relation field**
```bash
sed -n '2249,2280p' /Users/matt/Repos/MOPC/prisma/schema.prisma
```
Expected: see `model JuryGroup { ... rounds Round[] ... }`. The relation field name is **`rounds`** (plural). This is the field name we'll use in the Prisma `where` filter.
- [ ] **Step 3: Inspect the consumer to confirm return shape stays identical**
```bash
sed -n '17,62p' /Users/matt/Repos/MOPC/src/components/jury/preferences-banner.tsx
```
Expected: see that the banner reads `(ctx?.memberships ?? []).filter(m => m.selfServiceCap === null)`. We are only narrowing the rows returned — the row shape is unchanged — so the banner needs no edit.
---
## Task 2: Write the failing tests
**Files:**
- Create: `tests/unit/jury-preferences-filter.test.ts`
- [ ] **Step 1: Create the test file**
Write the file at `tests/unit/jury-preferences-filter.test.ts`:
```ts
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser, createTestProgram, createTestCompetition, createTestRound,
cleanupTestData, uid,
} from '../helpers'
import { userRouter } from '../../src/server/routers/user'
describe('user.getOnboardingContext — preferences filter excludes LIVE_FINAL/DELIBERATION-only groups', () => {
let programId: string
let competitionId: string
let juror: { id: string; email: string; role: 'JURY_MEMBER' }
let observerOnlyGroupId: string
let reviewGroupId: string
let mixedGroupId: string
const userIds: string[] = []
beforeAll(async () => {
const program = await createTestProgram({ name: `prefs-filter-${uid()}` })
programId = program.id
const competition = await createTestCompetition(programId)
competitionId = competition.id
const reviewRound = await createTestRound(competitionId, {
name: 'Review Round', slug: `review-${uid()}`, roundType: 'EVALUATION', sortOrder: 0,
})
const liveFinalRound = await createTestRound(competitionId, {
name: 'Final Round', slug: `final-${uid()}`, roundType: 'LIVE_FINAL', sortOrder: 1,
})
const deliberationRound = await createTestRound(competitionId, {
name: 'Delib Round', slug: `delib-${uid()}`, roundType: 'DELIBERATION', sortOrder: 2,
})
const reviewOnlyGroup = await prisma.juryGroup.create({
data: {
id: uid('jg-rev'), competitionId, name: 'Review Only Group',
slug: uid('rev'), defaultMaxAssignments: 30,
},
})
reviewGroupId = reviewOnlyGroup.id
const liveFinalOnlyGroup = await prisma.juryGroup.create({
data: {
id: uid('jg-fin'), competitionId, name: 'Finals Only Group',
slug: uid('fin'), defaultMaxAssignments: 10,
},
})
observerOnlyGroupId = liveFinalOnlyGroup.id
const mixedGroup = await prisma.juryGroup.create({
data: {
id: uid('jg-mix'), competitionId, name: 'Mixed Group',
slug: uid('mix'), defaultMaxAssignments: 20,
},
})
mixedGroupId = mixedGroup.id
await prisma.round.update({ where: { id: reviewRound.id }, data: { juryGroupId: reviewOnlyGroup.id } })
await prisma.round.update({ where: { id: liveFinalRound.id }, data: { juryGroupId: liveFinalOnlyGroup.id } })
const mixedReview = await createTestRound(competitionId, {
name: 'Mixed Review', slug: `mixed-rev-${uid()}`, roundType: 'EVALUATION', sortOrder: 3,
})
const mixedFinal = await createTestRound(competitionId, {
name: 'Mixed Final', slug: `mixed-fin-${uid()}`, roundType: 'LIVE_FINAL', sortOrder: 4,
})
await prisma.round.update({ where: { id: mixedReview.id }, data: { juryGroupId: mixedGroup.id } })
await prisma.round.update({ where: { id: mixedFinal.id }, data: { juryGroupId: mixedGroup.id } })
void deliberationRound // referenced for cleanup; not attached to a group in these scenarios
const u = await createTestUser('JURY_MEMBER')
userIds.push(u.id)
juror = { id: u.id, email: u.email, role: 'JURY_MEMBER' }
await prisma.juryGroupMember.createMany({
data: [
{ id: uid('jgm-rev'), juryGroupId: reviewGroupId, userId: u.id, role: 'MEMBER' },
{ id: uid('jgm-fin'), juryGroupId: observerOnlyGroupId, userId: u.id, role: 'MEMBER' },
{ id: uid('jgm-mix'), juryGroupId: mixedGroupId, userId: u.id, role: 'MEMBER' },
],
})
})
afterAll(async () => {
await cleanupTestData(programId, userIds)
})
it('returns the review-only group membership', async () => {
const caller = createCaller(userRouter, juror)
const ctx = await caller.getOnboardingContext()
const names = ctx.memberships.map((m) => m.juryGroupName).sort()
expect(names).toContain('Review Only Group')
})
it('omits the LIVE_FINAL-only group membership', async () => {
const caller = createCaller(userRouter, juror)
const ctx = await caller.getOnboardingContext()
const names = ctx.memberships.map((m) => m.juryGroupName)
expect(names).not.toContain('Finals Only Group')
})
it('keeps the mixed group (has at least one review round)', async () => {
const caller = createCaller(userRouter, juror)
const ctx = await caller.getOnboardingContext()
const names = ctx.memberships.map((m) => m.juryGroupName)
expect(names).toContain('Mixed Group')
})
it('returns hasSelfServiceOptions=true when at least one membership remains', async () => {
const caller = createCaller(userRouter, juror)
const ctx = await caller.getOnboardingContext()
expect(ctx.hasSelfServiceOptions).toBe(true)
expect(ctx.memberships.length).toBe(2)
})
})
describe('user.getOnboardingContext — juror with only LIVE_FINAL membership', () => {
let programId: string
let juror: { id: string; email: string; role: 'JURY_MEMBER' }
const userIds: string[] = []
beforeAll(async () => {
const program = await createTestProgram({ name: `prefs-only-fin-${uid()}` })
programId = program.id
const competition = await createTestCompetition(programId)
const liveFinalRound = await createTestRound(competition.id, {
name: 'Solo Final', slug: `solo-fin-${uid()}`, roundType: 'LIVE_FINAL', sortOrder: 0,
})
const liveFinalOnlyGroup = await prisma.juryGroup.create({
data: {
id: uid('jg-only-fin'), competitionId: competition.id, name: 'Solo Finals Group',
slug: uid('solo-fin'), defaultMaxAssignments: 10,
},
})
await prisma.round.update({ where: { id: liveFinalRound.id }, data: { juryGroupId: liveFinalOnlyGroup.id } })
const u = await createTestUser('JURY_MEMBER')
userIds.push(u.id)
juror = { id: u.id, email: u.email, role: 'JURY_MEMBER' }
await prisma.juryGroupMember.create({
data: { id: uid('jgm-only-fin'), juryGroupId: liveFinalOnlyGroup.id, userId: u.id, role: 'MEMBER' },
})
})
afterAll(async () => {
await cleanupTestData(programId, userIds)
})
it('returns no memberships and hasSelfServiceOptions=false', async () => {
const caller = createCaller(userRouter, juror)
const ctx = await caller.getOnboardingContext()
expect(ctx.memberships).toEqual([])
expect(ctx.hasSelfServiceOptions).toBe(false)
})
})
```
- [ ] **Step 2: Run the new tests and confirm they FAIL**
```bash
cd /Users/matt/Repos/MOPC && npx vitest run tests/unit/jury-preferences-filter.test.ts 2>&1 | tail -30
```
Expected: at least one of these failures:
- "omits the LIVE_FINAL-only group membership" → `expected [...] not to contain 'Finals Only Group'` (today the procedure returns ALL memberships, so it WILL contain that name).
- "returns no memberships and hasSelfServiceOptions=false" → `expected [{ ... 'Solo Finals Group' ... }] to equal []` (today returns the lone Finals membership).
If all four tests pass with no code change, STOP — that means the filter is already in place or the test fixtures aren't exercising the procedure correctly. Re-read Task 1 outputs.
---
## Task 3: Apply the Prisma filter
**Files:**
- Modify: `src/server/routers/user.ts` (the `findMany` call inside `getOnboardingContext`)
- [ ] **Step 1: Read the current procedure to anchor the edit**
```bash
sed -n '1397,1410p' /Users/matt/Repos/MOPC/src/server/routers/user.ts
```
Expected: lines look like
```ts
getOnboardingContext: protectedProcedure.query(async ({ ctx }) => {
const memberships = await ctx.prisma.juryGroupMember.findMany({
where: { userId: ctx.user.id },
include: {
juryGroup: {
select: {
id: true,
name: true,
defaultMaxAssignments: true,
},
},
},
})
```
- [ ] **Step 2: Add the round-type filter to the `where` clause**
Edit `src/server/routers/user.ts`. Replace the `findMany` call's `where` clause:
```ts
// before
where: { userId: ctx.user.id },
// after
where: {
userId: ctx.user.id,
juryGroup: {
rounds: {
some: {
roundType: {
in: ['INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING'],
},
},
},
},
},
```
(The `include` block stays unchanged. The `return` block stays unchanged.)
- [ ] **Step 3: Re-run the tests and confirm they all PASS**
```bash
cd /Users/matt/Repos/MOPC && npx vitest run tests/unit/jury-preferences-filter.test.ts 2>&1 | tail -30
```
Expected: 5 passing, 0 failing across the two `describe` blocks.
If any test fails:
- Re-read the procedure: did the edit save? `sed -n '1397,1415p' src/server/routers/user.ts`
- Did the relation field name change? Re-confirm via `grep "rounds " prisma/schema.prisma`
- Did the test cleanup run from a previous failed test leave stale data? Try `npx vitest run -t 'returns the review-only group membership'` in isolation.
---
## Task 4: Run the full unit suite to check for regressions
- [ ] **Step 1: Run all unit tests**
```bash
cd /Users/matt/Repos/MOPC && npx vitest run tests/unit 2>&1 | tail -20
```
Expected: all unit tests pass. The new file should appear in the output as `tests/unit/jury-preferences-filter.test.ts ... ✓`. No previously-passing test should now fail.
If any other test fails: read the failure. The most likely cause is that the Prisma filter unintentionally hides memberships from a test fixture that happens to use a jury group with no attached rounds. If so, the test fixture (not our change) is the problem — flag it and fix the fixture to attach a review-type round.
---
## Task 5: Run typecheck
- [ ] **Step 1: Run the project typecheck**
```bash
cd /Users/matt/Repos/MOPC && npm run typecheck 2>&1 | tail -10
```
Expected: `tsc --noEmit` exits with code 0, no output.
---
## Task 6: Commit
- [ ] **Step 1: Stage the changes**
```bash
cd /Users/matt/Repos/MOPC && git add src/server/routers/user.ts tests/unit/jury-preferences-filter.test.ts
```
- [ ] **Step 2: Verify staged diff is what we expect**
```bash
cd /Users/matt/Repos/MOPC && git diff --cached --stat
```
Expected:
```
src/server/routers/user.ts | ~10 +-
tests/unit/jury-preferences-filter.test.ts | ~140 ++++
2 files changed, ~150 insertions(+), ~3 deletions(-)
```
(Numbers approximate. If anything else is staged, unstage it: `git restore --staged <unwanted-file>`.)
- [ ] **Step 3: Commit**
```bash
cd /Users/matt/Repos/MOPC && git commit -m "$(cat <<'EOF'
fix: filter juror preferences banner to review-round groups
The "Confirm Your Evaluation Preferences" banner was including jury
group memberships whose only rounds are LIVE_FINAL or DELIBERATION.
Those ceremonies don't use cap+category preferences, so the sliders
were meaningless. Filter getOnboardingContext to memberships in
groups with at least one INTAKE/FILTERING/EVALUATION/SUBMISSION/
MENTORING round.
Spec: docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md §E
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
- [ ] **Step 4: Verify clean status**
```bash
cd /Users/matt/Repos/MOPC && git status --short && git log -1 --oneline
```
Expected: empty status, latest commit is the one just created.
---
## Acceptance criteria
- [ ] `npx vitest run tests/unit/jury-preferences-filter.test.ts` → 5 pass
- [ ] `npx vitest run tests/unit` → no regressions
- [ ] `npm run typecheck` → no errors
- [ ] Commit message references §E of the spec
- [ ] No frontend changes
- [ ] No Prisma migration files changed
## Out of scope (verified)
- The `preferences-banner.tsx` component is NOT modified — the return shape from `getOnboardingContext` is unchanged, only the row count differs.
- Existing tests are NOT modified — the change is additive.
- Prisma schema is NOT touched.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
# PR 3 — MENTORING Round Config Completeness (§A)
> **For agentic workers:** Use superpowers:executing-plans for inline execution.
**Goal:** Surface every `MentoringConfigSchema` field on the round Config tab; hide the empty General Settings card on MENTORING rounds; relax the "File requirements set" Launch Readiness gate when no file promotion is configured.
**Architecture:** UI-only changes. No schema, no API. Three files touched.
**Spec:** `docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md` §A.
## File map
| File | Action | Why |
|------|--------|-----|
| `src/components/admin/rounds/config/mentoring-config.tsx` | Modify | Add `mentoringRequestDeadlineDays` numeric input + `passThroughIfNoRequest` toggle; add help-text to Eligibility |
| `src/app/(admin)/admin/rounds/[roundId]/page.tsx` | Modify | Hide General Settings card when `round.roundType === 'MENTORING'`; relax File-requirements readiness gate for MENTORING rounds without file promotion configured |
## Tasks
### Task 1: Add the two missing inputs to `mentoring-config.tsx`
- [ ] **Step 1: Patch the file** — append a new "Mentoring Request Window" card BETWEEN the existing two cards, and add help-text to Eligibility. Code in execution.
- [ ] **Step 2: Typecheck**`npm run typecheck`. Expect 0 errors.
### Task 2: Hide General Settings card + relax readiness on MENTORING rounds
- [ ] **Step 1: Patch `(admin)/admin/rounds/[roundId]/page.tsx`** — wrap the General Settings card in `{!isMentoring && (...)}` and extend the file-requirements bypass condition.
- [ ] **Step 2: Typecheck + build** — confirm clean.
### Task 3: Smoke + commit
- [ ] **Step 1: `npm run build`** — confirm clean.
- [ ] **Step 2: Commit** — message references §A.
## Out of scope
Form unit tests (heavy render setup; existing config-save mutation already verified by other PRs). Manual smoke covers the UI work.

View File

@@ -0,0 +1,269 @@
# PR 4: Visa Tracking Implementation Plan
> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Track the visa-application lifecycle for grand-finale attendees who need a visa, without ever holding sensitive documents (passport scans, visa letters). Documents continue to flow over email; the platform records process metadata only.
**Architecture:** A new `VisaApplication` model 1:1 with `AttendingMember`, auto-created when `needsVisa=true` flips on. Compact 5-stage status enum. A `Program.visaStatusVisibleToMembers` toggle gates whether teams see their own status. Auto-create / auto-cleanup runs in the same writes as `confirm` / `adminConfirm` / `editAttendees` so the lifecycle stays in sync.
**Tech Stack:** Prisma 6 (additive migration), tRPC (router additions to `logistics` + `applicant`), Vitest 4 for TDD, shadcn/ui for the admin tab + member badge.
---
## Task 1: Schema migration (additive)
**Files:**
- Modify: `prisma/schema.prisma`
- Create: `prisma/migrations/<timestamp>_add_visa_tracking/migration.sql`
- [ ] **Step 1: Add the enum + model + program toggle**
```prisma
enum VisaStatus {
NOT_NEEDED
REQUESTED
INVITATION_SENT
APPOINTMENT_BOOKED
GRANTED
DENIED
}
model VisaApplication {
id String @id @default(cuid())
attendingMemberId String @unique
status VisaStatus @default(REQUESTED)
nationality String? // self-declared, optional
invitationSentAt DateTime?
appointmentAt DateTime?
decisionAt DateTime? // GRANTED or DENIED date
notes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
@@index([status])
}
```
Add the back-reference on `AttendingMember`:
```prisma
visaApplication VisaApplication?
```
Add to `Program`:
```prisma
visaStatusVisibleToMembers Boolean @default(true)
```
- [ ] **Step 2: Generate migration with `--create-only`** and inspect the SQL for any non-additive operations (DROP COLUMN / ALTER COLUMN / RENAME). Should be only `CREATE TYPE`, `CREATE TABLE`, `ALTER TABLE Program ADD COLUMN`, foreign keys.
Run: `npx prisma migrate dev --name add_visa_tracking --create-only`
Then: read migration SQL, verify it's safe.
- [ ] **Step 3: Apply migration + regenerate client**
Run: `npx prisma migrate dev` (apply pending) and `npx prisma generate`.
- [ ] **Step 4: Commit**.
---
## Task 2: Auto-create / cleanup VisaApplication on attendee writes (TDD)
**Files:**
- Modify: `src/server/routers/finalist.ts` (`confirm`, `adminConfirm`, `editAttendees`)
- Create: `tests/unit/visa-application-lifecycle.test.ts`
- [ ] **Step 1: Write failing tests**
```ts
describe('VisaApplication lifecycle', () => {
it('public confirm creates a VisaApplication for each needsVisa=true attendee', async () => {
// setup: PENDING confirmation, 2 team members
// call confirm with both attending, visaFlags { lead: false, member: true }
// assert: 1 VisaApplication with status=REQUESTED for member
})
it('adminConfirm creates a VisaApplication for each needsVisa=true attendee', async () => {
// same as above but via adminConfirm
})
it('editAttendees creates a VisaApplication when an attendee flips to needsVisa=true', async () => {
// setup: CONFIRMED with 1 attendee (lead) needsVisa=false, no VisaApp
// call editAttendees with same attendees but visaFlags { lead: true }
// assert: 1 VisaApplication for lead
})
it('editAttendees deletes the VisaApplication when an attendee flips to needsVisa=false', async () => {
// setup: CONFIRMED with 1 attendee needsVisa=true + VisaApp exists
// call editAttendees same roster but visaFlags { lead: false }
// assert: 0 VisaApplications
})
it('editAttendees preserves an existing VisaApplication when needsVisa stays true', async () => {
// setup: VisaApp with notes='abc', status=APPOINTMENT_BOOKED
// call editAttendees same roster + visaFlags unchanged
// assert: VisaApp unchanged (notes still 'abc', status still APPOINTMENT_BOOKED)
})
it('removing an attendee cascades the VisaApplication', async () => {
// setup: CONFIRMED with 2 attendees, both needsVisa=true with VisaApp rows
// call editAttendees roster of just the lead
// assert: only 1 VisaApp left (for lead)
})
})
```
- [ ] **Step 2: Run tests, expect 6 failures**.
- [ ] **Step 3: Wire auto-create in `confirm` (public)**
After the existing `attendingMember.createMany`, add a follow-up createMany for any user with `visaFlags[uid] === true`:
```ts
// inside the same $transaction
ctx.prisma.visaApplication.createMany({
data: input.attendingUserIds
.filter((uid) => input.visaFlags[uid] === true)
.map((uid) => /* will need attendingMemberId — use a separate post-tx pass */),
})
```
Note: `createMany` won't have the `attendingMemberId` until after the AttendingMember rows are created. Easiest: run the visa creation in a follow-up step (after the transaction commits) by re-querying the attendee rows. The transaction guarantees the attendees are committed; if the visa step fails the worst case is missing visa rows that the admin can manually re-create — but to be safer wrap both in a single Prisma transaction using `$transaction(async (tx) => {...})` callback form.
- [ ] **Step 4: Wire auto-create in `adminConfirm`** — same pattern.
- [ ] **Step 5: Wire diff-aware sync in `editAttendees`**
After the existing diff transaction, derive the desired `needsVisa=true` set, the existing VisaApplication set, and:
- Create rows for new needsVisa=true attendees with no VisaApp
- Delete rows for attendees whose needsVisa flipped to false (or whose AttendingMember was deleted — already cascaded)
- Leave alone rows where needsVisa stays true (preserves notes / status)
- [ ] **Step 6: Run tests, expect green**.
- [ ] **Step 7: Commit**.
---
## Task 3: Admin visa CRUD procedures (TDD)
**Files:**
- Modify: `src/server/routers/logistics.ts` (add 3 procedures)
- Create: `tests/unit/visa-admin.test.ts`
- [ ] **Step 1: Write failing tests**
```ts
describe('logistics.listVisaApplications', () => {
it('returns rows joined with project + attendee for the program, sorted by status priority', async () => {
// 3 visa apps: GRANTED, REQUESTED, APPOINTMENT_BOOKED
// expect order: REQUESTED, INVITATION_SENT (none), APPOINTMENT_BOOKED, GRANTED
})
})
describe('logistics.updateVisaApplication', () => {
it('updates status + dates + notes + nationality', async () => {
// setup: REQUESTED app
// update -> APPOINTMENT_BOOKED + appointmentAt + notes
// assert: row updated, audit log VISA_UPDATE written
})
it('rejects an unknown application id', async () => {
// expect throw /not found/i
})
})
describe('logistics.setVisaVisibility', () => {
it('flips Program.visaStatusVisibleToMembers', async () => {
// default true -> set false -> verify
})
})
```
- [ ] **Step 2: Implement the three procedures** in `logistics.ts`.
- [ ] **Step 3: Run tests, expect green**.
- [ ] **Step 4: Commit**.
---
## Task 4: Member visa query (TDD)
**Files:**
- Modify: `src/server/routers/applicant.ts`
- Modify: `tests/unit/visa-admin.test.ts` (add a describe block) OR new file `tests/unit/visa-member-visibility.test.ts`
- [ ] **Step 1: Write failing tests**
```ts
describe('applicant.getMyVisaApplications', () => {
it('returns the caller-team visa apps when toggle is true', async () => {
// setup: program toggle=true, member with VisaApp
// assert: returns array with that app
})
it('returns null when toggle is false', async () => {
// assert: returns null
})
it('returns empty array when caller has no visa apps', async () => {
// assert: []
})
})
```
- [ ] **Step 2: Implement** — query AttendingMember rows where `userId = caller`, include VisaApplication. Return null if `program.visaStatusVisibleToMembers === false`.
- [ ] **Step 3: Commit**.
---
## Task 5: Admin Visas tab UI
**Files:**
- Modify: `src/app/(admin)/admin/logistics/page.tsx` (un-disable the Visas tab)
- Create: `src/components/admin/logistics/visas-tab.tsx`
- Create: `src/components/admin/logistics/visa-edit-dialog.tsx`
- [ ] **Step 1: Build the tab**
Table columns: Project · Member · Nationality · Status · Next date · Notes (truncated) · Actions. Status filter pills mirror the Confirmations tab. Header has a "Visible to teams" Switch wired to `setVisaVisibility`.
- [ ] **Step 2: Build the edit dialog**
Status dropdown, nationality input, three date pickers (invitation / appointment / decision), notes textarea. Save calls `updateVisaApplication`.
- [ ] **Step 3: Activate the tab** — remove the `disabled` prop and wire `<VisasTab programId={programId} />`.
- [ ] **Step 4: Live smoke** — confirm a finalist with one needsVisa attendee, open Visas tab, edit → verify persistence.
- [ ] **Step 5: Commit**.
---
## Task 6: Member visa surface on AttendingMembersCard
**Files:**
- Modify: `src/components/applicant/attending-members-card.tsx`
- [ ] **Step 1: Wire the query**
Call `applicant.getMyVisaApplications` alongside the existing confirmation query. If the result is null (toggle off), don't render any visa info. Otherwise, render a small status badge + the next upcoming date next to each attendee whose `needsVisa=true`.
- [ ] **Step 2: Live smoke** — toggle visibility off in the admin tab, confirm member can no longer see status.
- [ ] **Step 3: Commit**.
---
## Task 7: Final verification
- [ ] **Step 1: Full vitest**`npx vitest run`. Expect 148 + new tests, all green.
- [ ] **Step 2: Typecheck**`npm run typecheck`.
- [ ] **Step 3: Build**`npm run build`.
- [ ] **Step 4: Live smoke** end-to-end — confirm an attendee with visa, edit status as admin, verify member view, toggle off, verify hidden.

View File

@@ -0,0 +1,142 @@
# PR 5: Settings Consolidation Implementation Plan
> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Consolidate per-edition logistics configuration onto `/admin/settings` so admins find every knob in one place. Remove dead Logistics tabs and move the visa-visibility toggle out of the Visas tab.
**Architecture:** A new "Edition" tab on `/admin/settings` reads + writes a merged view: `Program` fields (defaultAttendeeCap, visaStatusVisibleToMembers) + the LIVE_FINAL round's `configJson` (attendeeEditCutoffHours, confirmationWindowHours). One reader (`program.getEditionSettings`) + one writer (`program.updateEditionSettings`) so the UI stays simple.
**Tech Stack:** Prisma 6 (no migration — purely a UX layer over existing fields), tRPC, Vitest 4 for the procedure tests, shadcn/ui for the tab + sub-sections.
---
## Task 1: tRPC procedures for edition settings (TDD)
**Files:**
- Modify: `src/server/routers/program.ts`
- Create: `tests/unit/program-edition-settings.test.ts`
- [ ] **Step 1: Failing tests**
```ts
describe('program.getEditionSettings', () => {
it('returns the merged view (program fields + LIVE_FINAL round config)', async () => {
// setup: program with defaultAttendeeCap=5, visaStatusVisibleToMembers=false
// + LIVE_FINAL round with configJson { attendeeEditCutoffHours: 24, confirmationWindowHours: 12 }
// assert: shape { defaultAttendeeCap, visaStatusVisibleToMembers, attendeeEditCutoffHours, confirmationWindowHours }
})
it('falls back to defaults when LIVE_FINAL round has no config', async () => {
// assert: attendeeEditCutoffHours = 48, confirmationWindowHours = 24
})
it('returns nulls for round-derived fields if no LIVE_FINAL round exists', async () => {
// assert: attendeeEditCutoffHours = null, confirmationWindowHours = null
})
})
describe('program.updateEditionSettings', () => {
it('writes program fields + round configJson + audit-logs', async () => {
// call with partial { defaultAttendeeCap: 4, attendeeEditCutoffHours: 36 }
// assert: program.defaultAttendeeCap=4, round.configJson.attendeeEditCutoffHours=36
// assert: AuditLog action=PROGRAM_EDITION_SETTINGS_UPDATE
})
it('preserves untouched configJson keys', async () => {
// round.configJson initially { foo: 'bar', attendeeEditCutoffHours: 48 }
// call with { attendeeEditCutoffHours: 24 }
// assert: round.configJson = { foo: 'bar', attendeeEditCutoffHours: 24 }
})
})
```
- [ ] **Step 2: Run failing tests**.
- [ ] **Step 3: Implement getEditionSettings**
```ts
getEditionSettings: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const program = await ctx.prisma.program.findUniqueOrThrow({
where: { id: input.programId },
select: { id: true, defaultAttendeeCap: true, visaStatusVisibleToMembers: true },
})
const round = await ctx.prisma.round.findFirst({
where: { competition: { programId: input.programId }, roundType: 'LIVE_FINAL' },
orderBy: { sortOrder: 'desc' },
select: { id: true, configJson: true },
})
const cfg = (round?.configJson ?? {}) as Record<string, unknown>
return {
programId: program.id,
defaultAttendeeCap: program.defaultAttendeeCap,
visaStatusVisibleToMembers: program.visaStatusVisibleToMembers,
liveFinalRoundId: round?.id ?? null,
attendeeEditCutoffHours: round
? ((cfg.attendeeEditCutoffHours as number | undefined) ?? 48)
: null,
confirmationWindowHours: round
? ((cfg.confirmationWindowHours as number | undefined) ?? 24)
: null,
}
}),
```
- [ ] **Step 4: Implement updateEditionSettings** with merge semantics on configJson + audit log.
- [ ] **Step 5: Run tests, expect green**.
- [ ] **Step 6: Commit**.
---
## Task 2: Edition Settings tab UI
**Files:**
- Create: `src/components/admin/settings/edition-settings-tab.tsx`
- Modify: `src/components/settings/settings-content.tsx` (add the new tab + entry)
- [ ] **Step 1: Build the Edition Settings tab**
Three sub-sections (Card per section):
1. **Grand-finale logistics**`defaultAttendeeCap` (number input), `attendeeEditCutoffHours` (number input with "hours before LIVE_FINAL" hint), `confirmationWindowHours` (number input with "hours from email send" hint).
2. **Visa**`visaStatusVisibleToMembers` Switch + caption.
3. **Coming soon** — placeholder for Lunch + Email Templates that PRs 6 + 7 will fill.
Each input fires `program.updateEditionSettings.useMutation` debounced or on-blur. Toast on success.
- [ ] **Step 2: Wire into `/admin/settings`** — add `<TabsTrigger value="edition">` and `<TabsContent value="edition">` in settings-content. Place before existing tabs.
- [ ] **Step 3: Live smoke** — open settings, verify it renders, toggle visa visibility, verify it persists in the DB and the applicant dashboard hides the surface.
- [ ] **Step 4: Commit**.
---
## Task 3: Cleanup — remove dead Logistics tabs + visibility toggle
**Files:**
- Modify: `src/app/(admin)/admin/logistics/page.tsx`
- Modify: `src/components/admin/logistics/visas-tab.tsx`
- [ ] **Step 1: Remove disabled tabs**
Drop the `<TabsTrigger value="documents" disabled>` and `<TabsTrigger value="settings" disabled>` blocks. Also drop their unused imports (`FileText`, `Settings`).
- [ ] **Step 2: Replace visibility toggle with a hint**
In `visas-tab.tsx`, swap the inline Switch + Label for a small banner: "Configure visibility in Settings → Edition" with a Link to `/admin/settings?tab=edition`. Drop the `getVisaVisibility` query and `setVisaVisibility` mutation usage from this component (keep the procedures — used by the new settings page).
- [ ] **Step 3: Live smoke** — confirm Logistics tab bar shows only the active tabs, visas tab no longer has a toggle, settings page does.
- [ ] **Step 4: Commit**.
---
## Task 4: Final verification
- [ ] **Step 1: Full vitest**`npx vitest run`. Expect 161 + new tests (~5).
- [ ] **Step 2: Typecheck** — clean.
- [ ] **Step 3: Build** — clean.
- [ ] **Step 4: E2E smoke** — open `/admin/settings` → Edition tab, change knobs, verify persistence; confirm the Visas tab no longer shows the toggle and the hint links to settings.

View File

@@ -0,0 +1,182 @@
# PR 7 — "Email Team" Modal on Project Detail Page
> **For agentic workers:** Use superpowers:executing-plans for inline execution.
**Goal:** Add an "Email Team" button to `/admin/projects/[id]` that opens a modal composing a custom email to that project's team. Same look + features as `/admin/messages` (subject, body, live HTML preview, send-to-all). Body pre-filled with `Hello [Project Title] team,\n\n` so admin can edit and add their custom content beneath.
**Architecture:** Adds `PROJECT_TEAM` to the existing `RecipientType` enum (5 places) + a resolver branch. New self-contained `<ProjectEmailDialog>` component reuses the existing `message.previewEmail` and `message.send` procedures. No template-system rewrite.
**Spec:** New ask added during PR review (2026-04-28). Not in original spec but adjacent to §B (admin views).
## File map
| File | Action | Why |
|------|--------|-----|
| `src/server/routers/message.ts` | Modify | Add `PROJECT_TEAM` to 4 input enums + 1 resolver case |
| `src/components/admin/project-email-dialog.tsx` | Create | Self-contained modal with subject + body + live preview + send |
| `src/app/(admin)/admin/projects/[id]/page.tsx` | Modify | Add "Email Team" button next to Edit button; render the dialog |
| `tests/unit/message-recipient-project-team.test.ts` | Create | Resolver returns team members + submittedByUserId |
## Tasks
### Task 1: Backend — `PROJECT_TEAM` recipient type
- [ ] **Step 1: Write failing test**
```ts
// tests/unit/message-recipient-project-team.test.ts
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser, createTestProgram, createTestProject, cleanupTestData, uid,
} from '../helpers'
import { messageRouter } from '../../src/server/routers/message'
describe('message.previewRecipients — PROJECT_TEAM', () => {
let programId: string
let admin: { id: string; email: string; role: 'SUPER_ADMIN' }
let projectId: string
const userIds: string[] = []
beforeAll(async () => {
const program = await createTestProgram({ name: `proj-team-${uid()}` })
programId = program.id
const lead = await createTestUser('APPLICANT')
userIds.push(lead.id)
const project = await createTestProject(programId, { title: 'TestProj' })
projectId = project.id
await prisma.project.update({ where: { id: projectId }, data: { submittedByUserId: lead.id } })
const member1 = await createTestUser('APPLICANT')
const member2 = await createTestUser('APPLICANT')
userIds.push(member1.id, member2.id)
await prisma.teamMember.createMany({
data: [
{ id: uid('tm'), projectId, userId: member1.id, role: 'MEMBER' },
{ id: uid('tm'), projectId, userId: member2.id, role: 'MEMBER' },
],
})
const a = await createTestUser('SUPER_ADMIN')
userIds.push(a.id)
admin = { id: a.id, email: a.email, role: 'SUPER_ADMIN' }
})
afterAll(async () => {
await cleanupTestData(programId, userIds)
})
it('counts the lead + 2 team members', async () => {
const caller = createCaller(messageRouter, admin)
const result = await caller.previewRecipients({
recipientType: 'PROJECT_TEAM',
recipientFilter: { projectId },
})
expect(result.totalApplicants).toBe(3)
})
it('returns 0 when projectId is missing', async () => {
const caller = createCaller(messageRouter, admin)
const result = await caller.previewRecipients({
recipientType: 'PROJECT_TEAM',
recipientFilter: {},
})
expect(result.totalApplicants).toBe(0)
})
})
```
- [ ] **Step 2: Run, expect FAIL**`'PROJECT_TEAM'` not in enum.
- [ ] **Step 3: Patch `src/server/routers/message.ts`**
Replace ALL FIVE enum literal lines:
```ts
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
```
with:
```ts
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'PROJECT_TEAM', 'ALL']),
```
(Use Edit's `replace_all: true` since the line is identical at all 5 occurrences.)
Add a new case in `resolveRecipients` (after `'PROGRAM_TEAM'`):
```ts
case 'PROJECT_TEAM': {
const projectId = filter?.projectId as string
if (!projectId) return []
const [teamMembers, project] = await Promise.all([
prisma.teamMember.findMany({
where: { projectId },
select: { userId: true },
}),
prisma.project.findUnique({
where: { id: projectId },
select: { submittedByUserId: true },
}),
])
const ids = new Set<string>()
for (const tm of teamMembers) ids.add(tm.userId)
if (project?.submittedByUserId) ids.add(project.submittedByUserId)
return [...ids]
}
```
- [ ] **Step 4: Re-run, expect PASS.**
### Task 2: Build `<ProjectEmailDialog>`
- [ ] **Step 1: Create the component** (full code in execution)
Behaviour:
- Open via `open: boolean; onClose: () => void; projectId: string; projectTitle: string` props.
- On open, body field is pre-filled: ``Hello ${projectTitle} team,\n\n``. Cursor placed at the end.
- Subject field default: empty (admin types).
- Live HTML preview pane below the form, calling `message.previewEmail` with `{ subject, body }` (debounced 300ms).
- Recipient count line: calls `message.previewRecipients` with `PROJECT_TEAM` + `{ projectId }`. Shows "Will email N team members."
- "Send Test" button: sends to the admin only via `message.sendTest`.
- "Send" button: calls `message.send` with `recipientType: 'PROJECT_TEAM'`, `recipientFilter: { projectId }`, `deliveryChannels: ['EMAIL']`, `linkType: 'NONE'`.
- On success: toast + close dialog. On error: toast.
### Task 3: Wire the button on project detail page
- [ ] **Step 1: Add button next to Edit** in `src/app/(admin)/admin/projects/[id]/page.tsx`:
```tsx
<Button variant="outline" onClick={() => setEmailDialogOpen(true)}>
<Mail className="mr-2 h-4 w-4" />
Email Team
</Button>
```
(Add `Mail` to the lucide imports. Add `useState` for `emailDialogOpen`.)
Render the dialog at the bottom of the page:
```tsx
{project && (
<ProjectEmailDialog
open={emailDialogOpen}
onClose={() => setEmailDialogOpen(false)}
projectId={project.id}
projectTitle={project.title}
/>
)}
```
### Task 4: Verify + commit
- [ ] `npx vitest run tests/unit` → all pass.
- [ ] `npm run typecheck` → clean.
- [ ] `npm run build` → clean.
- [ ] Commit with message referencing PR 7.
## Out of scope
Template picker (admins can use plain body for now; integration with template system can land later). HTML editor (plain textarea — same UX as messages page composer).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,520 @@
# Mentor Round Readiness — End-to-End Design
**Date:** 2026-04-28
**Author:** Matt + Claude (brainstorming session)
**Status:** Draft, awaiting review
## Motivation
R5 (Semi-Final Evaluation) is about to close. Next is R6 (Mentoring) for projects that request or are assigned a mentor, then R7 (Grand Final). The MENTORING backend exists but has gaps that block operational use:
- Admin Config form omits two `MentoringConfigSchema` fields (`mentoringRequestDeadlineDays`, `passThroughIfNoRequest`)
- Round Overview shows generic stats only — no mentor-specific dashboard
- `/admin/projects/[id]/mentor` exposes only AI suggestions; manual mentor selection is missing entirely from the UI
- File uploads (`mentor.workspaceUploadFile`) accept client-controlled `bucket` / `objectKey` — security/consistency hole
- Juror "Confirm Your Evaluation Preferences" banner pulls in LIVE_FINAL groups (not appropriate for a live ceremony)
- Multi-role users (juror + mentor) land on primary role's dashboard only; no quick path for an admin to bulk-promote jurors
- Zero tests for MENTORING round behavior
This spec covers all of the above plus workspace messaging/file UX polish, in one design with phased PRs.
## Goals
1. Admin can fully configure a MENTORING round from the UI (no DB-direct edits needed for any `MentoringConfigSchema` field).
2. Admin can see at a glance: who requested mentoring, who has a mentor, who doesn't, who's mentoring whom, what the mentor pool looks like.
3. Admin can manually assign a mentor to any project, AND auto-fill all unassigned projects in one action.
4. Files uploaded in the mentor workspace land at `<projectName>/mentorship/<file>` in the configured bucket, with paths constructed server-side.
5. Mentors and applicant teams see recent messages on their respective dashboards.
6. A juror who is also a mentor can switch dashboards in one click, without seeing irrelevant LIVE_FINAL preference cards.
7. The MENTORING round behavior (pass-through, eligibility, advancement) is covered by integration tests.
## Non-goals
- Redesigning messaging or notifications from scratch.
- Replacing the AI mentor-matching service with a different model.
- Building a mentor scheduling/calendar feature.
- Bulk-promoting jurors to mentors via CSV import (per-row checkbox + bulk action is enough for this iteration).
- Migrating any existing mentor file objects in MinIO (none exist yet — spec asserts a pre-flight check).
## Out-of-scope but adjacent
- Grand Finale (R7 LIVE_FINAL) UX — explicitly deferred per user direction (handled separately, much further build-out planned).
- Mentor pool capacity / load-balancing algorithm changes — covered only by surfacing existing fields in the admin view.
---
## High-level architecture
No new top-level architecture. Extending existing patterns:
- **Storage path:** new helper `generateMentorObjectKey(projectTitle, fileName)` in `src/lib/minio.ts` that returns `<sanitizedProjectName>/mentorship/<timestamp>-<sanitizedFileName>` — exact same shape as `generateObjectKey()` with `roundName="mentorship"`. Server-side only.
- **Config schema:** no Prisma migration. The two missing fields (`mentoringRequestDeadlineDays`, `passThroughIfNoRequest`) already exist in `MentoringConfigSchema` and are read by `round-engine.ts` and `applicant.ts` — only the form needs updating.
- **Multi-role dashboards:** existing `User.roles UserRole[]` array drives everything; logic-only changes (post-login redirect priority, bulk-promote bulk action, fix CSS layering on impersonation banner).
- **Preferences filter:** single Prisma query change in `getOnboardingContext`.
- **Workspace dashboards:** reuse existing `MentorMessage` table; new tRPC procedures return last-N message previews.
## Phasing / PR plan
Six PRs, ordered smallest-blast-radius first:
| PR | Section | Risk | What ships |
|----|---------|------|------------|
| 1 | §E | Low | Filter `getOnboardingContext` to review-only rounds |
| 2 | §F.1 | Low | Server-side `objectKey` enforcement + `generateMentorObjectKey` helper |
| 3 | §A | Med | Config form completeness (2 missing inputs + General Settings cleanup + Launch Readiness gate relax) |
| 4 | §C | Med | Manual mentor picker + bulk auto-fill + AI fallback |
| 5 | §B | Med | Mentor-specific Round Overview + un-redirect `/admin/mentors` |
| 6 | §D + §F.2 | Med | Multi-role redirect priority + bulk-promote + impersonation banner fix + dashboard message previews |
| (continuous) | §G | Low | Tests added in each PR for the surface changing in that PR |
A standalone test PR is *not* planned — tests ride with the change they cover.
---
## §A. MENTORING round Config form
**Files:**
- `src/components/admin/round-config/mentoring-config.tsx` (likely path; locate the round-type-specific config component used by `(admin)/admin/rounds/[roundId]` Config tab)
- `src/components/admin/round-config/launch-readiness.tsx` (or similar — the component that renders the 0/3 readiness checklist)
**Changes:**
1. Add **"Mentoring Request Window"** section to the Config form:
- Numeric input bound to `configJson.mentoringRequestDeadlineDays` — int, min 1, max 90, default 14.
- Help text: "Number of days from round opening during which teams may request mentoring. After this window, no new requests are accepted."
2. Add **"Pass-through behavior"** toggle bound to `configJson.passThroughIfNoRequest`:
- Default `true` (matches schema default).
- Off-state label: "Hold all projects in PENDING until mentor is assigned (manual gate)"
- On-state label: "Auto-PASS projects that don't request mentoring (default)"
3. Replace empty **"General Settings"** section header. Either:
- Delete the empty header (preferred — fewer questions); OR
- Move the eligibility dropdown into it (so the section has content).
4. Relax Launch Readiness "File requirements set" gate for MENTORING rounds:
- Required only when `configJson.filePromotionEnabled === true` AND `configJson.promotionTargetWindowId` is set (i.e., the round is configured to promote mentor-authored files into a downstream submission window).
- Otherwise treat the readiness item as N/A and don't count it against the 0/3 (it becomes 0/2 for mentoring rounds without promotion configured).
5. Help-text added to the existing **Eligibility** dropdown explaining each option:
- `requested_only` — only projects that flag `mentoringRequested` participate (default).
- `all_advancing` — every project advancing into this round gets a mentor.
- `admin_selected` — admin manually picks which projects participate.
**Tests** (in PR 3): one per `MentoringConfigSchema` field — render with default config, change input, submit, assert config persisted via the existing config-save mutation.
---
## §B. Mentoring-specific admin views
**Files:**
- `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (Round Overview tab)
- `src/app/(admin)/admin/rounds/[roundId]/projects-tab.tsx` (Projects tab — exact filename to confirm during impl)
- `src/app/(admin)/admin/mentors/page.tsx` (currently a redirect stub — replace with a real list page)
- `src/app/(admin)/admin/mentors/[id]/page.tsx` (also a stub today; replace with mentor detail)
- New tRPC procedures on `mentor` router (admin-gated): `getRoundStats`, `getMentorPool`, `getMentorDetail`
**Round Overview — replace generic Round Details with a mentoring-specific stats card** when `round.roundType === 'MENTORING'`:
- **Top-line counts** (single row of stat cards):
- Total projects in round
- Requested mentoring (count + % of total)
- Mentor assigned (count + % of total)
- Awaiting assignment (= requested - assigned)
- **Request window** card:
- Deadline (computed from `windowOpenAt + mentoringRequestDeadlineDays`)
- Time remaining (live countdown, using existing `formatCountdown` helper)
- "Closes in N days" pill, turns amber within 48 hours, red within 12 hours
- **Mentor pool** card:
- Pool size (count of users with MENTOR role in the program)
- Average load (assigned projects ÷ pool size)
- Capacity remaining (sum of `User.maxAssignmentsOverride` minus current load, where overrides exist)
- Link → `/admin/mentors`
- **Workspace activity** card:
- Total messages exchanged (sum across all assignments in round)
- Total files uploaded
- Total milestones completed
- "Last activity" timestamp
**Round Details panel** stays at the bottom of the Overview tab when round is MENTORING (the existing panel is still useful for type/status/position/dates), but with these field-level adjustments:
- Replace "Jury Group: —" row with "Mentor Pool: N members" (link to `/admin/mentors`).
- Keep "Type", "Status", "Position", "Opens", "Closes" rows unchanged.
- The new "mentoring stats card" (top-line counts, request window, mentor pool, workspace activity) renders **above** the Round Details panel, not in place of it.
**Projects tab — when round is MENTORING**, the per-project row shows:
- Project title + team lead
- "Requested mentoring" badge (yes/no)
- "Mentor assigned" cell — mentor name + expertise overlap chip, OR "Unassigned" with inline "Assign" button → opens the manual-pick drawer (see §C)
- "Workspace activity" small-text summary (msgs / files / milestones)
- Bulk action bar (when ≥1 project selected): "Auto-fill mentors for selected" → calls `mentor.autoAssignBulk`
**`/admin/mentors` — un-redirect, replace stub with a real list page:**
- Searchable/filterable list of all users with MENTOR role in the current edition.
- Columns: name, email, country, expertise tags (chips), assigned-projects count, completed count, capacity remaining, last activity.
- Row → `/admin/mentors/[id]` detail page (existing route, replace stub):
- Mentor profile + expertise + bio
- List of assigned projects (link to per-project workspace)
- Per-project status (in_progress / completed / paused)
- Recent activity feed (messages / file uploads / milestone completions across all assignments)
- Admin actions: reassign / unassign
**Tests** (in PR 5): integration test for `getRoundStats` returning correct counts; render-test for round overview when round.roundType=MENTORING.
---
## §C. Manual + auto-fill mentor assignment
**Files:**
- `src/app/(admin)/admin/projects/[id]/mentor/page.tsx` (rewrite)
- `src/server/services/mentor-matching.ts` (add expertise-tag fallback)
- `src/server/routers/mentor.ts` (`getCandidates` new procedure for manual picker; ensure `autoAssignBulk` exposes a "skip already assigned" param — confirm and document)
**Page rewrite — three sections, all visible at once (not tabs):**
1. **Project Context** card (top):
- Project title, ocean issue, country, team size, expertise needs (project tags)
- Round being assigned for (linked)
- Mentoring requested? Yes/no
2. **Currently Assigned** card:
- If assigned: mentor name, email, country, expertise overlap chips, "Assigned by [admin], 3 days ago, method: MANUAL/AUTO", actions: Unassign | Swap
- If unassigned: empty state with copy "No mentor assigned yet — pick one below or use AI"
3. **Pick a mentor** card with a tab strip:
- **Tab 1 — Manual picker** (default selected):
- Searchable input
- Sortable table of all MENTOR-role users in the program: name, expertise tags, country, current load, capacity, **expertise overlap with this project** (computed: count of shared tags / total project tags, displayed as a percentage chip)
- Default sort: highest expertise overlap first
- Per-row "Assign" button → calls `mentor.assign({ projectId, mentorId, method: 'MANUAL' })`
- **Tab 2 — AI suggestions**:
- Existing pane (loads `getSuggestions`).
- **Fallback**: if AI fails (no `OPENAI_API_KEY`, network error, or returns empty) — show expertise-tag-overlap ranking as the suggestion source instead, with a banner: "AI matching unavailable — showing expertise-tag overlap instead". (The fallback ranking is the same algorithm as Tab 1's default sort, so the lists may look similar — that's fine.)
**Auto-fill remainder** (bulk action):
- On round Projects tab + Round Overview, button: "Auto-fill mentors for unassigned projects".
- Call `mentor.autoAssignBulk` with the round ID; the service filters to projects-in-round-without-MentorAssignment, scoped further by the round's `eligibility` config:
- `requested_only` → only projects with `mentoringRequested=true`
- `all_advancing` → every project in the round
- `admin_selected` → button disabled (admins must pick manually for this mode)
- Confirm the existing service already skips projects with a MentorAssignment (any method); if it doesn't, fix in the same PR.
- Result toast: "Assigned N projects, skipped M already-assigned, K unassignable (no matching mentor)".
**Tests** (in PR 4):
- `mentor.assign` round-trips with method=MANUAL
- `mentor.autoAssignBulk` skips manually-assigned projects
- `getCandidates` returns expected expertise-overlap ordering
- Fallback path used when AI unavailable
---
## §D. Juror→mentor multi-role UX
**Files:**
- `src/app/page.tsx` (post-login redirect)
- `src/app/(admin)/admin/members/page.tsx` (bulk action)
- `src/components/layouts/role-nav.tsx` (no change — switcher already correct)
- `src/components/layouts/impersonation-banner.tsx` (or wherever the banner lives — find via grep)
- `src/server/routers/user.ts` (new `bulkUpdateRoles` mutation if not exists)
- `src/lib/email/templates/mentor-onboarding.tsx` (new)
- `src/server/services/notifications.ts` (or equivalent — call site to send mentor-onboarding email when MENTOR role is freshly added to a user)
**1. Post-login redirect — context-aware "go where the work is":**
Replace single-`role` switch in `src/app/page.tsx` with a priority list that is *filtered by actionable work*. The user lands on the highest-priority role for which they have something to do right now; if no role has active work, fall back to the static priority order.
New tRPC query: `user.getDefaultDashboard()` (server-side, called from `src/app/page.tsx`):
```ts
// Static priority — used as fallback ordering AND as the order we check for work.
const ROLE_DASHBOARD_PRIORITY: Array<[UserRole, Route]> = [
['SUPER_ADMIN', '/admin'],
['PROGRAM_ADMIN', '/admin'],
['AWARD_MASTER', '/award-master'],
['JURY_MEMBER', '/jury'],
['MENTOR', '/mentor'],
['APPLICANT', '/applicant'],
['OBSERVER', '/observer'],
['AUDIENCE', '/audience'],
]
```
For each role the user holds (in priority order), the server checks "does this user have actionable work in this role right now?":
| Role | "Has actionable work" predicate |
|------|---------------------------------|
| SUPER_ADMIN / PROGRAM_ADMIN | Always true (admin work is always present) |
| AWARD_MASTER | Any unfinalized award decision in an active round in current edition |
| JURY_MEMBER | Any `JuryAssignment` linked to a round whose `status = ROUND_ACTIVE` AND the user has at least one PENDING evaluation |
| MENTOR | Any `MentorAssignment` whose linked round is `ROUND_ACTIVE` AND `workspaceEnabled = true` |
| APPLICANT | Any `Project` led by user with at least one `ProjectRoundState` in a non-terminal state in an active round |
| OBSERVER | Always false (observers have nothing to act on) |
| AUDIENCE | Always false |
Algorithm:
1. Try roles in priority order. Return the first role whose predicate is true.
2. If no role has actionable work, return the highest-priority role the user holds (static fallback).
3. Always end with a non-null route (worst case: any signed-in user has at least their primary role).
**Why this matters (your example):** a juror+observer who logs in during an open jury round lands on `/jury` (because they have a pending evaluation), not `/observer`. A mentor+juror logs in during an active MENTORING round → `/mentor`. After both rounds close, same user logs in → static fallback (jury > mentor) → `/jury`. The role switcher in the user menu is always available to override.
**Decision: context-aware, not "remember last view".** "Remember last view" requires a new column and surprises users when their last context disappears (round closed, role removed). Context-aware is deterministic, explains itself, and handles the cross-role overlap cleanly. The role switcher dropdown is the user's escape hatch.
**Tests** (in PR 6):
- Juror with pending evaluation in active round + Observer → `/jury`
- Juror with no active assignments + Observer → `/jury` (fallback to static priority)
- Mentor+Juror, MENTORING round active, no jury work → `/mentor`
- Mentor+Juror, both rounds active with work in both → `/jury` (priority order breaks the tie)
- Observer-only user → `/observer`
- Multi-role with no active work anywhere → static-priority fallback
**2. Bulk juror→mentor promotion** on `/admin/members`:
- Add row checkboxes to the Members table (already a table — confirm during impl).
- When ≥1 row selected, surface a bulk action toolbar with "Add role…" dropdown (OBSERVER / MENTOR / AWARD_MASTER) and "Remove role…".
- Call new `user.bulkUpdateRoles({ userIds, addRole?, removeRole? })` mutation. Server-side: only SUPER_ADMIN/PROGRAM_ADMIN, log a `DecisionAuditLog` entry per user changed.
- After success, refresh the table and toast "Added MENTOR role to N users; M already had it (no-op)".
**3. Mentor-onboarding email** (one-shot):
- New email template at `src/lib/email/templates/mentor-onboarding.tsx`: brief welcome, explanation of mentor responsibilities, link to `/mentor`, link to "Switch View" doc/walkthrough.
- Trigger: in `user.bulkUpdateRoles` and the existing single-user `updateRoles` mutation, when MENTOR is **newly** added (i.e., wasn't in `roles[]` before this update) → enqueue the email. Idempotent on subsequent edits that keep MENTOR in `roles`.
- Add a `User.mentorOnboardingSentAt: DateTime?` column for idempotency. Migration: nullable column, no backfill needed.
**4. Fix impersonation banner pointer-events:**
- Locate the banner component (grep `Impersonating` / `bg-red-600 fixed top-0`).
- Restructure: banner sits in a flex container above the header rather than being `position: fixed` over it. The header height stays unchanged; the banner pushes content down.
- Alternative (smaller change): keep `position: fixed` but `pointer-events: none` on the banner div and re-enable `pointer-events: auto` on the inner "Return to Admin" button only. Either fixes the menu intercept.
- Pick the simpler diff at impl time; document choice in PR.
**5. Banner shows all roles:**
- When `session.user.roles.length > 1`, render comma-separated list: "Impersonating Dr. Sophie Laurent (JURY MEMBER, MENTOR)".
**6. Standardize the role-switcher (location + presentation):**
Today's state:
- Header layouts (`role-nav.tsx`) — used by jury, mentor, applicant, observer, award-master — put the user menu **top-right** with role-switcher items inside the dropdown.
- Admin layout (`admin-sidebar.tsx`) puts the user menu **bottom-left of the sidebar** with its own duplicate `ROLE_SWITCH_OPTIONS` constant + `switchableRoles` filter (lines 161, 191, 377-401).
Two problems: (a) duplicated logic across two files; (b) different physical placement, so a multi-role user has to learn two patterns to find "Switch View".
Changes:
- **Extract a shared module** at `src/components/layouts/role-switcher.tsx` exporting:
- `useRoleSwitcher()` hook returning `{ switchableRoles: Array<{ label, path, icon }>, currentBasePath }`. Both `role-nav.tsx` and `admin-sidebar.tsx` import this. Source of truth for `ROLE_SWITCH_OPTIONS` lives here only.
- `RoleSwitcherMenuItems` component — renders the dropdown items (used inside both layouts' user menus). Keeps rendering inline-consistent.
- `RoleSwitcherPill` component — a standalone visible button that renders just outside the user-menu dropdown, with label "Switch View" + the icon of the next-best alternate role. Visible only when `switchableRoles.length > 0`. Click opens a small popover listing alternates.
- **Place the `RoleSwitcherPill` in a consistent location across all layouts**: top-right of the header, immediately to the LEFT of the notifications bell. For the admin layout (sidebar-based), add a top-right header strip that hosts the pill + notifications + theme toggle, mirroring the other dashboards. (The admin sidebar keeps everything else; just the top-bar is added.)
Why top-right: that's where the existing role-nav layouts already put switching/profile actions. Admins gain the pill in the same spot — no learning curve when switching from /admin to /jury.
- **Pill behavior:**
- Hidden if `switchableRoles.length === 0` (single-role users see nothing — clean default).
- Hidden when `isImpersonating` (impersonator UX is already different; the existing impersonation banner with "Return to Admin" handles role-switching for that path).
- On hover/focus: shows tooltip "Switch dashboard view".
- Keyboard: `Cmd+Shift+V` shortcut opens the popover (nice-to-have; ship if it doesn't add much code).
- **Admin sidebar bottom user pill stays** (so admin users can still sign out / open settings from there). The role-switcher items are removed from that menu — they live exclusively in the new pill + the user-dropdown's switch list. (Avoids three places to switch view.)
**Acceptance for §D.6:** any signed-in user with `roles.length > 1` sees a "Switch View" pill in the same screen position regardless of which dashboard they're currently in.
**Tests** (in PR 6):
- `user.getDefaultDashboard` test cases enumerated above (juror+observer with active jury round → /jury; etc.).
- `bulkUpdateRoles` adds MENTOR to N users and sends N onboarding emails.
- Idempotency: second `bulkUpdateRoles` with same input does NOT resend email.
- Impersonation banner does not intercept clicks on user dropdown (Playwright e2e if available).
- `RoleSwitcherPill` renders in the top-right of every dashboard for multi-role users; renders nothing for single-role users.
- Single shared `useRoleSwitcher` source means changing `ROLE_SWITCH_OPTIONS` updates both layouts simultaneously.
---
## §E. Filter juror preferences to review-only rounds (PR 1)
**File:** `src/server/routers/user.ts:1397-1422` (`getOnboardingContext`)
**Change:** Query the membership's jury group, including its linked rounds. Filter out memberships where every linked round is LIVE_FINAL or DELIBERATION. Keep memberships where at least one linked round is INTAKE / FILTERING / EVALUATION / SUBMISSION / MENTORING.
```ts
const memberships = await ctx.prisma.juryGroupMember.findMany({
where: {
userId: ctx.user.id,
juryGroup: {
rounds: {
some: {
roundType: {
in: ['INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING'],
},
},
},
},
},
include: { juryGroup: { select: { id: true, name: true, defaultMaxAssignments: true } } },
})
```
(Confirm the relation field name `rounds` on `JuryGroup` during impl — Prisma schema field may be `Round[]` named differently.)
**Tests** (in PR 1):
- Juror with memberships in (Screening: FILTERING) + (Finals: LIVE_FINAL) → only Screening returned.
- Juror with memberships in (Mixed: EVALUATION + LIVE_FINAL) → returned (group has at least one review round).
- Juror with only (Finals: LIVE_FINAL) → no memberships returned.
**Risk:** very low. Single procedure, additive Prisma filter, easy to revert.
---
## §F. Workspace messaging + files end-to-end
### §F.1 — Server-side path enforcement (PR 2)
**Files:**
- `src/lib/minio.ts` (add helper)
- `src/server/routers/mentor.ts` (`workspaceUploadFile` procedure + presign procedure)
- `src/server/services/mentor-workspace.ts` (`uploadFile` service)
**New helper** in `src/lib/minio.ts`:
```ts
export function generateMentorObjectKey(projectTitle: string, fileName: string): string {
return generateObjectKey(projectTitle, fileName, 'mentorship')
}
```
This produces `<sanitizedProjectName>/mentorship/<timestamp>-<sanitizedFileName>`, matching the existing project-file scheme.
**Procedure changes:**
1. Add a presign procedure (if not present): `mentor.presignWorkspaceUpload({ mentorAssignmentId, fileName, mimeType, size })`
- Loads the `MentorAssignment` + linked `Project` (server-side).
- Authorizes: user is the assigned mentor OR a project team member (mentorProcedure for mentors; protectedProcedure with project-team check for applicants).
- Constructs `objectKey = generateMentorObjectKey(project.title, fileName)`.
- Returns `{ uploadUrl, bucket, objectKey }` — the presigned PUT URL is short-lived (1h).
2. Change `workspaceUploadFile` to accept ONLY `{ uploadToken, description? }` (where `uploadToken` is an opaque value returned by the presign call). The presign procedure stores `{ token → { mentorAssignmentId, fileName, mimeType, size, bucket, objectKey } }` in a short-lived cache (in-memory or Redis if configured, 1h TTL). The upload procedure looks up the token, validates that the user is the same one who called presign, then writes the `MentorFile` row using the cached values. This eliminates any client-controlled path entirely.
3. Mirror the same change for applicant-side uploads to mentor workspace (if a separate procedure exists).
**Migration:** Pre-flight — confirm `MentorFile` table is empty (or only test data) in production. If it has any rows, migrate `objectKey`s to the new scheme via a one-shot script; otherwise skip migration.
**Tests** (in PR 2):
- Presign returns key matching `<projectName>/mentorship/<timestamp>-<file>` shape.
- `workspaceUploadFile` rejects payloads that include `bucket` or `objectKey` (input schema rejects unknown fields via Zod).
- Authorization: mentor uploading to a workspace they're NOT assigned to → throws TRPCError UNAUTHORIZED.
### §F.2 — Dashboard message previews (PR 6)
**Files:**
- New component: `src/components/mentor/recent-messages-card.tsx`
- New component: `src/components/applicant/mentor-conversation-card.tsx`
- `src/app/(mentor)/mentor/page.tsx` — embed RecentMessagesCard
- `src/app/(applicant)/applicant/page.tsx` — embed MentorConversationCard (only render when project has mentorAssignment + workspace enabled)
- `src/server/routers/mentor.ts` — new procedure `getRecentMessagesForMentor` (returns last N msgs across all assignments)
- `src/server/routers/applicant.ts` — new procedure `getMentorConversationPreview({ projectId })` (returns last 3 msgs + unread count for one project)
**Mentor dashboard preview**:
- Card title: "Recent Messages"
- Shows last 5 unread messages across ALL assignments (sender name + project + first 100 chars + relative timestamp).
- Each row links to `/mentor/workspace/<projectId>` (jumps to that conversation).
- "View all" link → `/mentor/messages` (existing or new index — confirm during impl).
- Empty state: "No new messages. Your mentees will appear here when they reach out."
**Applicant dashboard preview** (only when project has assigned mentor + workspace enabled):
- Card title: "Conversation with [Mentor Name]"
- Shows last 3 messages (sender name + content + timestamp).
- Unread count badge.
- "Send a message" inline composer or "Open chat" button → `/applicant/mentor`.
- Empty state: "Say hi to your mentor — they're here to help you sharpen your project."
**Performance:** both queries use indexed lookups on `MentorMessage(workspaceId, createdAt)`. Add an index migration if not present.
**Tests** (in PR 6):
- `getRecentMessagesForMentor` returns N most-recent unread messages across assignments.
- `getMentorConversationPreview` returns 3 most-recent messages + correct unread count.
- Renders gracefully when no assignment / no messages.
### §F.3 — End-to-end verification scenario (covered in §G)
A single integration test walking through the full happy path. See §G.
---
## §G. Tests
**New test files:**
- `tests/unit/mentor-config.test.ts` (PR 3) — Config form persistence per field
- `tests/unit/mentor-key-construction.test.ts` (PR 2) — `generateMentorObjectKey` shape + sanitization
- `tests/integration/mentor-assignment.test.ts` (PR 4) — manual + auto + bulk + skip
- `tests/integration/mentor-round-engine.test.ts` (NEW for PR 3 or PR 5) — pass-through behavior, eligibility variants, advancement
- `tests/integration/mentor-workspace.test.ts` (PR 6) — message + file lifecycle, dashboard previews, milestone auto-complete
- `tests/unit/jury-preferences-filter.test.ts` (PR 1) — `getOnboardingContext` filter
**End-to-end happy path** (`tests/integration/mentor-round-e2e.test.ts`, ships with PR 6):
1. Admin creates a MENTORING round, sets dates + eligibility=requested_only + 14-day deadline.
2. Admin activates round.
3. Project A has `mentoringRequested=true`, project B does not.
4. Round-engine activation: B auto-PASSED (pass-through), A stays PENDING.
5. Admin manually assigns mentor M1 to project A. A flips PENDING → IN_PROGRESS. Mentor + team get assignment notification.
6. M1 sends a message in workspace; team replies. Both messages appear in respective dashboard previews.
7. M1 uploads a file. ObjectKey matches `<projectA-title>/mentorship/<timestamp>-...`. Team comments on the file.
8. M1 marks all required milestones complete → assignment.completionStatus = "completed".
9. Admin closes round. A and B both PASSED; A also COMPLETED.
This single test covers the operational path the user actually cares about for the upcoming round.
---
## Open questions
1. **`generateMentorObjectKey` — which "project name" field do we pass?** `Project.title` is the obvious choice (it's what `generateObjectKey` for submission files uses). Confirm during impl that there's no team-name-specific field we should prefer.
2. **Does `JuryGroup` have a direct `rounds` Prisma relation?** Spec assumes it; confirm field name during impl. If it's `Round.juryGroupId` only (no back-relation), use a nested `Round` query.
3. **Mentor-onboarding email content** — copy needs writing. Owned by admin, not blocking impl; can ship with placeholder copy and finalize before going live.
4. **`mentor.autoAssignBulk` — does it already skip manually-assigned?** Spec assumes yes; confirm by reading source during PR 4. If no, change is small (add `where: { method: { not: 'MANUAL' } }` to its query).
5. **Pre-flight check on existing mentor files in prod MinIO before §F.1** — must be empty or migrated, not orphaned. Confirm via `prisma db query` against prod read replica before deploying PR 2.
## Risks
| Risk | Severity | Mitigation |
|------|----------|------------|
| Existing mentor files in prod use legacy keys | High if hit | Pre-flight check; migration script ready before deploy |
| `bulkUpdateRoles` accidentally removes a critical role | Med | Server-side guard: SUPER_ADMIN cannot be self-demoted; audit log all changes |
| Multi-role redirect priority surprises some users | Low | Document the priority order; role switcher exists for override |
| AI fallback ordering doesn't match prior AI suggestions | Low | UX banner clearly states fallback is in use; keep logic simple |
| Filter on `getOnboardingContext` accidentally hides valid memberships | Low | Tests cover the three cases; ship behind no flag, easy to revert |
## Migration plan
- §A: no migration.
- §B: no migration.
- §C: no migration.
- §D: one Prisma migration adding nullable `User.mentorOnboardingSentAt: DateTime?`. No backfill (treat all existing users as not-yet-onboarded; on next role edit, email fires once).
- §E: no migration.
- §F.1: optional one-shot script to rewrite legacy `MentorFile.objectKey` rows to the new scheme. Only runs if pre-flight check finds rows. The script copies objects to the new key path then updates DB rows in a transaction; old keys remain readable until manual cleanup.
- §F.2: optional Prisma index on `MentorMessage(workspaceId, createdAt DESC)` if not present.
## Rollback
Each PR independently revertable. PRs 1, 2, 4 ship with no migration → straight git revert. PR 6 has a migration → revert PR + one-line down migration to drop the column. PR 3 has no migration; PR 5 has no migration.
## Acceptance criteria (per phase)
**PR 1 (§E):**
- Sophie Laurent (member of Screening, Expert, Finals jury groups) sees Screening + Expert preferences only — not Finals.
**PR 2 (§F.1):**
- New mentor file uploads write to `<projectName>/mentorship/<timestamp>-<file>` in MinIO.
- Removing `bucket` / `objectKey` from a `workspaceUploadFile` call still succeeds.
- Old `objectKey` upload payloads now fail Zod validation.
**PR 3 (§A):**
- All `MentoringConfigSchema` fields are editable from the Config tab.
- A draft MENTORING round with no document-promotion configured can pass Launch Readiness without a "File requirements set" check.
**PR 4 (§C):**
- Admin can manually assign any MENTOR-role user to any project from `/admin/projects/[id]/mentor`.
- Round Projects tab "Auto-fill remaining" assigns to all `mentoringRequested=true` projects without a mentor.
- Page renders sensibly with no `OPENAI_API_KEY` set (expertise-tag fallback).
**PR 5 (§B):**
- MENTORING round Overview shows live counts (requested / assigned / unassigned), deadline countdown, mentor pool size, workspace activity totals.
- `/admin/mentors` shows real list of MENTOR-role users with current assignments.
**PR 6 (§D + §F.2):**
- Juror+observer logging in during an active jury round lands on `/jury` (context-aware default). Same user logging in after the round closes lands on `/jury` via static fallback (still highest-priority role they hold).
- Mentor+juror with active mentoring assignments and no jury work lands on `/mentor`.
- `RoleSwitcherPill` ("Switch View") renders in the top-right of the header on every dashboard for multi-role users, in the same screen position regardless of layout. Single-role users don't see it.
- Admin sidebar still has the user pill at the bottom-left for sign-out / settings; role-switcher entries are removed from that menu (live in the new pill instead).
- `/admin/members` allows multi-select + "Add MENTOR role to selected" → all selected users get email + role.
- Impersonation banner doesn't intercept clicks on the user dropdown.
- Mentor `/mentor` dashboard shows "Recent Messages" card; applicant `/applicant` dashboard shows "Conversation with [Mentor]" card.

View File

@@ -0,0 +1,348 @@
# PR 6 — Lunch event (design)
Date: 2026-04-29
Status: design locked, ready for implementation plan
## 1. Goal & scope
Replace the Lunch tab placeholder on `/admin/logistics` with a working flow: admins configure a single per-edition lunch event, attendees pick a dish + log allergies, and the manifest is reviewable in-app, exportable to CSV, and emailed at the change deadline.
**In scope:**
- New models: `LunchEvent` (1:1 per program), `Dish` (per event), `MemberLunchPick` (1:1 per `AttendingMember`), `ExternalAttendee` (per program, optionally team-attached).
- Enums: `DietaryTag`, `Allergen`.
- Admin UI on the existing Logistics → Lunch tab: event config card, dishes CRUD, manifest table, externals CRUD, recap preview/send, audit logging.
- Team-lead UX: dish/allergy editing for any `AttendingMember` on their project, on the existing applicant dashboard.
- Member self-serve UX: dish/allergy editing for own `AttendingMember`, on the same dashboard.
- Single reminder email (configurable hours before deadline).
- Recap email (manual button + cron auto-send, both toggleable; admin recipients + free-form extras).
- Removal of the Lunch placeholders from Edition settings and from the disabled Logistics tab trigger.
**Out of scope:**
- No caterer-facing email integration. Admins forward the recap manually.
- No multi-event per edition (1:1 with `Program`).
- No public token-gated picker. Members must have an account; team leads or admins fill in for non-active members.
- Editable email templates (lands in PR 7).
## 2. Permission matrix
| Editor | Can edit |
| --- | --- |
| Member (logged in) | Their own dish + allergies, until deadline |
| Team lead | Any `AttendingMember` on their project, until deadline |
| Admin | Everything — all `AttendingMember` picks + all `ExternalAttendee` records, no deadline cap |
External (off-platform) attendees are admin-only to manage; team leads see them on the project page read-only when an external is attached to their team.
*"Team lead"* throughout this spec means a user with a `TeamMember` row on the project where `TeamMember.role === 'LEAD'` (existing enum value, defined at `schema.prisma:273-277`).
*"Admins of the edition"* (used by recap recipients and audit-log actor scoping) means all users with `role === 'SUPER_ADMIN'` plus all users with `role === 'PROGRAM_ADMIN'`. There is no per-program admin scoping today, so all program admins receive the recap.
## 3. Data model
```prisma
enum DietaryTag {
VEGETARIAN
VEGAN
GLUTEN_FREE
PESCATARIAN
}
enum Allergen {
GLUTEN // cereals containing gluten
CRUSTACEANS
EGGS
FISH
PEANUTS
SOYBEANS
MILK
TREE_NUTS
CELERY
MUSTARD
SESAME
SULPHITES
LUPIN
MOLLUSCS
}
model LunchEvent {
id String @id @default(cuid())
programId String @unique // 1:1 — one lunch per edition
enabled Boolean @default(false)
eventAt DateTime? // nullable until admin sets it
endAt DateTime?
venue String?
notes String? @db.Text
changeCutoffHours Int @default(48)
reminderHoursBeforeDeadline Int? // null = no reminder
cronEnabled Boolean @default(true) // auto-recap at deadline
extraRecipients String[] @default([]) // off-platform recap recipients
reminderSentAt DateTime? // cron idempotency
recapSentAt DateTime? // gates "send updated recap?" prompt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
dishes Dish[]
externalAttendees ExternalAttendee[]
}
model Dish {
id String @id @default(cuid())
lunchEventId String
name String
sortOrder Int @default(0)
dietaryTags DietaryTag[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
memberPicks MemberLunchPick[]
externals ExternalAttendee[]
@@index([lunchEventId])
}
model MemberLunchPick {
id String @id @default(cuid())
attendingMemberId String @unique // 1:1, mirrors FlightDetail/VisaApplication
dishId String? // null = not picked yet
allergens Allergen[] @default([])
allergenOther String? // "other" free-text
pickedAt DateTime? // null until first pick made
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull)
@@index([dishId])
}
model ExternalAttendee {
id String @id @default(cuid())
lunchEventId String
projectId String? // optional — null = standalone (jury/dignitary/etc.)
name String
email String?
roleNote String?
dishId String?
allergens Allergen[] @default([])
allergenOther String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull)
@@index([lunchEventId])
@@index([projectId])
}
```
**Back-references on existing models:**
```prisma
model Program {
// ...existing fields...
lunchEvent LunchEvent?
}
model AttendingMember {
// ...existing fields...
lunchPick MemberLunchPick?
}
model Project {
// ...existing fields...
externalLunchAttendees ExternalAttendee[]
}
```
**Auto-create hook.** When an `AttendingMember` is created, if a `LunchEvent` exists for the parent program, also create an empty `MemberLunchPick` (`dishId=null`, `pickedAt=null`). When the `AttendingMember` is deleted, the cascade handles the pick. Mirrors the visa-application auto-sync added in commit `bdfd998`.
**Migrations are additive.** Nothing existing changes shape. `pickedAt` is set on the first `upsertPick` call where `dishId` is non-null; subsequent edits update `updatedAt` only.
## 4. API surface
New router `src/server/routers/lunch.ts`, mounted as `trpc.lunch.*`. Logistics router unchanged.
### Admin-only (`adminProcedure`)
| Procedure | Purpose |
| --- | --- |
| `getEvent` | Get-or-create the `LunchEvent` for the current program (lazy create, mirrors hotel's pattern). |
| `updateEvent` | Patch any subset of: `enabled, eventAt, endAt, venue, notes, changeCutoffHours, reminderHoursBeforeDeadline, cronEnabled, extraRecipients[]`. |
| `createDish` / `updateDish` / `deleteDish` / `reorderDishes` | Dish CRUD. Delete sets `dishId=null` on picks via Prisma `SetNull`. |
| `listExternals` / `createExternal` / `updateExternal` / `deleteExternal` | External-attendee CRUD. |
| `getManifest` | Full manifest: attending members (filtered to `FinalistConfirmation.status === CONFIRMED`) + externals, each with project, name, dish, allergens. Backs the Lunch tab table and CSV export. |
| `exportManifestCsv` | Server-side CSV generation; returns string for client-side download. |
| `getRecapPreview` | Returns the recap email payload (counts + table) for in-app preview. |
| `sendRecap` | Manual send. Input `{ forceUpdate?: boolean }`. If `recapSentAt` is set and `forceUpdate=false`, throws `PRECONDITION_FAILED` so the UI can show the "send updated?" confirm. Sends to admins of the edition + `extraRecipients[]`. Updates `recapSentAt`. Audit-logged. |
### Mixed permission (`protectedProcedure` with role guard inside)
| Procedure | Purpose |
| --- | --- |
| `upsertPick` | Single procedure for member-self / team-lead / admin. Input: `{ attendingMemberId, dishId, allergens, allergenOther }`. Guard: caller is (a) the `AttendingMember.userId`, OR (b) team lead of the parent project, OR (c) admin. After `changeCutoffHours` cutoff, only admins pass. Audit-logged on every write with actor role. |
### Member read (`protectedProcedure`)
| Procedure | Purpose |
| --- | --- |
| `getEventForMember` | Public-ish event view: `{ enabled, eventAt, endAt, venue, notes, changeDeadline }` for the dashboard banner. Returns `null` when `enabled=false`. |
| `getTeamPicks` | All picks for the caller's team (resolved via `TeamMember → project`). Returns `[{ attendingMemberId, memberName, dish, allergens, hasPicked }]` for the team-wide-read visibility. |
### Cron endpoints (REST, `CRON_SECRET` guarded)
| Endpoint | Behavior |
| --- | --- |
| `POST /api/cron/lunch-reminders` | Single fire per event: scans enabled `LunchEvent`s with `reminderHoursBeforeDeadline` set and `reminderSentAt` null. If `now ∈ [reminderAt, deadline)`, emails attending members with `pickedAt=null` whose parent `FinalistConfirmation.status === CONFIRMED`, then stamps `reminderSentAt`. Idempotent. |
| `POST /api/cron/lunch-recap` | Single fire per event: scans enabled `LunchEvent`s with `cronEnabled=true`, `recapSentAt` null, and `now >= deadline`. Sends recap to admins + `extraRecipients[]`, stamps `recapSentAt`. Idempotent. |
Both endpoints follow the CLAUDE.md rule "Round notifications never throw — all errors caught and logged" with per-event `try/catch` so one failure does not poison the sweep.
## 5. UI
### Admin: `/admin/logistics → Lunch tab`
Stack of cards on the existing tab content area:
1. **Event config card** — enabled toggle (master switch), `eventAt` + `endAt` date pickers, `venue`, `notes`, `changeCutoffHours`, `reminderHoursBeforeDeadline`, `cronEnabled`, `extraRecipients[]` (chip-input for emails). Same blur-to-commit pattern used by the Edition settings tab.
2. **Dishes card** — list of dishes (name, dietary-tag pills, drag handle for `sortOrder`), inline add row, edit/delete buttons. Empty state: *"Add at least one dish to open picks."*
3. **Manifest card** — table: `Team | Attendee | Type (member/external) | Dish | Allergens | Picked at`. Filters: team dropdown, "Missing picks only" toggle. Header summary chip: *"23/30 picked · 3 vegan · 2 nut-allergic · 1 missing"*. Externals appear inline (team column shows "—" for standalone). Edit-pencil opens a slide-over on any row (admin override).
4. **Externals card** — table of external attendees with add button → dialog (name, email, project (optional), `roleNote`, `dishId`, `allergens`, `allergenOther`). Edits use the same dialog.
5. **Recap actions card** — two buttons: *"Preview recap"* (modal showing email body) and *"Send recap now"* (with the post-deadline "you already sent — resend updated?" confirm); plus *"Download CSV"*. Footer text: *"Last sent: 2026-06-25 14:02. Recipients: 4 admins + 2 extra."*
When `enabled=false`, cards 25 collapse to a single empty state: *"Lunch is disabled — toggle on to configure."*
### Applicant dashboard (`/applicant`) — extend `AttendingMembersCard`
Each attending-member row (already shows visa + flight) gets a new collapsible **Lunch** subsection:
- Dish dropdown (grouped by dietary tag — *"Vegetarian options"*, *"All options"*).
- Allergen checklist (EU 14 inline grid) + "other" textarea.
- "Picked" chip with timestamp once `pickedAt` is set.
Edit affordance:
- **Member viewing own row:** editable until deadline.
- **Team lead viewing teammates' rows:** editable until deadline, with a clear *"Editing on behalf of [Name]"* label.
- **Past deadline:** read-only, with note *"Past change deadline. Contact an admin for changes."*
Above `AttendingMembersCard`, a thin **lunch banner** (only when `enabled=true`) shows event date/time, venue, change-deadline countdown, and a *"Notes from organizers"* expander.
### Project page
Read-only **External attendees for your team** strip — only when externals with `projectId === thisProject` exist, so the team knows who's joining them. No edits — admin-only.
### Removals
- Drop the Lunch line from the "Coming soon" card on `edition-settings-tab.tsx:212-216`.
- Remove `disabled` from the Lunch tab trigger in `logistics/page.tsx:55-58` and wire it to a new `<LunchTab>` component.
## 6. Email + cron details
**Email templates** live inline in `src/lib/email.ts` (the existing single-file pattern); no new infrastructure.
**Reminder.** Subject: *"Pick your lunch dish — deadline in [Xh]"*. Body: greeting, event date/venue/notes, deadline timestamp, button → applicant dashboard. Sent only to attending members with `pickedAt=null` whose confirmation is `CONFIRMED`.
**Recap.** Subject: *"Lunch manifest — [event date]"*. Body: aggregate counts (dishes, dietary tags, allergens), per-attendee table grouped by team (externals as a final group), event metadata. Plain HTML; no CSV attachment in v1 — admins use the in-app *"Download CSV"* button when needed.
**Time formatting.** Same approach as the confirmation page: format with `Intl.DateTimeFormat` in the recipient's email-client locale, plus a hardcoded `"Europe/Monaco"` zone label and the ISO timestamp for unambiguous parsing.
**Audit log entries** (new `eventType` string literals on the existing `DecisionAuditLog.eventType` field — no schema change since the column is free-form):
- `LUNCH_EVENT_UPDATED`
- `LUNCH_DISH_CREATED` / `LUNCH_DISH_UPDATED` / `LUNCH_DISH_DELETED`
- `LUNCH_PICK_UPDATED` (records actor role: `SELF` / `TEAM_LEAD` / `ADMIN`)
- `LUNCH_EXTERNAL_CREATED` / `LUNCH_EXTERNAL_UPDATED` / `LUNCH_EXTERNAL_DELETED`
- `LUNCH_RECAP_SENT` (with recipient count)
## 7. Edge cases & error handling
| Case | Behavior |
| --- | --- |
| `LunchEvent` does not yet exist for the program | `getEvent` lazily creates it with defaults; member/team-lead reads return `null` (banner hidden). |
| Admin disables lunch after picks made | Picks remain in the database; UI hides them; recap still works if re-enabled. |
| `FinalistConfirmation` flips from `CONFIRMED` to `SUPERSEDED` after a pick was made | Pick row stays; manifest filters the team out. If the team is later re-promoted via waitlist, picks are visible again. |
| Dish is deleted | `dishId` becomes `null` on picks/externals; rows show as "not picked"; admins re-pick. Audit logged. |
| `eventAt` is moved | Deadline (`eventAt - changeCutoffHours`) and reminder window recalculate automatically — no manual adjustment needed. |
| `eventAt` is set in the past | Admin sees a UI warning; cron treats deadline as already-past (so a manual send is the only recourse, since `recapSentAt` may already be moot). |
| `changeCutoffHours = 0` | Deadline equals `eventAt`. Allowed. |
| Admin edits a pick after `recapSentAt` is set | UI surfaces a confirm dialog: *"This will not auto-resend the recap. Send updated recap?"* ─ "Yes" calls `sendRecap` with `forceUpdate=true`. Audit logged regardless. |
| Member with no `AttendingMember` row | Cannot edit. UI hides the lunch subsection (no row exists). |
| External with `projectId` that points to a project no longer in the edition | `onDelete: SetNull` on the relation already covers cascades; standalone-display fallback. |
## 8. Testing strategy
Vitest, sequential pool (per CLAUDE.md). Tests grouped by router/service:
**`tests/lunch/lunch-router.test.ts`**
- `getEvent` lazily creates the row on first call.
- `updateEvent` patches an arbitrary subset.
- Dish CRUD (`createDish`, `updateDish`, `deleteDish`, `reorderDishes`) — delete sets `dishId=null` on existing picks.
- External CRUD covers the standalone (`projectId=null`) and team-attached cases.
- `getManifest` filters out non-`CONFIRMED` confirmations and merges externals.
**`tests/lunch/upsert-pick.test.ts`**
- Member edits own row: succeeds before deadline, fails after.
- Team lead edits teammate row: succeeds before deadline, fails after.
- Team lead edits a non-team member's row: fails with `FORBIDDEN`.
- Admin edits any row before/after deadline: succeeds in both cases.
- Audit log records actor role correctly per case.
**`tests/lunch/recap.test.ts`**
- `sendRecap` with `recapSentAt=null` succeeds and stamps the timestamp.
- `sendRecap` with `recapSentAt` set and `forceUpdate=false` throws `PRECONDITION_FAILED`.
- `sendRecap` with `forceUpdate=true` succeeds and re-stamps.
- Recap aggregation is correct (dish counts, dietary-tag counts, allergen counts).
**`tests/lunch/cron.test.ts`**
- `lunch-reminders` is idempotent (second call within window does not double-send).
- `lunch-reminders` skips events with `reminderSentAt` already set.
- `lunch-recap` skips events with `cronEnabled=false`.
- `lunch-recap` skips events with `recapSentAt` already set.
- Per-event try/catch — a failing send for one event does not stop the next from being processed.
**`tests/lunch/auto-create.test.ts`**
- Creating an `AttendingMember` while a `LunchEvent` exists also creates an empty `MemberLunchPick`.
- Creating an `AttendingMember` while no `LunchEvent` exists does not error and does not create a pick.
Build (`npm run build`), typecheck (`npm run typecheck`), and full test suite must be green before commit.
## 9. File-level work surface (informative — drives the implementation plan)
- `prisma/schema.prisma` — add models, enums, back-references; new migration.
- `src/server/routers/lunch.ts` (new) — router as designed.
- `src/server/routers/_app.ts` — mount `lunch` router.
- `src/server/services/lunch-pick-sync.ts` (new) — `ensureLunchPickForAttendingMember` helper called from existing attendee-creation paths.
- `src/server/services/lunch-recap.ts` (new) — manifest aggregation + email body builder, used by `sendRecap` and the recap cron.
- `src/lib/email.ts` — append two new template functions (reminder + recap).
- `src/app/api/cron/lunch-reminders/route.ts` (new).
- `src/app/api/cron/lunch-recap/route.ts` (new).
- `src/app/(admin)/admin/logistics/page.tsx` — un-disable the Lunch tab trigger; mount new tab content.
- `src/components/admin/logistics/lunch-tab.tsx` (new) — orchestrates the five cards.
- `src/components/admin/logistics/lunch-event-config.tsx` (new) — config card.
- `src/components/admin/logistics/lunch-dishes.tsx` (new) — dishes card.
- `src/components/admin/logistics/lunch-manifest.tsx` (new) — manifest card.
- `src/components/admin/logistics/lunch-externals.tsx` (new) — externals card.
- `src/components/admin/logistics/lunch-recap-actions.tsx` (new) — recap actions card.
- `src/components/applicant/attending-members-card.tsx` — extend each row with the lunch subsection.
- `src/components/applicant/lunch-banner.tsx` (new) — the dashboard banner above the attending-members card.
- `src/components/admin/settings/edition-settings-tab.tsx` — drop the Lunch line from the "Coming soon" card.
## 10. Non-goals reminder
- No keyboard shortcuts anywhere — visible UI affordances only (per existing user feedback memory).
- No editable email templates in this PR (PR 7).
- No public token-gated picker.
- No multi-event support.
- No caterer email integration.

15
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "mopc-platform", "name": "mopc-platform",
"version": "0.1.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mopc-platform", "name": "mopc-platform",
"version": "0.1.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.78.0", "@anthropic-ai/sdk": "^0.78.0",
"@auth/prisma-adapter": "^2.7.4", "@auth/prisma-adapter": "^2.7.4",
@@ -61,7 +61,6 @@
"motion": "^11.15.0", "motion": "^11.15.0",
"next": "^15.1.0", "next": "^15.1.0",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.6",
"nodemailer": "^7.0.7", "nodemailer": "^7.0.7",
"openai": "^6.16.0", "openai": "^6.16.0",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
@@ -12143,16 +12142,6 @@
} }
} }
}, },
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "mopc-platform", "name": "mopc-platform",
"version": "0.1.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -75,7 +75,6 @@
"motion": "^11.15.0", "motion": "^11.15.0",
"next": "^15.1.0", "next": "^15.1.0",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.6",
"nodemailer": "^7.0.7", "nodemailer": "^7.0.7",
"openai": "^6.16.0", "openai": "^6.16.0",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",

View File

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

View File

@@ -0,0 +1,119 @@
-- CreateEnum
CREATE TYPE "WaitlistEntryStatus" AS ENUM ('WAITING', 'PROMOTED', 'USED');
-- CreateEnum
CREATE TYPE "FinalistConfirmationStatus" AS ENUM ('PENDING', 'CONFIRMED', 'DECLINED', 'EXPIRED', 'SUPERSEDED');
-- AlterTable
ALTER TABLE "Program" ADD COLUMN "defaultAttendeeCap" INTEGER NOT NULL DEFAULT 3;
-- CreateTable
CREATE TABLE "FinalistSlotQuota" (
"id" TEXT NOT NULL,
"programId" TEXT NOT NULL,
"category" "CompetitionCategory" NOT NULL,
"quota" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FinalistSlotQuota_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WaitlistEntry" (
"id" TEXT NOT NULL,
"programId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"category" "CompetitionCategory" NOT NULL,
"rank" INTEGER NOT NULL,
"status" "WaitlistEntryStatus" NOT NULL DEFAULT 'WAITING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FinalistConfirmation" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"category" "CompetitionCategory" NOT NULL,
"status" "FinalistConfirmationStatus" NOT NULL DEFAULT 'PENDING',
"deadline" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"confirmedAt" TIMESTAMP(3),
"declinedAt" TIMESTAMP(3),
"declineReason" TEXT,
"expiredAt" TIMESTAMP(3),
"promotedFromWaitlistEntryId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FinalistConfirmation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AttendingMember" (
"id" TEXT NOT NULL,
"confirmationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"needsVisa" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AttendingMember_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "FinalistSlotQuota_programId_idx" ON "FinalistSlotQuota"("programId");
-- CreateIndex
CREATE UNIQUE INDEX "FinalistSlotQuota_programId_category_key" ON "FinalistSlotQuota"("programId", "category");
-- CreateIndex
CREATE UNIQUE INDEX "WaitlistEntry_projectId_key" ON "WaitlistEntry"("projectId");
-- CreateIndex
CREATE INDEX "WaitlistEntry_programId_category_status_idx" ON "WaitlistEntry"("programId", "category", "status");
-- CreateIndex
CREATE UNIQUE INDEX "WaitlistEntry_programId_category_rank_key" ON "WaitlistEntry"("programId", "category", "rank");
-- CreateIndex
CREATE UNIQUE INDEX "FinalistConfirmation_projectId_key" ON "FinalistConfirmation"("projectId");
-- CreateIndex
CREATE UNIQUE INDEX "FinalistConfirmation_token_key" ON "FinalistConfirmation"("token");
-- CreateIndex
CREATE UNIQUE INDEX "FinalistConfirmation_promotedFromWaitlistEntryId_key" ON "FinalistConfirmation"("promotedFromWaitlistEntryId");
-- CreateIndex
CREATE INDEX "FinalistConfirmation_status_deadline_idx" ON "FinalistConfirmation"("status", "deadline");
-- CreateIndex
CREATE INDEX "FinalistConfirmation_category_status_idx" ON "FinalistConfirmation"("category", "status");
-- CreateIndex
CREATE INDEX "AttendingMember_userId_idx" ON "AttendingMember"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "AttendingMember_confirmationId_userId_key" ON "AttendingMember"("confirmationId", "userId");
-- AddForeignKey
ALTER TABLE "FinalistSlotQuota" ADD CONSTRAINT "FinalistSlotQuota_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FinalistConfirmation" ADD CONSTRAINT "FinalistConfirmation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AttendingMember" ADD CONSTRAINT "AttendingMember_confirmationId_fkey" FOREIGN KEY ("confirmationId") REFERENCES "FinalistConfirmation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AttendingMember" ADD CONSTRAINT "AttendingMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,49 @@
-- CreateEnum
CREATE TYPE "FlightDetailStatus" AS ENUM ('PENDING', 'CONFIRMED');
-- CreateTable
CREATE TABLE "Hotel" (
"id" TEXT NOT NULL,
"programId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"address" TEXT,
"link" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Hotel_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FlightDetail" (
"id" TEXT NOT NULL,
"attendingMemberId" TEXT NOT NULL,
"arrivalAt" TIMESTAMP(3),
"arrivalFlightNumber" TEXT,
"arrivalAirport" TEXT,
"departureAt" TIMESTAMP(3),
"departureFlightNumber" TEXT,
"departureAirport" TEXT,
"status" "FlightDetailStatus" NOT NULL DEFAULT 'PENDING',
"adminNotes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FlightDetail_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Hotel_programId_key" ON "Hotel"("programId");
-- CreateIndex
CREATE UNIQUE INDEX "FlightDetail_attendingMemberId_key" ON "FlightDetail"("attendingMemberId");
-- CreateIndex
CREATE INDEX "FlightDetail_status_idx" ON "FlightDetail"("status");
-- AddForeignKey
ALTER TABLE "Hotel" ADD CONSTRAINT "Hotel_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FlightDetail" ADD CONSTRAINT "FlightDetail_attendingMemberId_fkey" FOREIGN KEY ("attendingMemberId") REFERENCES "AttendingMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "MentorAssignment" ADD COLUMN "droppedAt" TIMESTAMP(3),
ADD COLUMN "droppedBy" TEXT,
ADD COLUMN "droppedReason" TEXT;

View File

@@ -0,0 +1,30 @@
-- CreateEnum
CREATE TYPE "VisaStatus" AS ENUM ('NOT_NEEDED', 'REQUESTED', 'INVITATION_SENT', 'APPOINTMENT_BOOKED', 'GRANTED', 'DENIED');
-- AlterTable
ALTER TABLE "Program" ADD COLUMN "visaStatusVisibleToMembers" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "VisaApplication" (
"id" TEXT NOT NULL,
"attendingMemberId" TEXT NOT NULL,
"status" "VisaStatus" NOT NULL DEFAULT 'REQUESTED',
"nationality" TEXT,
"invitationSentAt" TIMESTAMP(3),
"appointmentAt" TIMESTAMP(3),
"decisionAt" TIMESTAMP(3),
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VisaApplication_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "VisaApplication_attendingMemberId_key" ON "VisaApplication"("attendingMemberId");
-- CreateIndex
CREATE INDEX "VisaApplication_status_idx" ON "VisaApplication"("status");
-- AddForeignKey
ALTER TABLE "VisaApplication" ADD CONSTRAINT "VisaApplication_attendingMemberId_fkey" FOREIGN KEY ("attendingMemberId") REFERENCES "AttendingMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,109 @@
-- CreateEnum
CREATE TYPE "DietaryTag" AS ENUM ('VEGETARIAN', 'VEGAN', 'GLUTEN_FREE', 'PESCATARIAN');
-- CreateEnum
CREATE TYPE "Allergen" AS ENUM ('GLUTEN', 'CRUSTACEANS', 'EGGS', 'FISH', 'PEANUTS', 'SOYBEANS', 'MILK', 'TREE_NUTS', 'CELERY', 'MUSTARD', 'SESAME', 'SULPHITES', 'LUPIN', 'MOLLUSCS');
-- CreateTable
CREATE TABLE "LunchEvent" (
"id" TEXT NOT NULL,
"programId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"eventAt" TIMESTAMP(3),
"endAt" TIMESTAMP(3),
"venue" TEXT,
"notes" TEXT,
"changeCutoffHours" INTEGER NOT NULL DEFAULT 48,
"reminderHoursBeforeDeadline" INTEGER,
"cronEnabled" BOOLEAN NOT NULL DEFAULT true,
"extraRecipients" TEXT[] DEFAULT ARRAY[]::TEXT[],
"reminderSentAt" TIMESTAMP(3),
"recapSentAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LunchEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Dish" (
"id" TEXT NOT NULL,
"lunchEventId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"dietaryTags" "DietaryTag"[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Dish_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MemberLunchPick" (
"id" TEXT NOT NULL,
"attendingMemberId" TEXT NOT NULL,
"dishId" TEXT,
"allergens" "Allergen"[] DEFAULT ARRAY[]::"Allergen"[],
"allergenOther" TEXT,
"pickedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MemberLunchPick_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ExternalAttendee" (
"id" TEXT NOT NULL,
"lunchEventId" TEXT NOT NULL,
"projectId" TEXT,
"name" TEXT NOT NULL,
"email" TEXT,
"roleNote" TEXT,
"dishId" TEXT,
"allergens" "Allergen"[] DEFAULT ARRAY[]::"Allergen"[],
"allergenOther" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ExternalAttendee_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "LunchEvent_programId_key" ON "LunchEvent"("programId");
-- CreateIndex
CREATE INDEX "Dish_lunchEventId_idx" ON "Dish"("lunchEventId");
-- CreateIndex
CREATE UNIQUE INDEX "MemberLunchPick_attendingMemberId_key" ON "MemberLunchPick"("attendingMemberId");
-- CreateIndex
CREATE INDEX "MemberLunchPick_dishId_idx" ON "MemberLunchPick"("dishId");
-- CreateIndex
CREATE INDEX "ExternalAttendee_lunchEventId_idx" ON "ExternalAttendee"("lunchEventId");
-- CreateIndex
CREATE INDEX "ExternalAttendee_projectId_idx" ON "ExternalAttendee"("projectId");
-- AddForeignKey
ALTER TABLE "LunchEvent" ADD CONSTRAINT "LunchEvent_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Dish" ADD CONSTRAINT "Dish_lunchEventId_fkey" FOREIGN KEY ("lunchEventId") REFERENCES "LunchEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberLunchPick" ADD CONSTRAINT "MemberLunchPick_attendingMemberId_fkey" FOREIGN KEY ("attendingMemberId") REFERENCES "AttendingMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberLunchPick" ADD CONSTRAINT "MemberLunchPick_dishId_fkey" FOREIGN KEY ("dishId") REFERENCES "Dish"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ExternalAttendee" ADD CONSTRAINT "ExternalAttendee_lunchEventId_fkey" FOREIGN KEY ("lunchEventId") REFERENCES "LunchEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ExternalAttendee" ADD CONSTRAINT "ExternalAttendee_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ExternalAttendee" ADD CONSTRAINT "ExternalAttendee_dishId_fkey" FOREIGN KEY ("dishId") REFERENCES "Dish"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,31 @@
-- Drops AWARD_MASTER from the UserRole enum.
--
-- Any row still holding AWARD_MASTER is demoted to JURY_MEMBER (singular role)
-- or filtered out of the roles[] array (multi-role) before the enum swap, so
-- the type alteration is safe even if the prod migration was missed.
UPDATE "User" SET role = 'JURY_MEMBER' WHERE role = 'AWARD_MASTER';
UPDATE "User" SET roles = array_remove(roles, 'AWARD_MASTER') WHERE 'AWARD_MASTER' = ANY(roles);
CREATE TYPE "UserRole_new" AS ENUM (
'SUPER_ADMIN',
'PROGRAM_ADMIN',
'JURY_MEMBER',
'MENTOR',
'OBSERVER',
'APPLICANT',
'AUDIENCE'
);
ALTER TABLE "User" ALTER COLUMN role DROP DEFAULT;
ALTER TABLE "User"
ALTER COLUMN role TYPE "UserRole_new" USING role::text::"UserRole_new";
ALTER TABLE "User" ALTER COLUMN role SET DEFAULT 'APPLICANT';
ALTER TABLE "User" ALTER COLUMN roles DROP DEFAULT;
ALTER TABLE "User"
ALTER COLUMN roles TYPE "UserRole_new"[] USING roles::text[]::"UserRole_new"[];
ALTER TABLE "User" ALTER COLUMN roles SET DEFAULT '{}'::"UserRole_new"[];
DROP TYPE "UserRole";
ALTER TYPE "UserRole_new" RENAME TO "UserRole";

View File

@@ -0,0 +1,78 @@
-- Hand-written migration for PR8 (multi-mentor per team).
--
-- All DDL guarded with IF EXISTS / IF NOT EXISTS so the docker-entrypoint
-- retry loop is safe to re-run. No regex (the 2026-05-07 prod incident was
-- caused by Prisma 6 generating regex-based DDL that Postgres rejected).
-- No BEGIN/COMMIT blocks — Prisma wraps the migration in a transaction.
-- Phase 1: MentorAssignment — drop unique, add composite, add notification field
ALTER TABLE "MentorAssignment" DROP CONSTRAINT IF EXISTS "MentorAssignment_projectId_key";
DROP INDEX IF EXISTS "MentorAssignment_projectId_key";
CREATE UNIQUE INDEX IF NOT EXISTS "MentorAssignment_projectId_mentorId_key"
ON "MentorAssignment"("projectId", "mentorId");
CREATE INDEX IF NOT EXISTS "MentorAssignment_projectId_idx"
ON "MentorAssignment"("projectId");
ALTER TABLE "MentorAssignment" ADD COLUMN IF NOT EXISTS "notificationSentAt" TIMESTAMP(3);
-- Phase 2: MentorFile — re-scope to project (two-phase backfill)
ALTER TABLE "MentorFile" ADD COLUMN IF NOT EXISTS "projectId" TEXT;
UPDATE "MentorFile" mf
SET "projectId" = ma."projectId"
FROM "MentorAssignment" ma
WHERE mf."mentorAssignmentId" = ma."id"
AND mf."projectId" IS NULL;
ALTER TABLE "MentorFile" ALTER COLUMN "projectId" SET NOT NULL;
ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_projectId_fkey";
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE INDEX IF NOT EXISTS "MentorFile_projectId_idx" ON "MentorFile"("projectId");
-- Phase 2b: Make MentorFile.mentorAssignmentId nullable + switch its FK to SetNull
ALTER TABLE "MentorFile" ALTER COLUMN "mentorAssignmentId" DROP NOT NULL;
ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_mentorAssignmentId_fkey";
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_mentorAssignmentId_fkey"
FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Phase 3: MentorChangeRequest table
-- Postgres < 14 doesn't support CREATE TYPE ... IF NOT EXISTS, so wrap in a
-- DO block that swallows duplicate_object errors (idempotent for re-runs).
DO $$ BEGIN
CREATE TYPE "MentorChangeRequestStatus" AS ENUM ('PENDING', 'RESOLVED', 'DISMISSED');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
CREATE TABLE IF NOT EXISTS "MentorChangeRequest" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"targetAssignmentId" TEXT,
"requestedByUserId" TEXT,
"reason" TEXT NOT NULL,
"status" "MentorChangeRequestStatus" NOT NULL DEFAULT 'PENDING',
"resolvedByUserId" TEXT,
"resolvedAt" TIMESTAMP(3),
"resolutionNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MentorChangeRequest_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_projectId_fkey";
ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_targetAssignmentId_fkey";
ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_targetAssignmentId_fkey"
FOREIGN KEY ("targetAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_requestedByUserId_fkey";
ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_requestedByUserId_fkey"
FOREIGN KEY ("requestedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_resolvedByUserId_fkey";
ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_resolvedByUserId_fkey"
FOREIGN KEY ("resolvedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX IF NOT EXISTS "MentorChangeRequest_projectId_idx" ON "MentorChangeRequest"("projectId");
CREATE INDEX IF NOT EXISTS "MentorChangeRequest_status_idx" ON "MentorChangeRequest"("status");
CREATE INDEX IF NOT EXISTS "MentorChangeRequest_targetAssignmentId_idx" ON "MentorChangeRequest"("targetAssignmentId");

View File

@@ -0,0 +1,23 @@
-- PR8 rollback SQL (manual, only safe BEFORE any project has >1 mentor)
-- Reverses 20260522155652_multi_mentor_per_team
-- MentorChangeRequest: drop new table + enum
DROP TABLE IF EXISTS "MentorChangeRequest";
DROP TYPE IF EXISTS "MentorChangeRequestStatus";
-- MentorFile: drop projectId scope + restore mentorAssignmentId as required Cascade
ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_projectId_fkey";
DROP INDEX IF EXISTS "MentorFile_projectId_idx";
ALTER TABLE "MentorFile" DROP COLUMN IF EXISTS "projectId";
-- Restoring NOT NULL is safe only if no rows have NULL mentorAssignmentId (true unless multi-mentor assignments were dropped post-migration)
ALTER TABLE "MentorFile" ALTER COLUMN "mentorAssignmentId" SET NOT NULL;
ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_mentorAssignmentId_fkey";
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_mentorAssignmentId_fkey"
FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- MentorAssignment: restore projectId @unique + drop new fields
DROP INDEX IF EXISTS "MentorAssignment_projectId_mentorId_key";
DROP INDEX IF EXISTS "MentorAssignment_projectId_idx";
ALTER TABLE "MentorAssignment" DROP COLUMN IF EXISTS "notificationSentAt";
-- Re-adding UNIQUE will FAIL if any project has >1 mentor (intended safety signal)
ALTER TABLE "MentorAssignment" ADD CONSTRAINT "MentorAssignment_projectId_key" UNIQUE ("projectId");

View File

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

View File

@@ -29,7 +29,6 @@ enum UserRole {
MENTOR MENTOR
OBSERVER OBSERVER
APPLICANT APPLICANT
AWARD_MASTER
AUDIENCE AUDIENCE
} }
@@ -119,7 +118,6 @@ enum NotificationChannel {
NONE NONE
} }
enum PartnerVisibility { enum PartnerVisibility {
ADMIN_ONLY ADMIN_ONLY
JURY_VISIBLE JURY_VISIBLE
@@ -134,7 +132,6 @@ enum PartnerType {
OTHER OTHER
} }
// ============================================================================= // =============================================================================
// COMPETITION / ROUND ENGINE ENUMS // COMPETITION / ROUND ENGINE ENUMS
// ============================================================================= // =============================================================================
@@ -172,7 +169,6 @@ enum ProjectRoundStateValue {
WITHDRAWN WITHDRAWN
} }
enum CapMode { enum CapMode {
HARD HARD
SOFT SOFT
@@ -302,6 +298,9 @@ model User {
institution String? // User's institution/organization institution String? // User's institution/organization
metadataJson Json? @db.JsonB metadataJson Json? @db.JsonB
// Mentor onboarding email idempotency: stamped once when MENTOR role is first added.
mentorOnboardingSentAt DateTime?
// Profile // Profile
bio String? // User bio for matching with project descriptions bio String? // User bio for matching with project descriptions
profileImageKey String? // Storage key (e.g., "avatars/user123/1234567890.jpg") profileImageKey String? // Storage key (e.g., "avatars/user123/1234567890.jpg")
@@ -326,8 +325,8 @@ model User {
inviteTokenExpiresAt DateTime? inviteTokenExpiresAt DateTime?
// Password reset token // Password reset token
passwordResetToken String? @unique passwordResetToken String? @unique
passwordResetExpiresAt DateTime? passwordResetExpiresAt DateTime?
// Digest & availability preferences // Digest & availability preferences
digestFrequency String @default("none") // 'none' | 'daily' | 'weekly' digestFrequency String @default("none") // 'none' | 'daily' | 'weekly'
@@ -361,9 +360,9 @@ model User {
filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy") filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy")
// Award overrides // Award overrides
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy") awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer") awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer")
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy") awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
// In-app notifications // In-app notifications
notifications InAppNotification[] @relation("UserNotifications") notifications InAppNotification[] @relation("UserNotifications")
@@ -411,17 +410,24 @@ model User {
sessions Session[] sessions Session[]
// ── Competition/Round architecture relations ── // ── Competition/Round architecture relations ──
juryGroupMemberships JuryGroupMember[] juryGroupMemberships JuryGroupMember[]
mentorFilesUploaded MentorFile[] @relation("MentorFileUploader") mentorFilesUploaded MentorFile[] @relation("MentorFileUploader")
mentorFilesPromoted MentorFile[] @relation("MentorFilePromoter") mentorFilesPromoted MentorFile[] @relation("MentorFilePromoter")
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor") mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
resultLocksCreated ResultLock[] @relation("ResultLockCreator") resultLocksCreated ResultLock[] @relation("ResultLockCreator")
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker") resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter") submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement") deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
// AI Ranking // AI Ranking
rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots") rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots")
// Grand-finale logistics
finalistAttendances AttendingMember[]
// Mentor change requests
mentorChangeRequestsRequested MentorChangeRequest[] @relation("MentorChangeRequester")
mentorChangeRequestsResolved MentorChangeRequest[] @relation("MentorChangeResolver")
@@index([role]) @@index([role])
@@index([status]) @@index([status])
@@ -480,6 +486,10 @@ model Program {
description String? description String?
settingsJson Json? @db.JsonB settingsJson Json? @db.JsonB
// Grand-finale logistics
defaultAttendeeCap Int @default(3) // Max team members allowed at the grand finale per finalist team
visaStatusVisibleToMembers Boolean @default(true) // Whether team members see their own visa status on the applicant dashboard
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -493,6 +503,12 @@ model Program {
mentorMilestones MentorMilestone[] mentorMilestones MentorMilestone[]
competitions Competition[] competitions Competition[]
// Grand-finale logistics
finalistSlotQuotas FinalistSlotQuota[]
waitlistEntries WaitlistEntry[]
hotel Hotel?
lunchEvent LunchEvent?
@@unique([name, year]) @@unique([name, year])
@@index([status]) @@index([status])
} }
@@ -614,7 +630,9 @@ model Project {
assignments Assignment[] assignments Assignment[]
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull) submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
teamMembers TeamMember[] teamMembers TeamMember[]
mentorAssignment MentorAssignment? mentorAssignments MentorAssignment[]
mentorFiles MentorFile[]
mentorChangeRequests MentorChangeRequest[]
filteringResults FilteringResult[] filteringResults FilteringResult[]
awardEligibilities AwardEligibility[] awardEligibilities AwardEligibility[]
awardVotes AwardVote[] awardVotes AwardVote[]
@@ -627,12 +645,17 @@ model Project {
cohortProjects CohortProject[] cohortProjects CohortProject[]
// ── Competition/Round architecture relations ── // ── Competition/Round architecture relations ──
projectRoundStates ProjectRoundState[] projectRoundStates ProjectRoundState[]
assignmentIntents AssignmentIntent[] assignmentIntents AssignmentIntent[]
deliberationVotes DeliberationVote[] deliberationVotes DeliberationVote[]
deliberationResults DeliberationResult[] deliberationResults DeliberationResult[]
submissionPromotions SubmissionPromotionEvent[] submissionPromotions SubmissionPromotionEvent[]
notificationLogs NotificationLog[] notificationLogs NotificationLog[]
// Grand-finale logistics
waitlistEntry WaitlistEntry?
finalistConfirmation FinalistConfirmation?
externalLunchAttendees ExternalAttendee[]
@@index([programId]) @@index([programId])
@@index([status]) @@index([status])
@@ -679,9 +702,9 @@ model ProjectFile {
// Document analysis (optional, populated by document-analyzer service) // Document analysis (optional, populated by document-analyzer service)
textPreview String? @db.Text // First ~2000 chars of extracted text textPreview String? @db.Text // First ~2000 chars of extracted text
detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und') detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und')
langConfidence Float? // 0.01.0 confidence langConfidence Float? // 0.01.0 confidence
analyzedAt DateTime? // When analysis last ran analyzedAt DateTime? // When analysis last ran
// MinIO location // MinIO location
bucket String bucket String
@@ -694,7 +717,7 @@ model ProjectFile {
replacedById String? // FK to the newer file that replaced this one replacedById String? // FK to the newer file that replaced this one
// ── Competition/Round architecture fields ── // ── Competition/Round architecture fields ──
submissionWindowId String? // FK to SubmissionWindow submissionWindowId String? // FK to SubmissionWindow
submissionFileRequirementId String? // FK to SubmissionFileRequirement submissionFileRequirementId String? // FK to SubmissionFileRequirement
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -743,10 +766,10 @@ model Assignment {
juryGroupId String? juryGroupId String?
// Relations // Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
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?
@@ -1006,12 +1029,12 @@ model NotificationEmailSetting {
// ============================================================================= // =============================================================================
model LearningResource { model LearningResource {
id String @id @default(cuid()) id String @id @default(cuid())
programId String? // null = global resource programId String? // null = global resource
title String title String
description String? @db.Text description String? @db.Text
contentJson Json? @db.JsonB // BlockNote document structure contentJson Json? @db.JsonB // BlockNote document structure
accessJson Json? @db.JsonB // Fine-grained access rules accessJson Json? @db.JsonB // Fine-grained access rules
// File storage (for uploaded resources) // File storage (for uploaded resources)
fileName String? fileName String?
@@ -1250,7 +1273,7 @@ model TeamMember {
model MentorAssignment { model MentorAssignment {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String @unique // One mentor per project projectId String // Team can have multiple mentors; uniqueness enforced via composite below
mentorId String // User with MENTOR role or expertise mentorId String // User with MENTOR role or expertise
// Assignment tracking // Assignment tracking
@@ -1258,6 +1281,16 @@ model MentorAssignment {
assignedAt DateTime @default(now()) assignedAt DateTime @default(now())
assignedBy String? // Admin who assigned assignedBy String? // Admin who assigned
// Per-assignment email idempotency: stamped once the MENTOR-side notification
// email has been sent (the "you've been assigned a project" email to the mentor).
notificationSentAt DateTime?
// Stamped once the TEAM has been introduced to this mentor (the "meet your
// mentor" email with mentor contact info). Fired by `activateRound` for
// MENTORING rounds and by mentor.assign when the project's MENTORING round
// is already ROUND_ACTIVE. Independent from notificationSentAt above.
teamIntroducedAt DateTime?
// AI assignment metadata // AI assignment metadata
aiConfidenceScore Float? aiConfidenceScore Float?
expertiseMatchScore Float? expertiseMatchScore Float?
@@ -1267,6 +1300,11 @@ model MentorAssignment {
completionStatus String @default("in_progress") // 'in_progress' | 'completed' | 'paused' completionStatus String @default("in_progress") // 'in_progress' | 'completed' | 'paused'
lastViewedAt DateTime? lastViewedAt DateTime?
// Drop tracking — null while assignment is active
droppedAt DateTime?
droppedReason String? @db.Text
droppedBy String? // 'mentor' | 'admin' | 'finalist_unconfirmed'
// ── Competition/Round architecture — workspace activation ── // ── Competition/Round architecture — workspace activation ──
workspaceEnabled Boolean @default(false) workspaceEnabled Boolean @default(false)
workspaceOpenAt DateTime? workspaceOpenAt DateTime?
@@ -1279,11 +1317,47 @@ model MentorAssignment {
milestoneCompletions MentorMilestoneCompletion[] milestoneCompletions MentorMilestoneCompletion[]
messages MentorMessage[] messages MentorMessage[]
files MentorFile[] files MentorFile[]
changeRequests MentorChangeRequest[] @relation("MentorChangeRequestTarget")
@@unique([projectId, mentorId])
@@index([projectId])
@@index([mentorId]) @@index([mentorId])
@@index([method]) @@index([method])
} }
// =============================================================================
// MENTOR CHANGE REQUESTS
// =============================================================================
enum MentorChangeRequestStatus {
PENDING
RESOLVED
DISMISSED
}
model MentorChangeRequest {
id String @id @default(cuid())
projectId String
targetAssignmentId String? // Optional: a specific co-mentor the request is about
requestedByUserId String?
reason String @db.Text
status MentorChangeRequestStatus @default(PENDING)
resolvedByUserId String?
resolvedAt DateTime?
resolutionNote String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
targetAssignment MentorAssignment? @relation("MentorChangeRequestTarget", fields: [targetAssignmentId], references: [id], onDelete: SetNull)
requestedBy User? @relation("MentorChangeRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
resolvedBy User? @relation("MentorChangeResolver", fields: [resolvedByUserId], references: [id])
@@index([projectId])
@@index([status])
@@index([targetAssignmentId])
}
// ============================================================================= // =============================================================================
// FILTERING ROUND SYSTEM // FILTERING ROUND SYSTEM
// ============================================================================= // =============================================================================
@@ -1418,17 +1492,17 @@ enum AssignmentJobStatus {
// ============================================================================= // =============================================================================
enum RankingTriggerType { enum RankingTriggerType {
MANUAL // Admin clicked "Run ranking" MANUAL // Admin clicked "Run ranking"
AUTO // Auto-triggered by assignment completion AUTO // Auto-triggered by assignment completion
RETROACTIVE // Retroactive scan on deployment RETROACTIVE // Retroactive scan on deployment
QUICK // Quick-rank mode (no preview) QUICK // Quick-rank mode (no preview)
} }
enum RankingMode { enum RankingMode {
PREVIEW // Parsed rules shown to admin (not yet applied) PREVIEW // Parsed rules shown to admin (not yet applied)
CONFIRMED // Admin confirmed rules, ranking applied CONFIRMED // Admin confirmed rules, ranking applied
QUICK // Quick-rank: parse + apply without preview QUICK // Quick-rank: parse + apply without preview
FORMULA // Formula-only: no LLM, pure math ranking FORMULA // Formula-only: no LLM, pure math ranking
} }
enum RankingSnapshotStatus { enum RankingSnapshotStatus {
@@ -1445,7 +1519,7 @@ model RankingSnapshot {
roundId String roundId String
// Trigger metadata // Trigger metadata
triggeredById String? // null = auto-triggered triggeredById String? // null = auto-triggered
triggerType RankingTriggerType @default(MANUAL) triggerType RankingTriggerType @default(MANUAL)
// Criteria used // Criteria used
@@ -1574,7 +1648,7 @@ model SpecialAward {
evaluationRoundId String? evaluationRoundId String?
juryGroupId String? juryGroupId String?
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN) eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
decisionMode String? // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION" decisionMode String? // "JURY_VOTE" | "ADMIN_DECISION"
shortlistSize Int @default(10) shortlistSize Int @default(10)
// Eligibility job tracking // Eligibility job tracking
@@ -1596,10 +1670,10 @@ model SpecialAward {
votes AwardVote[] votes AwardVote[]
// Competition/Round architecture relations // Competition/Round architecture relations
competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull) competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull)
evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull) evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull)
awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull) awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
rounds Round[] @relation("AwardRounds") rounds Round[] @relation("AwardRounds")
@@index([programId]) @@index([programId])
@@index([status]) @@index([status])
@@ -1663,12 +1737,12 @@ model AwardJuror {
} }
model AwardVote { model AwardVote {
id String @id @default(cuid()) id String @id @default(cuid())
awardId String awardId String
userId String userId String
projectId String projectId String
rank Int? // For RANKED mode rank Int? // For RANKED mode
justification String? @db.Text justification String? @db.Text
votedAt DateTime @default(now()) votedAt DateTime @default(now())
// Relations // Relations
@@ -1785,7 +1859,7 @@ model MentorMessage {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
// ── Competition/Round architecture fields ── // ── Competition/Round architecture fields ──
workspaceId String? // FK to MentorAssignment (used as workspace) workspaceId String? // FK to MentorAssignment (used as workspace)
senderRole MentorMessageRole? senderRole MentorMessageRole?
// Relations // Relations
@@ -2121,9 +2195,9 @@ model Competition {
status CompetitionStatus @default(DRAFT) status CompetitionStatus @default(DRAFT)
// Competition-wide settings // Competition-wide settings
categoryMode String @default("SHARED") categoryMode String @default("SHARED")
startupFinalistCount Int @default(3) startupFinalistCount Int @default(3)
conceptFinalistCount Int @default(3) conceptFinalistCount Int @default(3)
// Notification preferences // Notification preferences
notifyOnRoundAdvance Boolean @default(true) notifyOnRoundAdvance Boolean @default(true)
@@ -2134,7 +2208,7 @@ model Competition {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Relations // Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade) program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
rounds Round[] rounds Round[]
juryGroups JuryGroup[] juryGroups JuryGroup[]
submissionWindows SubmissionWindow[] submissionWindows SubmissionWindow[]
@@ -2179,10 +2253,10 @@ model Round {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Relations // Relations
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
specialAward SpecialAward? @relation("AwardRounds", fields: [specialAwardId], references: [id], onDelete: SetNull) specialAward SpecialAward? @relation("AwardRounds", fields: [specialAwardId], references: [id], onDelete: SetNull)
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[]
visibleSubmissionWindows RoundSubmissionVisibility[] visibleSubmissionWindows RoundSubmissionVisibility[]
assignmentIntents AssignmentIntent[] assignmentIntents AssignmentIntent[]
@@ -2201,7 +2275,7 @@ model Round {
filteringResults FilteringResult[] filteringResults FilteringResult[]
filteringJobs FilteringJob[] filteringJobs FilteringJob[]
assignmentJobs AssignmentJob[] assignmentJobs AssignmentJob[]
rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots") rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots")
reminderLogs ReminderLog[] reminderLogs ReminderLog[]
evaluationSummaries EvaluationSummary[] evaluationSummaries EvaluationSummary[]
evaluationDiscussions EvaluationDiscussion[] evaluationDiscussions EvaluationDiscussion[]
@@ -2247,7 +2321,7 @@ model ProjectRoundState {
// ============================================================================= // =============================================================================
model JuryGroup { model JuryGroup {
id String @id @default(cuid()) id String @id @default(cuid())
competitionId String competitionId String
name String name String
slug String slug String
@@ -2305,8 +2379,8 @@ model JuryGroupMember {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Relations // Relations
juryGroup JuryGroup @relation(fields: [juryGroupId], references: [id], onDelete: Cascade) juryGroup JuryGroup @relation(fields: [juryGroupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
assignmentIntents AssignmentIntent[] assignmentIntents AssignmentIntent[]
deliberationVotes DeliberationVote[] deliberationVotes DeliberationVote[]
deliberationParticipations DeliberationParticipant[] deliberationParticipations DeliberationParticipant[]
@@ -2344,7 +2418,7 @@ model SubmissionWindow {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Relations // Relations
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
fileRequirements SubmissionFileRequirement[] fileRequirements SubmissionFileRequirement[]
projectFiles ProjectFile[] projectFiles ProjectFile[]
rounds Round[] rounds Round[]
@@ -2378,7 +2452,7 @@ model SubmissionFileRequirement {
} }
model RoundSubmissionVisibility { model RoundSubmissionVisibility {
id String @id @default(cuid()) id String @id @default(cuid())
roundId String roundId String
submissionWindowId String submissionWindowId String
canView Boolean @default(true) canView Boolean @default(true)
@@ -2423,8 +2497,9 @@ model AssignmentIntent {
// ============================================================================= // =============================================================================
model MentorFile { model MentorFile {
id String @id @default(cuid()) id String @id @default(cuid())
mentorAssignmentId String projectId String // Primary access scope: files belong to the team
mentorAssignmentId String? // Nullable audit FK: which assignment uploaded; survives mentor drop
uploadedByUserId String uploadedByUserId String
fileName String fileName String
@@ -2443,13 +2518,15 @@ model MentorFile {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
// Relations // Relations
mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id]) mentorAssignment MentorAssignment? @relation(fields: [mentorAssignmentId], references: [id], onDelete: SetNull)
promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id]) uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id])
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull) promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
comments MentorFileComment[] comments MentorFileComment[]
promotionEvents SubmissionPromotionEvent[] promotionEvents SubmissionPromotionEvent[]
@@index([projectId])
@@index([mentorAssignmentId]) @@index([mentorAssignmentId])
@@index([uploadedByUserId]) @@index([uploadedByUserId])
} }
@@ -2467,9 +2544,9 @@ model MentorFileComment {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Relations // Relations
mentorFile MentorFile @relation(fields: [mentorFileId], references: [id], onDelete: Cascade) mentorFile MentorFile @relation(fields: [mentorFileId], references: [id], onDelete: Cascade)
author User @relation("MentorFileCommentAuthor", fields: [authorId], references: [id]) author User @relation("MentorFileCommentAuthor", fields: [authorId], references: [id])
parentComment MentorFileComment? @relation("CommentThread", fields: [parentCommentId], references: [id], onDelete: Cascade) parentComment MentorFileComment? @relation("CommentThread", fields: [parentCommentId], references: [id], onDelete: Cascade)
replies MentorFileComment[] @relation("CommentThread") replies MentorFileComment[] @relation("CommentThread")
@@index([mentorFileId]) @@index([mentorFileId])
@@ -2478,14 +2555,14 @@ model MentorFileComment {
} }
model SubmissionPromotionEvent { model SubmissionPromotionEvent {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String projectId String
roundId String roundId String
slotKey String slotKey String
sourceType SubmissionPromotionSource sourceType SubmissionPromotionSource
sourceFileId String? sourceFileId String?
promotedById String promotedById String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
// Relations // Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@ -2623,3 +2700,273 @@ model ResultUnlockEvent {
@@index([resultLockId]) @@index([resultLockId])
@@index([unlockedById]) @@index([unlockedById])
} }
// ─────────────────────────────────────────────────────────────────────────────
// Grand-finale logistics (PR 1: finalist confirmation flow)
// ─────────────────────────────────────────────────────────────────────────────
enum WaitlistEntryStatus {
WAITING // available for promotion
PROMOTED // moved into a finalist slot
USED // promoted and confirmation flow completed (declined or accepted)
}
enum FinalistConfirmationStatus {
PENDING // sent, awaiting team response
CONFIRMED // team accepted, attendees selected
DECLINED // team explicitly declined
EXPIRED // deadline passed without response
SUPERSEDED // admin manually overrode (e.g. unconfirmed to allow quota decrease)
}
model FinalistSlotQuota {
id String @id @default(cuid())
programId String
category CompetitionCategory
quota Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
@@unique([programId, category])
@@index([programId])
}
model WaitlistEntry {
id String @id @default(cuid())
programId String
projectId String @unique
category CompetitionCategory
rank Int
status WaitlistEntryStatus @default(WAITING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([programId, category, rank])
@@index([programId, category, status])
}
model FinalistConfirmation {
id String @id @default(cuid())
projectId String @unique
category CompetitionCategory
status FinalistConfirmationStatus @default(PENDING)
deadline DateTime
token String @unique
confirmedAt DateTime?
declinedAt DateTime?
declineReason String? // optional free-text on decline
expiredAt DateTime?
promotedFromWaitlistEntryId String? @unique // null for original finalists
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
attendingMembers AttendingMember[]
@@index([status, deadline]) // for cron scan
@@index([category, status])
}
model AttendingMember {
id String @id @default(cuid())
confirmationId String
userId String // must be a TeamMember of the same project (validated at write time)
needsVisa Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
confirmation FinalistConfirmation @relation(fields: [confirmationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
flightDetail FlightDetail?
visaApplication VisaApplication?
lunchPick MemberLunchPick?
@@unique([confirmationId, userId])
@@index([userId])
}
// ─────────────────────────────────────────────────────────────────────────────
// Grand-finale logistics (PR 2: hotels + flight tracking)
// ─────────────────────────────────────────────────────────────────────────────
enum FlightDetailStatus {
PENDING // team submitted details, admin not yet reviewed
CONFIRMED // admin verified booking
}
model Hotel {
id String @id @default(cuid())
programId String @unique // 1:1 — one hotel per edition
name String
address String? @db.Text
link String? // external URL to hotel page / booking confirmation
notes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
}
model FlightDetail {
id String @id @default(cuid())
attendingMemberId String @unique // 1:1
arrivalAt DateTime?
arrivalFlightNumber String?
arrivalAirport String?
departureAt DateTime?
departureFlightNumber String?
departureAirport String?
status FlightDetailStatus @default(PENDING)
adminNotes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
@@index([status])
}
// ─────────────────────────────────────────────────────────────────────────────
// Grand-finale visa tracking (PR 4)
// Process metadata only — no document storage. Passport scans / invitation
// letters / decision documents are exchanged over email; this model just
// records what stage the application is at, key dates, and free-text notes.
// ─────────────────────────────────────────────────────────────────────────────
enum VisaStatus {
NOT_NEEDED
REQUESTED
INVITATION_SENT
APPOINTMENT_BOOKED
GRANTED
DENIED
}
model VisaApplication {
id String @id @default(cuid())
attendingMemberId String @unique // 1:1
status VisaStatus @default(REQUESTED)
nationality String? // self-declared, optional
invitationSentAt DateTime?
appointmentAt DateTime?
decisionAt DateTime? // GRANTED or DENIED date
notes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
@@index([status])
}
// ─────────────────────────────────────────────────────────────────────────────
// Grand-finale lunch event (PR 6)
// Single configurable lunch event per edition. Each attending member has a
// 1:1 MemberLunchPick (auto-created via lunch-pick-sync). External attendees
// can be standalone or attached to a finalist project. Allergens use the
// EU 14 regulated list; dishes carry dietary tags.
// ─────────────────────────────────────────────────────────────────────────────
enum DietaryTag {
VEGETARIAN
VEGAN
GLUTEN_FREE
PESCATARIAN
}
enum Allergen {
GLUTEN
CRUSTACEANS
EGGS
FISH
PEANUTS
SOYBEANS
MILK
TREE_NUTS
CELERY
MUSTARD
SESAME
SULPHITES
LUPIN
MOLLUSCS
}
model LunchEvent {
id String @id @default(cuid())
programId String @unique
enabled Boolean @default(false)
eventAt DateTime?
endAt DateTime?
venue String?
notes String? @db.Text
changeCutoffHours Int @default(48)
reminderHoursBeforeDeadline Int?
cronEnabled Boolean @default(true)
extraRecipients String[] @default([])
reminderSentAt DateTime?
recapSentAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
dishes Dish[]
externalAttendees ExternalAttendee[]
}
model Dish {
id String @id @default(cuid())
lunchEventId String
name String
sortOrder Int @default(0)
dietaryTags DietaryTag[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
memberPicks MemberLunchPick[]
externals ExternalAttendee[]
@@index([lunchEventId])
}
model MemberLunchPick {
id String @id @default(cuid())
attendingMemberId String @unique
dishId String?
allergens Allergen[] @default([])
allergenOther String?
pickedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull)
@@index([dishId])
}
model ExternalAttendee {
id String @id @default(cuid())
lunchEventId String
projectId String?
name String
email String?
roleNote String?
dishId String?
allergens Allergen[] @default([])
allergenOther String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull)
@@index([lunchEventId])
@@index([projectId])
}

View File

@@ -317,7 +317,6 @@ async function main() {
{ 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: '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!' },
] ]
const staffUsers: Record<string, string> = {} const staffUsers: Record<string, string> = {}

View File

@@ -0,0 +1,101 @@
/**
* One-shot: remove leaked test data from dev DB.
*
* Test runs that crashed before reaching `afterAll` left orphan test users +
* programs. This mirrors `tests/helpers.ts#cleanupTestData` with the same
* reverse-dependency order, applied to all programs whose name matches the
* test patterns.
*
* Run: npx tsx scripts/cleanup-test-pollution.ts
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const TEST_PROGRAM_PATTERNS = [
'Test Program prog-%',
'getCandidates-%',
'bulk-%',
'source-flag-%',
'mentor-files-%',
'mentor-config-%',
]
async function main() {
const programs = await prisma.program.findMany({
where: {
OR: TEST_PROGRAM_PATTERNS.map((p) => ({ name: { startsWith: p.replace('%', '') } })),
},
select: { id: true, name: true },
})
console.log(`Found ${programs.length} test programs:`)
programs.forEach((p) => console.log(` - ${p.id} ${p.name}`))
for (const program of programs) {
const programId = program.id
console.log(`\nCleaning ${program.name}...`)
// MentorAssignment isn't in cleanupTestData — kill it first
const ma = await prisma.mentorAssignment.deleteMany({
where: { project: { programId } },
})
if (ma.count > 0) console.log(` ${ma.count} MentorAssignment`)
// Mirror tests/helpers.ts#cleanupTestData order
await prisma.cohortProject.deleteMany({ where: { cohort: { round: { competition: { programId } } } } })
await prisma.cohort.deleteMany({ where: { round: { competition: { programId } } } })
await prisma.liveProgressCursor.deleteMany({ where: { round: { competition: { programId } } } })
await prisma.filteringResult.deleteMany({ where: { round: { competition: { programId } } } })
await prisma.filteringRule.deleteMany({ where: { round: { competition: { programId } } } })
await prisma.filteringJob.deleteMany({ where: { round: { competition: { programId } } } })
await prisma.assignmentJob.deleteMany({ where: { round: { competition: { programId } } } })
await prisma.conflictOfInterest.deleteMany({ where: { assignment: { round: { competition: { programId } } } } })
await prisma.evaluation.deleteMany({ where: { assignment: { round: { competition: { programId } } } } })
await prisma.assignment.deleteMany({ where: { round: { competition: { programId } } } })
await prisma.evaluationForm.deleteMany({ where: { round: { competition: { programId } } } })
await prisma.fileRequirement.deleteMany({ where: { round: { competition: { programId } } } })
await prisma.gracePeriod.deleteMany({ where: { round: { competition: { programId } } } })
await prisma.reminderLog.deleteMany({ where: { round: { competition: { programId } } } })
await prisma.evaluationSummary.deleteMany({ where: { round: { competition: { programId } } } })
await prisma.evaluationDiscussion.deleteMany({ where: { round: { competition: { programId } } } })
await prisma.projectRoundState.deleteMany({ where: { round: { competition: { programId } } } })
await prisma.awardEligibility.deleteMany({ where: { award: { program: { id: programId } } } })
await prisma.awardVote.deleteMany({ where: { award: { program: { id: programId } } } })
await prisma.awardJuror.deleteMany({ where: { award: { program: { id: programId } } } })
await prisma.specialAward.deleteMany({ where: { programId } })
await prisma.round.deleteMany({ where: { competition: { programId } } })
await prisma.competition.deleteMany({ where: { programId } })
await prisma.projectStatusHistory.deleteMany({ where: { project: { programId } } })
await prisma.projectFile.deleteMany({ where: { project: { programId } } })
await prisma.projectTag.deleteMany({ where: { project: { programId } } })
await prisma.project.deleteMany({ where: { programId } })
await prisma.program.deleteMany({ where: { id: programId } })
console.log(' cascade complete')
}
// Delete test users (@test.local). Catch any audit-log refs first.
const testUsers = await prisma.user.findMany({
where: { email: { endsWith: '@test.local' } },
select: { id: true },
})
const testUserIds = testUsers.map((u) => u.id)
console.log(`\nDeleting ${testUserIds.length} @test.local users...`)
if (testUserIds.length > 0) {
await prisma.decisionAuditLog.deleteMany({ where: { actorId: { in: testUserIds } } })
await prisma.auditLog.deleteMany({ where: { userId: { in: testUserIds } } })
// Any remaining MentorAssignments referencing these users (e.g., from other tests)
await prisma.mentorAssignment.deleteMany({ where: { mentorId: { in: testUserIds } } })
await prisma.user.deleteMany({ where: { id: { in: testUserIds } } })
}
console.log('\nDone.')
}
main()
.then(() => prisma.$disconnect())
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})

View File

@@ -58,7 +58,7 @@ export default function EditAwardPage({
const [votingEndAt, setVotingEndAt] = useState('') const [votingEndAt, setVotingEndAt] = useState('')
const [evaluationRoundId, setEvaluationRoundId] = useState('') const [evaluationRoundId, setEvaluationRoundId] = useState('')
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN') const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
const [decisionMode, setDecisionMode] = useState<'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION'>('JURY_VOTE') const [decisionMode, setDecisionMode] = useState<'JURY_VOTE' | 'ADMIN_DECISION'>('JURY_VOTE')
// Helper to format date for datetime-local input // Helper to format date for datetime-local input
const formatDateForInput = (date: Date | string | null | undefined): string => { const formatDateForInput = (date: Date | string | null | undefined): string => {
@@ -236,7 +236,6 @@ export default function EditAwardPage({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="JURY_VOTE">Jury Vote tallied from all jurors</SelectItem> <SelectItem value="JURY_VOTE">Jury Vote tallied from all jurors</SelectItem>
<SelectItem value="AWARD_MASTER_DECISION">Award Master sponsor picks winner</SelectItem>
<SelectItem value="ADMIN_DECISION">Admin Decision admin selects winner</SelectItem> <SelectItem value="ADMIN_DECISION">Admin Decision admin selects winner</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -335,20 +335,20 @@ function RoundsDndGrid({
function ConfidenceBadge({ confidence }: { confidence: number }) { function ConfidenceBadge({ confidence }: { confidence: number }) {
if (confidence > 0.8) { if (confidence > 0.8) {
return ( return (
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs tabular-nums"> <Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700 text-xs tabular-nums">
{Math.round(confidence * 100)}% {Math.round(confidence * 100)}%
</Badge> </Badge>
) )
} }
if (confidence >= 0.5) { if (confidence >= 0.5) {
return ( return (
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-400 text-xs tabular-nums"> <Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700 text-xs tabular-nums">
{Math.round(confidence * 100)}% {Math.round(confidence * 100)}%
</Badge> </Badge>
) )
} }
return ( return (
<Badge variant="outline" className="border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-950/30 dark:text-red-400 text-xs tabular-nums"> <Badge variant="outline" className="border-red-300 bg-red-50 text-red-700 text-xs tabular-nums">
{Math.round(confidence * 100)}% {Math.round(confidence * 100)}%
</Badge> </Badge>
) )
@@ -408,7 +408,7 @@ export default function AwardDetailPage({
// Deferred queries - only load when needed // Deferred queries - only load when needed
const { data: allUsers } = trpc.user.list.useQuery( const { data: allUsers } = trpc.user.list.useQuery(
{ page: 1, perPage: 100, roles: ['JURY_MEMBER', 'AWARD_MASTER'] }, { page: 1, perPage: 100, roles: ['JURY_MEMBER'] },
{ enabled: activeTab === 'jurors' } { enabled: activeTab === 'jurors' }
) )
const { data: juryGroups } = trpc.juryGroup.list.useQuery( const { data: juryGroups } = trpc.juryGroup.list.useQuery(
@@ -513,6 +513,13 @@ export default function AwardDetailPage({
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
const notifyJurors = trpc.specialAward.notifyJurors.useMutation({
onSuccess: (data) => {
const failedNote = data.failed > 0 ? ` (${data.failed} failed)` : ''
toast.success(`Reminder sent to ${data.sent} of ${data.targeted} juror(s)${failedNote}`)
},
onError: (err) => toast.error(err.message),
})
const setWinner = trpc.specialAward.setWinner.useMutation({ const setWinner = trpc.specialAward.setWinner.useMutation({
onSuccess: invalidateAward, onSuccess: invalidateAward,
}) })
@@ -890,8 +897,8 @@ export default function AwardDetailPage({
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Eligible</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Eligible</p>
<p className="text-2xl font-bold tabular-nums">{award.eligibleCount}</p> <p className="text-2xl font-bold tabular-nums">{award.eligibleCount}</p>
</div> </div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/40"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100">
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" /> <CheckCircle2 className="h-5 w-5 text-emerald-600" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -903,8 +910,8 @@ export default function AwardDetailPage({
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p>
<p className="text-2xl font-bold tabular-nums">{(award as any).totalAssessed ?? award._count.eligibilities}</p> <p className="text-2xl font-bold tabular-nums">{(award as any).totalAssessed ?? award._count.eligibilities}</p>
</div> </div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" /> <ListChecks className="h-5 w-5 text-blue-600" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -916,8 +923,8 @@ export default function AwardDetailPage({
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Jurors</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Jurors</p>
<p className="text-2xl font-bold tabular-nums">{award._count.jurors}</p> <p className="text-2xl font-bold tabular-nums">{award._count.jurors}</p>
</div> </div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-100 dark:bg-violet-950/40"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-100">
<Users className="h-5 w-5 text-violet-600 dark:text-violet-400" /> <Users className="h-5 w-5 text-violet-600" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -929,8 +936,8 @@ export default function AwardDetailPage({
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Votes</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Votes</p>
<p className="text-2xl font-bold tabular-nums">{award._count.votes}</p> <p className="text-2xl font-bold tabular-nums">{award._count.votes}</p>
</div> </div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-950/40"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100">
<Vote className="h-5 w-5 text-amber-600 dark:text-amber-400" /> <Vote className="h-5 w-5 text-amber-600" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -1335,7 +1342,7 @@ export default function AwardDetailPage({
{/* Jurors Tab */} {/* Jurors Tab */}
<TabsContent value="jurors" className="space-y-4"> <TabsContent value="jurors" className="space-y-4">
<div className="flex gap-2"> <div className="flex flex-wrap items-center gap-2">
<Select value={selectedJurorId} onValueChange={setSelectedJurorId}> <Select value={selectedJurorId} onValueChange={setSelectedJurorId}>
<SelectTrigger className="w-64"> <SelectTrigger className="w-64">
<SelectValue placeholder="Select a juror..." /> <SelectValue placeholder="Select a juror..." />
@@ -1355,6 +1362,19 @@ export default function AwardDetailPage({
<UserPlus className="mr-2 h-4 w-4" /> <UserPlus className="mr-2 h-4 w-4" />
Add Juror Add Juror
</Button> </Button>
{jurors && jurors.length > 0 && (
<Button
variant="outline"
onClick={() => notifyJurors.mutate({ awardId })}
disabled={notifyJurors.isPending}
className="ml-auto"
>
<Mail className="mr-2 h-4 w-4" />
{notifyJurors.isPending
? 'Sending...'
: `Send reminder to all (${jurors.length})`}
</Button>
)}
</div> </div>
{/* Import from Jury Group */} {/* Import from Jury Group */}
@@ -1498,7 +1518,6 @@ export default function AwardDetailPage({
onSubmit={async (rows) => { onSubmit={async (rows) => {
await bulkInvite.mutateAsync({ await bulkInvite.mutateAsync({
awardId, awardId,
role: 'AWARD_MASTER',
invitees: rows.map((r) => ({ name: r.name || undefined, email: r.email })), invitees: rows.map((r) => ({ name: r.name || undefined, email: r.email })),
}) })
}} }}
@@ -1549,11 +1568,23 @@ export default function AwardDetailPage({
/> />
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
notifyJurors.mutate({ awardId, userIds: [j.userId] })
}
disabled={notifyJurors.isPending}
title="Send reminder email"
>
<Mail className="h-4 w-4" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleRemoveJuror(j.userId)} onClick={() => handleRemoveJuror(j.userId)}
disabled={removeJuror.isPending} disabled={removeJuror.isPending}
title="Remove juror"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
@@ -1581,7 +1612,7 @@ export default function AwardDetailPage({
{/* Rounds Tab */} {/* Rounds Tab */}
<TabsContent value="rounds" className="space-y-4"> <TabsContent value="rounds" className="space-y-4">
{award.eligibilityMode !== 'SEPARATE_POOL' && ( {award.eligibilityMode !== 'SEPARATE_POOL' && (
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300"> <div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800">
<Info className="h-4 w-4 mt-0.5 shrink-0" /> <Info className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm"> <p className="text-sm">
Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects. Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects.
@@ -1589,7 +1620,7 @@ export default function AwardDetailPage({
</div> </div>
)} )}
{!award.competitionId && ( {!award.competitionId && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300"> <div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" /> <AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm"> <p className="text-sm">
Link this award to a competition first before creating rounds. Link this award to a competition first before creating rounds.
@@ -1719,16 +1750,16 @@ export default function AwardDetailPage({
return ( return (
<TableRow <TableRow
key={r.project.id} key={r.project.id}
className={isWinner ? 'bg-amber-50/80 dark:bg-amber-950/20' : ''} className={isWinner ? 'bg-amber-50/80' : ''}
> >
<TableCell> <TableCell>
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold ${ <span className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold ${
i === 0 i === 0
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300' ? 'bg-amber-100 text-amber-800'
: i === 1 : i === 1
? 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-300' ? 'bg-slate-200 text-slate-700'
: i === 2 : i === 2
? 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300' ? 'bg-orange-100 text-orange-800'
: 'text-muted-foreground' : 'text-muted-foreground'
}`}> }`}>
{i + 1} {i + 1}

View File

@@ -35,7 +35,7 @@ import {
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { toast } from 'sonner' import { toast } from 'sonner'
import { cn, formatEnumLabel } from '@/lib/utils' import { cn, formatEnumLabel } from '@/lib/utils'
import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot } from 'lucide-react' import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot, Trophy } from 'lucide-react'
const capModeLabels = { const capModeLabels = {
HARD: 'Hard Cap', HARD: 'Hard Cap',
@@ -301,10 +301,10 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
</div> </div>
</div> </div>
{/* Round assignments */} {/* Round + Special-award assignments */}
{(group as any).rounds?.length > 0 && ( {((group as any).rounds?.length > 0 || (group as any).awards?.length > 0) && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{(group as any).rounds.map((r: any) => ( {(group as any).rounds?.map((r: any) => (
<Badge <Badge
key={r.id} key={r.id}
variant="outline" variant="outline"
@@ -319,6 +319,21 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
{r.name} {r.name}
</Badge> </Badge>
))} ))}
{(group as any).awards?.map((a: any) => (
<Badge
key={a.id}
variant="outline"
className={cn(
'text-[10px] gap-1',
a.status === 'VOTING_OPEN' && 'border-amber-300 bg-amber-50 text-amber-700',
a.status === 'CLOSED' && 'border-emerald-300 bg-emerald-50 text-emerald-700',
(a.status === 'DRAFT' || a.status === 'NOMINATIONS_OPEN') && 'border-slate-200 text-slate-500',
)}
>
<Trophy className="h-2.5 w-2.5" />
{a.name}
</Badge>
))}
</div> </div>
)} )}

View File

@@ -75,7 +75,6 @@ const ROLE_OPTIONS = [
{ value: 'MENTOR', label: 'Mentors' }, { value: 'MENTOR', label: 'Mentors' },
{ value: 'OBSERVER', label: 'Observers' }, { value: 'OBSERVER', label: 'Observers' },
{ value: 'APPLICANT', label: 'Applicants' }, { value: 'APPLICANT', label: 'Applicants' },
{ value: 'AWARD_MASTER', label: 'Award Masters' },
] ]
type AccessRule = type AccessRule =

View File

@@ -60,7 +60,6 @@ const ROLE_OPTIONS = [
{ value: 'MENTOR', label: 'Mentors' }, { value: 'MENTOR', label: 'Mentors' },
{ value: 'OBSERVER', label: 'Observers' }, { value: 'OBSERVER', label: 'Observers' },
{ value: 'APPLICANT', label: 'Applicants' }, { value: 'APPLICANT', label: 'Applicants' },
{ value: 'AWARD_MASTER', label: 'Award Masters' },
] ]
type AccessRule = type AccessRule =

View File

@@ -0,0 +1,83 @@
'use client'
import { useState } from 'react'
import { useEdition } from '@/contexts/edition-context'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
CheckCircle2,
Hotel as HotelIcon,
Plane,
Salad,
ScrollText,
Stamp,
} from 'lucide-react'
import { ConfirmationsTab } from '@/components/admin/logistics/confirmations-tab'
import { TravelTab } from '@/components/admin/logistics/travel-tab'
import { HotelsTab } from '@/components/admin/logistics/hotels-tab'
import { VisasTab } from '@/components/admin/logistics/visas-tab'
import { LunchTab } from '@/components/admin/logistics/lunch-tab'
export default function LogisticsPage() {
const { currentEdition } = useEdition()
const [tab, setTab] = useState('confirmations')
if (!currentEdition) {
return (
<p className="text-muted-foreground py-12 text-center text-sm">
Select an edition to view logistics.
</p>
)
}
const programId = currentEdition.id
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Logistics</h1>
<p className="text-muted-foreground">
Operational hub for the grand finale: confirmations, travel, hotels, and more.
</p>
</div>
<Tabs value={tab} onValueChange={setTab} className="space-y-6">
<TabsList className="h-auto w-full justify-start overflow-x-auto pb-2">
<TabsTrigger value="confirmations">
<CheckCircle2 className="mr-2 h-4 w-4" /> Confirmations
</TabsTrigger>
<TabsTrigger value="travel">
<Plane className="mr-2 h-4 w-4" /> Travel
</TabsTrigger>
<TabsTrigger value="hotels">
<HotelIcon className="mr-2 h-4 w-4" /> Hotels
</TabsTrigger>
<TabsTrigger value="visas">
<Stamp className="mr-2 h-4 w-4" /> Visas
</TabsTrigger>
<TabsTrigger value="lunch">
<Salad className="mr-2 h-4 w-4" /> Lunch
</TabsTrigger>
<TabsTrigger value="email-templates" disabled>
<ScrollText className="mr-2 h-4 w-4" /> Email Templates
<span className="text-muted-foreground ml-1 text-xs">(soon)</span>
</TabsTrigger>
</TabsList>
<TabsContent value="confirmations">
<ConfirmationsTab programId={programId} />
</TabsContent>
<TabsContent value="travel">
<TravelTab programId={programId} />
</TabsContent>
<TabsContent value="hotels">
<HotelsTab programId={programId} />
</TabsContent>
<TabsContent value="visas">
<VisasTab programId={programId} />
</TabsContent>
<TabsContent value="lunch">
<LunchTab programId={programId} />
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -48,6 +48,22 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { UserAvatar } from '@/components/shared/user-avatar' import { UserAvatar } from '@/components/shared/user-avatar'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { import {
@@ -69,6 +85,11 @@ import {
LogIn, LogIn,
Calendar, Calendar,
Clock, Clock,
Link as LinkIcon,
Copy,
Check,
Plus,
X,
} from 'lucide-react' } from 'lucide-react'
import { getCountryName, getCountryFlag } from '@/lib/countries' import { getCountryName, getCountryFlag } from '@/lib/countries'
import { formatRelativeTime } from '@/lib/utils' import { formatRelativeTime } from '@/lib/utils'
@@ -97,7 +118,6 @@ const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
PROGRAM_ADMIN: 'default', PROGRAM_ADMIN: 'default',
SUPER_ADMIN: 'default', SUPER_ADMIN: 'default',
APPLICANT: 'secondary', APPLICANT: 'secondary',
AWARD_MASTER: 'outline',
AUDIENCE: 'outline', AUDIENCE: 'outline',
} }
@@ -112,8 +132,45 @@ 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 generateAccessLink = trpc.user.generateAccessLink.useMutation()
const startImpersonation = trpc.user.startImpersonation.useMutation() const startImpersonation = trpc.user.startImpersonation.useMutation()
const [accessLinkOpen, setAccessLinkOpen] = useState(false)
const [accessLink, setAccessLink] = useState<{
url: string
kind: 'setup' | 'magic_login'
expiresAt: Date
} | null>(null)
const [linkCopied, setLinkCopied] = useState(false)
const handleGenerateAccessLink = async () => {
try {
const result = await generateAccessLink.mutateAsync({ userId })
setAccessLink({
url: result.url,
kind: result.kind,
expiresAt: new Date(result.expiresAt),
})
setLinkCopied(false)
setAccessLinkOpen(true)
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to generate access link'
)
}
}
const handleCopyAccessLink = async () => {
if (!accessLink) return
try {
await navigator.clipboard.writeText(accessLink.url)
setLinkCopied(true)
toast.success('Link copied to clipboard')
} catch {
toast.error('Could not copy — please select and copy the link manually')
}
}
// 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(
{ mentorId: userId, page: 1, perPage: 50 }, { mentorId: userId, page: 1, perPage: 50 },
@@ -138,6 +195,10 @@ export default function MemberDetailPage() {
const [maxAssignments, setMaxAssignments] = useState<string>('') const [maxAssignments, setMaxAssignments] = useState<string>('')
const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false) const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false)
const [pendingSuperAdminRole, setPendingSuperAdminRole] = useState(false) const [pendingSuperAdminRole, setPendingSuperAdminRole] = useState(false)
const [pendingAdditionalRole, setPendingAdditionalRole] = useState<{
role: 'JURY_MEMBER' | 'OBSERVER' | 'MENTOR'
action: 'add' | 'remove'
} | null>(null)
const [additionalRoles, setAdditionalRoles] = useState<string[]>([]) const [additionalRoles, setAdditionalRoles] = useState<string[]>([])
useEffect(() => { useEffect(() => {
@@ -154,7 +215,7 @@ export default function MemberDetailPage() {
const handleSave = async () => { const handleSave = async () => {
try { try {
const allRoles = [role, ...additionalRoles.filter((r) => r !== role)] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'> const allRoles = [role, ...additionalRoles.filter((r) => r !== role)] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'>
await updateUser.mutateAsync({ await updateUser.mutateAsync({
id: userId, id: userId,
email: email || undefined, email: email || undefined,
@@ -283,6 +344,21 @@ export default function MemberDetailPage() {
{user.status === 'INVITED' ? 'Resend Invite' : 'Send Invitation'} {user.status === 'INVITED' ? 'Resend Invite' : 'Send Invitation'}
</Button> </Button>
)} )}
{user.status !== 'SUSPENDED' && (
<Button
variant="outline"
onClick={handleGenerateAccessLink}
disabled={generateAccessLink.isPending}
title="Generate a one-time link to share manually if email isn't reaching them"
>
{generateAccessLink.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LinkIcon className="mr-2 h-4 w-4" />
)}
Copy Access Link
</Button>
)}
<Button <Button
variant="outline" variant="outline"
onClick={handleImpersonate} onClick={handleImpersonate}
@@ -622,7 +698,6 @@ export default function MemberDetailPage() {
<SelectItem value="MENTOR">Mentor</SelectItem> <SelectItem value="MENTOR">Mentor</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem> <SelectItem value="OBSERVER">Observer</SelectItem>
<SelectItem value="APPLICANT">Applicant</SelectItem> <SelectItem value="APPLICANT">Applicant</SelectItem>
<SelectItem value="AWARD_MASTER">Award Master</SelectItem>
<SelectItem value="AUDIENCE">Audience</SelectItem> <SelectItem value="AUDIENCE">Audience</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -630,26 +705,72 @@ export default function MemberDetailPage() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Additional Roles</Label> <Label>Additional Roles</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Grant additional dashboard access beyond the primary role Grant additional dashboard access beyond the primary role.
Click the menu to add or remove a role you&apos;ll be
asked to confirm each change.
</p> </p>
<div className="grid grid-cols-2 gap-2"> <div className="flex flex-wrap items-center gap-2">
{(['JURY_MEMBER', 'OBSERVER', 'MENTOR', 'AWARD_MASTER'] as const) {additionalRoles.length === 0 ? (
.filter((r) => r !== role) <span className="text-sm text-muted-foreground italic">
.map((r) => ( None only the primary role above
<label key={r} className="flex items-center gap-2 text-sm cursor-pointer"> </span>
<Checkbox ) : (
checked={additionalRoles.includes(r)} additionalRoles.map((r) => (
onCheckedChange={(checked) => { <Badge
if (checked) { key={r}
setAdditionalRoles((prev) => [...prev, r]) variant={roleColors[r] || 'secondary'}
} else { className="gap-1.5 pl-2 pr-1 py-0.5"
setAdditionalRoles((prev) => prev.filter((x) => x !== r)) >
}
}}
/>
{r.replace(/_/g, ' ')} {r.replace(/_/g, ' ')}
</label> <button
))} type="button"
aria-label={`Remove ${r.replace(/_/g, ' ')} role`}
className="rounded-full hover:bg-foreground/10 p-0.5 transition-colors"
onClick={() =>
setPendingAdditionalRole({
role: r as 'JURY_MEMBER' | 'OBSERVER' | 'MENTOR',
action: 'remove',
})
}
>
<X className="h-3 w-3" />
</button>
</Badge>
))
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" type="button">
<Plus className="mr-1 h-3.5 w-3.5" />
Manage roles
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Toggle additional roles</DropdownMenuLabel>
<DropdownMenuSeparator />
{(['JURY_MEMBER', 'OBSERVER', 'MENTOR'] as const)
.filter((r) => r !== role)
.map((r) => {
const isAssigned = additionalRoles.includes(r)
return (
<DropdownMenuCheckboxItem
key={r}
checked={isAssigned}
onSelect={(e) => {
e.preventDefault()
setPendingAdditionalRole({
role: r,
action: isAssigned ? 'remove' : 'add',
})
}}
>
{r.replace(/_/g, ' ')}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -821,6 +942,81 @@ export default function MemberDetailPage() {
</Tabs> </Tabs>
{/* Super Admin Confirmation Dialog */} {/* Super Admin Confirmation Dialog */}
<AlertDialog
open={pendingAdditionalRole !== null}
onOpenChange={(open) => { if (!open) setPendingAdditionalRole(null) }}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{pendingAdditionalRole?.action === 'add' ? 'Add' : 'Remove'}{' '}
{pendingAdditionalRole?.role.replace(/_/g, ' ')} role?
</AlertDialogTitle>
<AlertDialogDescription>
{pendingAdditionalRole?.action === 'add' ? (
<>
This will give <strong>{name || user?.name || 'this user'}</strong>{' '}
the {pendingAdditionalRole.role.replace(/_/g, ' ')} dashboard
in addition to their primary role. They&apos;ll be able to
switch between dashboards from the role switcher. Click
&ldquo;Save changes&rdquo; below to apply.
</>
) : (
<>
This will revoke the {pendingAdditionalRole?.role.replace(/_/g, ' ')}
{' '}dashboard from <strong>{name || user?.name || 'this user'}</strong>.
They&apos;ll keep their primary role and any other additional
roles. Click &ldquo;Save changes&rdquo; below to apply.
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setPendingAdditionalRole(null)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
if (!pendingAdditionalRole) return
const { role: r, action } = pendingAdditionalRole
const nextAdditional =
action === 'add'
? additionalRoles.includes(r)
? additionalRoles
: [...additionalRoles, r]
: additionalRoles.filter((x) => x !== r)
const nextAllRoles = [
role,
...nextAdditional.filter((x) => x !== role),
] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'>
try {
await updateUser.mutateAsync({
id: userId,
roles: nextAllRoles,
})
setAdditionalRoles(nextAdditional)
utils.user.get.invalidate({ id: userId })
utils.user.list.invalidate()
toast.success(
action === 'add'
? `${r.replace(/_/g, ' ')} role added`
: `${r.replace(/_/g, ' ')} role removed`,
)
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to update roles',
)
} finally {
setPendingAdditionalRole(null)
}
}}
>
{pendingAdditionalRole?.action === 'add' ? 'Add role' : 'Remove role'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}> <AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
@@ -848,6 +1044,67 @@ export default function MemberDetailPage() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<Dialog open={accessLinkOpen} onOpenChange={setAccessLinkOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LinkIcon className="h-4 w-4" />
Access link ready
</DialogTitle>
<DialogDescription>
{accessLink?.kind === 'magic_login'
? `Share this with ${user.name || user.email} via Slack, WhatsApp, or any channel that reaches them. Clicking it will sign them in directly (their existing password is preserved) and take them to their dashboard.`
: `Share this with ${user.name || user.email} via Slack, WhatsApp, or any channel that reaches them. They'll be walked through setting a password and onboarding.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="rounded-md border bg-muted/40 p-3">
<Input
readOnly
value={accessLink?.url ?? ''}
onFocus={(e) => e.currentTarget.select()}
className="font-mono text-xs bg-background"
/>
</div>
<div className="flex items-center justify-between gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<div className="flex items-center gap-2">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>
Expires {accessLink?.expiresAt.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
{' · '}consumed on first successful login
</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
Don&apos;t paste this in a public channel. Anyone with the link
can sign in as this user until it&apos;s consumed.
</p>
</div>
<DialogFooter className="gap-2 sm:gap-2">
<Button variant="outline" onClick={() => setAccessLinkOpen(false)}>
Close
</Button>
<Button onClick={handleCopyAccessLink}>
{linkCopied ? (
<>
<Check className="mr-2 h-4 w-4" />
Copied
</>
) : (
<>
<Copy className="mr-2 h-4 w-4" />
Copy link
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
) )
} }

View File

@@ -74,7 +74,7 @@ import { useSession } from 'next-auth/react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type Step = 'input' | 'preview' | 'sending' | 'complete' type Step = 'input' | 'preview' | 'sending' | 'complete'
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
interface Assignment { interface Assignment {
projectId: string projectId: string
@@ -106,7 +106,6 @@ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const ROLE_LABELS: Record<Role, string> = { const ROLE_LABELS: Record<Role, string> = {
SUPER_ADMIN: 'Super Admin', SUPER_ADMIN: 'Super Admin',
PROGRAM_ADMIN: 'Program Admin', PROGRAM_ADMIN: 'Program Admin',
AWARD_MASTER: 'Award Master',
JURY_MEMBER: 'Jury Member', JURY_MEMBER: 'Jury Member',
MENTOR: 'Mentor', MENTOR: 'Mentor',
OBSERVER: 'Observer', OBSERVER: 'Observer',
@@ -289,7 +288,7 @@ export default function MemberInvitePage() {
const availableRoles = useMemo((): Role[] => { const availableRoles = useMemo((): Role[] => {
const roles: Role[] = [] const roles: Role[] = []
if (isSuperAdmin) roles.push('SUPER_ADMIN') if (isSuperAdmin) roles.push('SUPER_ADMIN')
if (isAdmin) roles.push('PROGRAM_ADMIN', 'AWARD_MASTER') if (isAdmin) roles.push('PROGRAM_ADMIN')
roles.push('JURY_MEMBER', 'MENTOR', 'OBSERVER') roles.push('JURY_MEMBER', 'MENTOR', 'OBSERVER')
return roles return roles
}, [isSuperAdmin, isAdmin]) }, [isSuperAdmin, isAdmin])
@@ -423,13 +422,11 @@ export default function MemberInvitePage() {
? 'SUPER_ADMIN' ? 'SUPER_ADMIN'
: rawRole === 'PROGRAM_ADMIN' : rawRole === 'PROGRAM_ADMIN'
? 'PROGRAM_ADMIN' ? 'PROGRAM_ADMIN'
: rawRole === 'AWARD_MASTER' : rawRole === 'MENTOR'
? 'AWARD_MASTER' ? 'MENTOR'
: rawRole === 'MENTOR' : rawRole === 'OBSERVER'
? 'MENTOR' ? 'OBSERVER'
: rawRole === 'OBSERVER' : 'JURY_MEMBER'
? 'OBSERVER'
: 'JURY_MEMBER'
const isValidFormat = emailRegex.test(email) const isValidFormat = emailRegex.test(email)
const isDuplicate = email ? seenEmails.has(email) : false const isDuplicate = email ? seenEmails.has(email) : false
const isUnauthorizedAdmin = role === 'SUPER_ADMIN' && !isSuperAdmin const isUnauthorizedAdmin = role === 'SUPER_ADMIN' && !isSuperAdmin
@@ -910,7 +907,7 @@ export default function MemberInvitePage() {
</div> </div>
{!sendInvitation && ( {!sendInvitation && (
<div className="flex items-start gap-3 rounded-lg bg-blue-500/10 p-4 text-blue-700 dark:text-blue-400"> <div className="flex items-start gap-3 rounded-lg bg-blue-500/10 p-4 text-blue-700">
<MailX className="h-5 w-5 shrink-0 mt-0.5" /> <MailX className="h-5 w-5 shrink-0 mt-0.5" />
<div> <div>
<p className="font-medium">No invitations will be sent</p> <p className="font-medium">No invitations will be sent</p>

View File

@@ -1,5 +1,470 @@
import { redirect } from 'next/navigation' 'use client'
export default function MentorsPage() { import { useMemo, useState } from 'react'
redirect('/admin/members') import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ArrowUpDown, GraduationCap, Search, Users } from 'lucide-react'
import { formatEnumLabel } from '@/lib/utils'
import { MentorDetailSheet } from '@/components/admin/mentor/mentor-detail-sheet'
type SortKey = 'name' | 'load' | 'capacity' | 'lastActivity'
function formatRelativePast(date: Date | string | null): string {
if (!date) return '—'
const d = typeof date === 'string' ? new Date(date) : date
const ms = Date.now() - d.getTime()
const days = Math.floor(ms / 86_400_000)
const hours = Math.floor(ms / 3_600_000)
if (days >= 1) return `${days}d ago`
if (hours >= 1) return `${hours}h ago`
const minutes = Math.floor(ms / 60_000)
return `${Math.max(0, minutes)}m ago`
}
const STATUS_BADGE: Record<
'unassigned' | 'assigned' | 'active' | 'stalled',
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
> = {
unassigned: { label: 'Unassigned', variant: 'outline' },
assigned: { label: 'Assigned', variant: 'secondary' },
active: { label: 'Active', variant: 'default' },
stalled: { label: 'Stalled', variant: 'destructive' },
}
type Mentor = {
id: string
name: string | null
email: string
country: string | null
expertiseTags: string[]
currentAssignments: number
completedAssignments: number
maxAssignments: number | null
capacityRemaining: number | null
lastActivityAt: Date | string | null
activeTeams: { id: string; title: string }[]
}
function MentorListPanel() {
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState<SortKey>('load')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const [detailMentorId, setDetailMentorId] = useState<string | null>(null)
const { data, isLoading } = trpc.mentor.getMentorPool.useQuery({})
const filtered = useMemo<Mentor[]>(() => {
if (!data) return []
const q = search.trim().toLowerCase()
let rows: Mentor[] = data.mentors
if (q) {
rows = rows.filter((m) =>
[m.name ?? '', m.email, m.country ?? '', ...m.expertiseTags]
.join(' ')
.toLowerCase()
.includes(q),
)
}
rows = [...rows].sort((a, b) => {
let av: string | number = 0
let bv: string | number = 0
switch (sortKey) {
case 'name':
av = (a.name ?? '').toLowerCase()
bv = (b.name ?? '').toLowerCase()
break
case 'load':
av = a.currentAssignments
bv = b.currentAssignments
break
case 'capacity':
av = a.capacityRemaining ?? Number.MAX_SAFE_INTEGER
bv = b.capacityRemaining ?? Number.MAX_SAFE_INTEGER
break
case 'lastActivity':
av = a.lastActivityAt ? new Date(a.lastActivityAt).getTime() : 0
bv = b.lastActivityAt ? new Date(b.lastActivityAt).getTime() : 0
break
}
if (av < bv) return sortDir === 'asc' ? -1 : 1
if (av > bv) return sortDir === 'asc' ? 1 : -1
return 0
})
return rows
}, [data, search, sortKey, sortDir])
const toggleSort = (key: SortKey) => {
if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
else {
setSortKey(key)
setSortDir(key === 'name' ? 'asc' : 'desc')
}
}
const SortHeader = ({
k,
children,
align = 'left',
}: {
k: SortKey
children: React.ReactNode
align?: 'left' | 'right'
}) => (
<button
type="button"
onClick={() => toggleSort(k)}
className={`flex items-center gap-1 text-sm font-medium ${align === 'right' ? 'ml-auto' : ''}`}
>
{children}
<ArrowUpDown
className={`h-3 w-3 ${sortKey === k ? 'text-foreground' : 'text-muted-foreground/50'}`}
/>
</button>
)
return (
<div className="space-y-6">
<Card>
<CardHeader className="space-y-4">
<CardTitle className="text-base">Mentor list</CardTitle>
<div className="relative">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name, email, country, or expertise tag…"
className="pl-9"
/>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : filtered.length === 0 ? (
<div className="text-muted-foreground py-12 text-center text-sm">
{search ? 'No matching mentors.' : 'No mentors yet.'}
</div>
) : (
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>
<SortHeader k="name">Mentor</SortHeader>
</TableHead>
<TableHead>Expertise</TableHead>
<TableHead>Country</TableHead>
<TableHead className="text-right">
<SortHeader k="capacity" align="right">
Capacity
</SortHeader>
</TableHead>
<TableHead>
<SortHeader k="lastActivity">Last activity</SortHeader>
</TableHead>
<TableHead>
<SortHeader k="load">Teams</SortHeader>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((m) => (
<TableRow
key={m.id}
className="cursor-pointer"
onClick={() => setDetailMentorId(m.id)}
>
<TableCell>
<div className="font-medium">{m.name ?? 'Unnamed'}</div>
<div className="text-muted-foreground text-xs">{m.email}</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{m.expertiseTags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
{m.expertiseTags.length > 4 && (
<Badge variant="outline" className="text-xs">
+{m.expertiseTags.length - 4}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-sm">{m.country ?? '—'}</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{m.capacityRemaining != null ? m.capacityRemaining : '∞'}
</TableCell>
<TableCell className="text-sm">
{formatRelativePast(m.lastActivityAt)}
</TableCell>
<TableCell>
{m.activeTeams.length === 0 ? (
<span className="text-muted-foreground text-xs"></span>
) : (
<div className="flex flex-wrap gap-1">
{m.activeTeams.slice(0, 2).map((t) => (
<Badge
key={t.id}
variant="outline"
className="max-w-[12rem] truncate text-xs"
title={t.title}
>
{t.title}
</Badge>
))}
{m.activeTeams.length > 2 && (
<Badge variant="outline" className="text-xs">
+{m.activeTeams.length - 2}
</Badge>
)}
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<MentorDetailSheet
mentorId={detailMentorId}
open={!!detailMentorId}
onOpenChange={(next) => {
if (!next) setDetailMentorId(null)
}}
/>
</div>
)
}
type StatusFilter = 'all' | 'unassigned' | 'assigned' | 'active' | 'stalled'
function MenteeActivityPanel() {
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const { data, isLoading } = trpc.mentor.getMenteeActivity.useQuery({})
const filtered = useMemo(() => {
if (!data) return []
const q = search.trim().toLowerCase()
return data.rows.filter((r) => {
if (statusFilter !== 'all' && r.status !== statusFilter) return false
if (!q) return true
const hay = [
r.project.title,
r.project.country ?? '',
r.teamLead?.name ?? '',
r.teamLead?.email ?? '',
r.mentor?.name ?? '',
r.mentor?.email ?? '',
]
.join(' ')
.toLowerCase()
return hay.includes(q)
})
}, [data, search, statusFilter])
const totals = data?.totals ?? { unassigned: 0, assigned: 0, active: 0, stalled: 0 }
const StatusPill = ({ value, label, count }: { value: StatusFilter; label: string; count: number }) => (
<button
type="button"
onClick={() => setStatusFilter(value)}
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
statusFilter === value
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-muted'
}`}
>
{label} <span className="tabular-nums opacity-80">({count})</span>
</button>
)
return (
<div className="space-y-6">
<Card>
<CardHeader className="space-y-4">
<div className="flex items-center justify-between gap-4">
<CardTitle className="text-base">Mentee teams</CardTitle>
<div className="flex flex-wrap gap-1.5">
<StatusPill
value="all"
label="All"
count={
totals.unassigned + totals.assigned + totals.active + totals.stalled
}
/>
<StatusPill value="unassigned" label="Unassigned" count={totals.unassigned} />
<StatusPill value="assigned" label="Assigned" count={totals.assigned} />
<StatusPill value="active" label="Active" count={totals.active} />
<StatusPill value="stalled" label="Stalled" count={totals.stalled} />
</div>
</div>
<div className="relative">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by project, team lead, or mentor…"
className="pl-9"
/>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : filtered.length === 0 ? (
<div className="text-muted-foreground py-12 text-center text-sm">
{search || statusFilter !== 'all'
? 'No matching teams.'
: 'No teams have requested mentorship yet.'}
</div>
) : (
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Status</TableHead>
<TableHead>Mentor</TableHead>
<TableHead className="text-right">Messages</TableHead>
<TableHead className="text-right">Files</TableHead>
<TableHead>Last activity</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((r) => {
const badge = STATUS_BADGE[r.status]
return (
<TableRow key={r.project.id}>
<TableCell>
<div className="font-medium">{r.project.title}</div>
<div className="text-muted-foreground text-xs">
{r.teamLead?.name ?? r.teamLead?.email ?? '—'}
{r.project.oceanIssue && (
<>
{' · '}
{formatEnumLabel(r.project.oceanIssue)}
</>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={badge.variant} className="text-xs">
{badge.label}
</Badge>
</TableCell>
<TableCell>
{r.mentor ? (
<div className="text-sm">
<div>{r.mentor.name ?? r.mentor.email}</div>
<div className="text-muted-foreground text-xs tabular-nums">
{r.mentor.currentLoad}
{r.mentor.maxAssignments != null
? `/${r.mentor.maxAssignments}`
: ''}
{' load'}
</div>
</div>
) : (
<span className="text-muted-foreground text-sm"></span>
)}
</TableCell>
<TableCell className="text-right tabular-nums">
{r.messageCount}
</TableCell>
<TableCell className="text-right tabular-nums">{r.fileCount}</TableCell>
<TableCell className="text-sm">
{formatRelativePast(r.lastActivityAt as unknown as Date | null)}
</TableCell>
<TableCell className="text-right">
<Button asChild size="sm" variant="outline">
<Link href={`/admin/projects/${r.project.id}/mentor`}>
{r.mentor ? 'Open' : 'Assign'}
</Link>
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
)
}
export default function MentorsListPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mentors</h1>
<p className="text-muted-foreground">
Manage the mentor pool and track mentee teams across the program.
</p>
</div>
<Button asChild variant="outline">
<Link href="/admin/members">
<Users className="mr-2 h-4 w-4" />
Manage Members
</Link>
</Button>
</div>
<Tabs defaultValue="mentors" className="space-y-6">
<TabsList>
<TabsTrigger value="mentors">
<GraduationCap className="mr-2 h-4 w-4" /> Mentors
</TabsTrigger>
<TabsTrigger value="mentees">
<Users className="mr-2 h-4 w-4" /> Mentees & Activity
</TabsTrigger>
</TabsList>
<TabsContent value="mentors">
<MentorListPanel />
</TabsContent>
<TabsContent value="mentees">
<MenteeActivityPanel />
</TabsContent>
</Tabs>
</div>
)
} }

File diff suppressed because it is too large Load Diff

View File

@@ -75,7 +75,9 @@ import {
Eye, Eye,
Plus, Plus,
X, X,
Mail,
} from 'lucide-react' } from 'lucide-react'
import { ProjectEmailDialog } from '@/components/admin/project-email-dialog'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatDateOnly } from '@/lib/utils' import { formatDateOnly } from '@/lib/utils'
import { getCountryName, getCountryFlag } from '@/lib/countries' import { getCountryName, getCountryFlag } from '@/lib/countries'
@@ -161,6 +163,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
// State for remove member confirmation // State for remove member confirmation
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null) const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
const [emailDialogOpen, setEmailDialogOpen] = useState(false)
const addTeamMember = trpc.project.addTeamMember.useMutation({ const addTeamMember = trpc.project.addTeamMember.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -269,14 +272,29 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</div> </div>
</div> </div>
<Button variant="outline" asChild> <div className="flex items-center gap-2">
<Link href={`/admin/projects/${projectId}/edit`}> <Button variant="outline" onClick={() => setEmailDialogOpen(true)}>
<Edit className="mr-2 h-4 w-4" /> <Mail className="mr-2 h-4 w-4" />
Edit Email Team
</Link> </Button>
</Button> <Button variant="outline" asChild>
<Link href={`/admin/projects/${projectId}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
</div>
</div> </div>
{project && (
<ProjectEmailDialog
open={emailDialogOpen}
onClose={() => setEmailDialogOpen(false)}
projectId={project.id}
projectTitle={project.title}
/>
)}
<Separator /> <Separator />
{/* Stats Grid */} {/* Stats Grid */}

View File

@@ -462,7 +462,7 @@ export default function BulkUploadPage() {
return ( return (
<TableRow <TableRow
key={row.project.id} key={row.project.id}
className={row.isComplete ? 'bg-green-50/50 dark:bg-green-950/10' : ''} className={row.isComplete ? 'bg-green-50/50' : ''}
> >
<TableCell> <TableCell>
<Link <Link

View File

@@ -53,15 +53,15 @@ type TeamMemberEntry = {
} }
const ROLE_COLORS: Record<string, string> = { const ROLE_COLORS: Record<string, string> = {
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', LEAD: 'bg-red-100 text-red-700',
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400', MEMBER: 'bg-teal-100 text-teal-700',
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', ADVISOR: 'bg-blue-100 text-blue-700',
} }
const ROLE_AVATAR_COLORS: Record<string, string> = { const ROLE_AVATAR_COLORS: Record<string, string> = {
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', LEAD: 'bg-red-100 text-red-700',
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300', MEMBER: 'bg-teal-100 text-teal-700',
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', ADVISOR: 'bg-blue-100 text-blue-700',
} }
const ROLE_LABELS: Record<string, string> = { const ROLE_LABELS: Record<string, string> = {

View File

@@ -679,7 +679,7 @@ export default function ProjectsPage() {
<Button <Button
variant="outline" variant="outline"
onClick={() => setAiTagDialogOpen(true)} onClick={() => setAiTagDialogOpen(true)}
className={taggingInProgress ? 'border-amber-400 bg-amber-50 dark:bg-amber-950/20' : ''} className={taggingInProgress ? 'border-amber-400 bg-amber-50' : ''}
> >
{taggingInProgress ? ( {taggingInProgress ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin text-amber-600" /> <Loader2 className="mr-2 h-4 w-4 animate-spin text-amber-600" />
@@ -1716,15 +1716,15 @@ export default function ProjectsPage() {
<div className="space-y-6 py-4"> <div className="space-y-6 py-4">
{/* Progress Indicator (when running) */} {/* Progress Indicator (when running) */}
{taggingInProgress && ( {taggingInProgress && (
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900"> <div className="p-4 rounded-lg bg-blue-50 border border-blue-200">
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-blue-600" /> <Loader2 className="h-5 w-5 animate-spin text-blue-600" />
<div className="flex-1"> <div className="flex-1">
<p className="font-medium text-blue-900 dark:text-blue-100"> <p className="font-medium text-blue-900">
AI Tagging in Progress AI Tagging in Progress
</p> </p>
<p className="text-sm text-blue-700 dark:text-blue-300"> <p className="text-sm text-blue-700">
{jobStatus?.status === 'PENDING' {jobStatus?.status === 'PENDING'
? 'Initializing...' ? 'Initializing...'
: `Processing ${jobStatus?.totalProjects || 0} projects with AI...`} : `Processing ${jobStatus?.totalProjects || 0} projects with AI...`}
@@ -1739,12 +1739,12 @@ export default function ProjectsPage() {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-blue-700 dark:text-blue-300"> <span className="text-blue-700">
{jobStatus?.processedCount || 0} of {jobStatus?.totalProjects || '?'} projects processed {jobStatus?.processedCount || 0} of {jobStatus?.totalProjects || '?'} projects processed
{jobStatus?.taggedCount ? ` (${jobStatus.taggedCount} tagged)` : ''} {jobStatus?.taggedCount ? ` (${jobStatus.taggedCount} tagged)` : ''}
</span> </span>
{jobStatus && jobStatus.totalProjects > 0 && ( {jobStatus && jobStatus.totalProjects > 0 && (
<span className="font-medium text-blue-900 dark:text-blue-100"> <span className="font-medium text-blue-900">
{taggingProgressPercent}% {taggingProgressPercent}%
</span> </span>
)} )}
@@ -1767,9 +1767,9 @@ export default function ProjectsPage() {
{taggingResult && !taggingInProgress && ( {taggingResult && !taggingInProgress && (
<div className={`p-4 rounded-lg border ${ <div className={`p-4 rounded-lg border ${
taggingResult.failed > 0 taggingResult.failed > 0
? 'bg-amber-50 dark:bg-amber-950/20 border-amber-200 dark:border-amber-900' ? 'bg-amber-50 border-amber-200'
: taggingResult.processed > 0 : taggingResult.processed > 0
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900' ? 'bg-green-50 border-green-200'
: 'bg-muted border-border' : 'bg-muted border-border'
}`}> }`}>
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
@@ -1804,12 +1804,12 @@ export default function ProjectsPage() {
</div> </div>
{taggingResult.errors.length > 0 && ( {taggingResult.errors.length > 0 && (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<p className="text-sm font-medium text-amber-700 dark:text-amber-300"> <p className="text-sm font-medium text-amber-700">
{taggingResult.errors.length} project{taggingResult.errors.length > 1 ? 's' : ''} failed: {taggingResult.errors.length} project{taggingResult.errors.length > 1 ? 's' : ''} failed:
</p> </p>
<div className="max-h-32 overflow-y-auto rounded bg-background/50 p-2 text-xs space-y-1"> <div className="max-h-32 overflow-y-auto rounded bg-background/50 p-2 text-xs space-y-1">
{taggingResult.errors.map((error, i) => ( {taggingResult.errors.map((error, i) => (
<p key={i} className="text-amber-700 dark:text-amber-300"> <p key={i} className="text-amber-700">
{error} {error}
</p> </p>
))} ))}

View File

@@ -52,6 +52,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatDateOnly } from '@/lib/utils' import { formatDateOnly } from '@/lib/utils'
import { getCountryName, getCountryFlag } from '@/lib/countries'
import { import {
ScoreDistributionChart, ScoreDistributionChart,
EvaluationTimelineChart, EvaluationTimelineChart,
@@ -91,6 +92,17 @@ function ReportsOverview({ scope }: { scope: string | null }) {
{ enabled: hasScope } { enabled: hasScope }
) )
// Applicant nationality breakdown — always runs (scope optional;
// empty scope = global view across all programs).
const { data: nationalityStats, isLoading: nationalityLoading } =
trpc.analytics.getApplicantNationalities.useQuery(scopeInput)
const nationalityScopeLabel = scopeInput.roundId
? 'in this round'
: scopeInput.programId
? `in ${programs?.find((p) => p.id === scopeInput.programId)?.year ?? ''} Edition`
: 'across all programs'
if (isLoading || statsLoading) { if (isLoading || statsLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -198,6 +210,13 @@ function ReportsOverview({ scope }: { scope: string | null }) {
</AnimatedCard> </AnimatedCard>
</div> </div>
{/* Applicant Nationalities */}
<ApplicantNationalitiesCard
data={nationalityStats}
loading={nationalityLoading}
scopeLabel={nationalityScopeLabel}
/>
{/* Score Distribution (if any evaluations exist) */} {/* Score Distribution (if any evaluations exist) */}
{dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && ( {dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && (
<Card> <Card>
@@ -500,6 +519,140 @@ function ReportsOverview({ scope }: { scope: string | null }) {
) )
} }
type NationalityStats = {
total: number
declared: number
notDeclared: number
byCountry: Array<{ country: string; count: number }>
}
function ApplicantNationalitiesCard({
data,
loading,
scopeLabel,
}: {
data: NationalityStats | undefined
loading: boolean
scopeLabel: string
}) {
const [showAll, setShowAll] = useState(false)
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Globe className="h-4 w-4 text-violet-600" />
</div>
Applicant Nationalities
</CardTitle>
<CardDescription>
Self-declared nationality of team members on projects {scopeLabel}.
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : !data || data.total === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Globe className="h-10 w-10 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No applicants in this scope.
</p>
</div>
) : data.declared === 0 ? (
<>
<NationalitySummary declared={data.declared} notDeclared={data.notDeclared} />
<div className="mt-4 flex flex-col items-center justify-center rounded-lg border border-dashed py-8 text-center">
<Globe className="h-8 w-8 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No nationality data yet.
</p>
</div>
</>
) : (
<>
<NationalitySummary declared={data.declared} notDeclared={data.notDeclared} />
<div className="mt-4 rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Country</TableHead>
<TableHead className="text-right w-32">Applicants</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(showAll ? data.byCountry : data.byCountry.slice(0, 10)).map((row) => {
const name = getCountryName(row.country)
const flag = getCountryFlag(row.country)
return (
<TableRow key={row.country}>
<TableCell className="font-medium">
<span className="inline-flex items-center gap-2">
{flag && <span aria-hidden>{flag}</span>}
<span>{name}</span>
{name !== row.country && (
<span className="text-xs text-muted-foreground tabular-nums">
{row.country}
</span>
)}
</span>
</TableCell>
<TableCell className="text-right">
<Badge variant="secondary" className="tabular-nums">
{row.count}
</Badge>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{data.byCountry.length > 10 && (
<div className="mt-3 flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => setShowAll((v) => !v)}
className="gap-1 text-muted-foreground"
>
{showAll
? 'Show top 10'
: `Show all (${data.byCountry.length} countries)`}
<ArrowRight className="h-3.5 w-3.5" />
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
)
}
function NationalitySummary({ declared, notDeclared }: { declared: number; notDeclared: number }) {
return (
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Declared</p>
<p className="text-2xl font-bold tabular-nums">{declared}</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Not declared</p>
<p className="text-2xl font-bold tabular-nums text-muted-foreground">
{notDeclared}
</p>
</div>
</div>
)
}
// Parse selection value: "all:programId" for edition-wide, or roundId // Parse selection value: "all:programId" for edition-wide, or roundId
function parseSelection(value: string | null): { roundId?: string; programId?: string } { function parseSelection(value: string | null): { roundId?: string; programId?: string } {
if (!value) return {} if (!value) return {}

View File

@@ -394,7 +394,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
</Link> </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">
{isReadOnly ? 'Submitted Evaluation' : 'Fill In Evaluation'} {isReadOnly ? 'Submitted Evaluation' : 'Fill In Evaluation'}
</h1> </h1>
<div className="flex items-center gap-2 mt-1 flex-wrap"> <div className="flex items-center gap-2 mt-1 flex-wrap">
@@ -404,8 +404,8 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
variant="secondary" variant="secondary"
className={ className={
project.competitionCategory === 'STARTUP' project.competitionCategory === 'STARTUP'
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300' ? 'bg-violet-100 text-violet-700 border-violet-200'
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300' : 'bg-sky-100 text-sky-700 border-sky-200'
} }
> >
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
@@ -415,7 +415,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
</div> </div>
</div> </div>
<Card className="border-l-4 border-l-amber-500 bg-amber-50/40 dark:bg-amber-950/10"> <Card className="border-l-4 border-l-amber-500 bg-amber-50/40">
<CardContent className="flex items-start gap-3 p-4"> <CardContent className="flex items-start gap-3 p-4">
<UserCheck className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" /> <UserCheck className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1 text-sm"> <div className="flex-1 text-sm">
@@ -431,7 +431,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
</Card> </Card>
{isReadOnly && ( {isReadOnly && (
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50 dark:bg-blue-950/20"> <Card className="border-l-4 border-l-blue-500 bg-blue-50/50">
<CardContent className="flex items-start gap-3 p-4"> <CardContent className="flex items-start gap-3 p-4">
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" /> <Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1">
@@ -446,7 +446,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
)} )}
{hasCOI && !isReadOnly && ( {hasCOI && !isReadOnly && (
<Card className="border-l-4 border-l-red-500 bg-red-50/40 dark:bg-red-950/10"> <Card className="border-l-4 border-l-red-500 bg-red-50/40">
<CardContent className="flex items-start gap-3 p-4"> <CardContent className="flex items-start gap-3 p-4">
<Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" /> <Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
<div className="flex-1 text-sm"> <div className="flex-1 text-sm">

View File

@@ -55,7 +55,7 @@ export default function AdminJurorProxyEvaluatePage({ params: paramsPromise }: P
</Link> </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">
Proxy Evaluations Proxy Evaluations
</h1> </h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">
@@ -205,8 +205,8 @@ function AssignmentRow({ roundId, userId, assignment, mode }: AssignmentRowProps
className={cn( className={cn(
'shrink-0', 'shrink-0',
project.competitionCategory === 'STARTUP' project.competitionCategory === 'STARTUP'
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300' ? 'bg-violet-100 text-violet-700 border-violet-200'
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300', : 'bg-sky-100 text-sky-700 border-sky-200',
)} )}
> >
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}

View File

@@ -91,6 +91,10 @@ import { ProjectStatesTable } from '@/components/admin/round/project-states-tabl
// SubmissionWindowManager removed — round dates + file requirements in Config are sufficient // SubmissionWindowManager removed — round dates + file requirements in Config are sufficient
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor' import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard' import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview'
import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table'
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard' import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
import { CoverageReport } from '@/components/admin/assignment/coverage-report' import { CoverageReport } from '@/components/admin/assignment/coverage-report'
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet' import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
@@ -145,6 +149,95 @@ const stateColors: Record<string, string> = Object.fromEntries(
Object.entries(projectStateConfig).map(([k, v]) => [k, v.bg]) Object.entries(projectStateConfig).map(([k, v]) => [k, v.bg])
) )
// ═══════════════════════════════════════════════════════════════════════════
// Mentoring round: Auto-fill remaining toolbar (Projects tab)
// ═══════════════════════════════════════════════════════════════════════════
function MentoringBulkAssignToolbar({
roundId,
configJson,
}: {
roundId: string
configJson: Record<string, unknown>
}) {
const utils = trpc.useUtils()
const eligibility = (configJson.eligibility as string) ?? 'requested_only'
const isAdminSelected = eligibility === 'admin_selected'
const { data: pending } = trpc.round.getProjectsNeedingMentor.useQuery(
{ roundId },
{ enabled: !isAdminSelected, refetchInterval: 30_000 },
)
const count = pending?.count ?? 0
const eligibleTotal = pending?.eligibleTotal ?? 0
const mentorPoolSize = pending?.mentorPoolSize ?? 0
const hasNoMentors = mentorPoolSize === 0
const hasNoEligible = eligibleTotal === 0
const bulk = trpc.mentor.autoAssignBulkForRound.useMutation({
onSuccess: (result) => {
toast.success(result.message)
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
utils.project.list.invalidate()
},
onError: (err) => toast.error(err.message),
})
const eligibilityLabel = eligibility.replace('_', ' ')
return (
<div className="flex items-center justify-between rounded-md border bg-muted/30 px-4 py-2.5">
<div className="text-sm">
{isAdminSelected ? (
<>
<span className="font-medium">Eligibility: admin-selected</span>
<span className="text-muted-foreground ml-2">
auto-fill is disabled. Assign each project manually.
</span>
</>
) : hasNoMentors ? (
<span className="text-muted-foreground">
No mentors in the pool yet {' '}
<Link
href="/admin/members?tab=mentors"
className="text-foreground underline-offset-2 hover:underline"
>
add mentors
</Link>{' '}
before auto-filling.
</span>
) : hasNoEligible ? (
<span className="text-muted-foreground">
No projects are eligible for mentorship in this round (
{eligibilityLabel}).
</span>
) : count > 0 ? (
<>
<span className="font-medium">{count}</span>{' '}
<span className="text-muted-foreground">
of {eligibleTotal} project{eligibleTotal === 1 ? '' : 's'} still
needs a mentor ({eligibilityLabel})
</span>
</>
) : (
<span className="text-muted-foreground">
All {eligibleTotal} eligible project{eligibleTotal === 1 ? '' : 's'}{' '}
already have a mentor.
</span>
)}
</div>
<Button
size="sm"
onClick={() => bulk.mutate({ roundId })}
disabled={isAdminSelected || count === 0 || hasNoMentors || bulk.isPending}
>
{bulk.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Auto-fill remaining
</Button>
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// Main Page Component // Main Page Component
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@@ -514,6 +607,16 @@ export default function RoundDetailPage() {
const isFiltering = round?.roundType === 'FILTERING' const isFiltering = round?.roundType === 'FILTERING'
const isEvaluation = round?.roundType === 'EVALUATION' const isEvaluation = round?.roundType === 'EVALUATION'
const isMentoring = round?.roundType === 'MENTORING'
const isGrandFinale = round?.roundType === 'LIVE_FINAL'
// Mentor pool size — used by Round Details panel below to replace the
// always-empty "Jury Group" row on MENTORING rounds.
const { data: mentorPool } = trpc.mentor.getMentorPool.useQuery(
{},
{ enabled: isMentoring },
)
const mentorPoolSize = mentorPool?.poolSize ?? 0
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '') const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
const hasAwards = roundAwards.length > 0 const hasAwards = roundAwards.length > 0
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '') const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
@@ -589,7 +692,8 @@ export default function RoundDetailPage() {
action: undefined as Route | undefined, action: undefined as Route | undefined,
actionLabel: undefined as string | undefined, actionLabel: undefined as string | undefined,
}, },
...((isEvaluation && !(config.requireDocumentUpload as boolean)) ...((isEvaluation && !(config.requireDocumentUpload as boolean)) ||
(isMentoring && !(config.filePromotionEnabled as boolean) && !config.promotionTargetWindowId)
? [] ? []
: [{ : [{
label: 'File requirements set', label: 'File requirements set',
@@ -1161,17 +1265,32 @@ export default function RoundDetailPage() {
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Project Management</p> <p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Project Management</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Link href={poolLink}> {isMentoring ? (
<button className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full"> <button
onClick={() => setActiveTab('projects')}
className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full"
>
<Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" /> <Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
<div> <div>
<p className="text-sm font-medium">Assign Projects</p> <p className="text-sm font-medium">Assign Projects</p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
Add projects from the pool to this round Open the Projects tab to add or auto-fill teams in this round
</p> </p>
</div> </div>
</button> </button>
</Link> ) : (
<Link href={poolLink}>
<button className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full">
<Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Assign Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
Add projects from the pool to this round
</p>
</div>
</button>
</Link>
)}
<button <button
onClick={() => setActiveTab('projects')} onClick={() => setActiveTab('projects')}
@@ -1400,6 +1519,17 @@ export default function RoundDetailPage() {
/> />
)} )}
{/* Mentoring-specific stats \u2014 only on MENTORING rounds */}
{isMentoring && <MentoringRoundOverview roundId={roundId} />}
{/* Grand-finale logistics \u2014 only on LIVE_FINAL rounds */}
{isGrandFinale && programId && (
<div className="grid gap-4 md:grid-cols-2">
<FinalistSlotsCard programId={programId} />
<WaitlistCard programId={programId} />
</div>
)}
{/* Round Info + Project Breakdown */} {/* Round Info + Project Breakdown */}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<AnimatedCard index={2}> <AnimatedCard index={2}>
@@ -1413,7 +1543,9 @@ export default function RoundDetailPage() {
{ label: 'Status', value: <span className="font-medium">{statusCfg.label}</span> }, { label: 'Status', value: <span className="font-medium">{statusCfg.label}</span> },
{ label: 'Position', value: <span className="font-medium">{`Round ${(round.sortOrder ?? 0) + 1}${competition?.rounds ? ` of ${competition.rounds.length}` : ''}`}</span> }, { label: 'Position', value: <span className="font-medium">{`Round ${(round.sortOrder ?? 0) + 1}${competition?.rounds ? ` of ${competition.rounds.length}` : ''}`}</span> },
...(round.purposeKey ? [{ label: 'Purpose', value: <span className="font-medium">{round.purposeKey}</span> }] : []), ...(round.purposeKey ? [{ label: 'Purpose', value: <span className="font-medium">{round.purposeKey}</span> }] : []),
{ label: 'Jury Group', value: <span className="font-medium">{juryGroup ? juryGroup.name : '\u2014'}</span> }, isMentoring
? { label: 'Mentor Pool', value: <Link href="/admin/mentors" className="font-medium hover:underline">{mentorPoolSize} member{mentorPoolSize === 1 ? '' : 's'}</Link> }
: { label: 'Jury Group', value: <span className="font-medium">{juryGroup ? juryGroup.name : '\u2014'}</span> },
{ label: 'Opens', value: <span className="font-medium">{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}</span> }, { label: 'Opens', value: <span className="font-medium">{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}</span> },
{ label: 'Closes', value: <span className="font-medium">{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}</span> }, { label: 'Closes', value: <span className="font-medium">{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}</span> },
].map((row, i) => ( ].map((row, i) => (
@@ -1475,17 +1607,30 @@ export default function RoundDetailPage() {
{/* ═══════════ PROJECTS TAB ═══════════ */} {/* ═══════════ PROJECTS TAB ═══════════ */}
<TabsContent value="projects" className="space-y-4"> <TabsContent value="projects" className="space-y-4">
<ProjectStatesTable {isMentoring && (
competitionId={competitionId} <>
roundId={roundId} <MentoringBulkAssignToolbar roundId={roundId} configJson={config} />
roundStatus={round?.status} <MentoringProjectsTable
competitionRounds={competition?.rounds} roundId={roundId}
currentSortOrder={round?.sortOrder} competitionId={competitionId}
onAssignProjects={() => { competitionRounds={competition?.rounds}
setActiveTab('assignments') currentSortOrder={round?.sortOrder}
setTimeout(() => setPreviewSheetOpen(true), 100) />
}} </>
/> )}
{!isMentoring && (
<ProjectStatesTable
competitionId={competitionId}
roundId={roundId}
roundStatus={round?.status}
competitionRounds={competition?.rounds}
currentSortOrder={round?.sortOrder}
onAssignProjects={() => {
setActiveTab('assignments')
setTimeout(() => setPreviewSheetOpen(true), 100)
}}
/>
)}
</TabsContent> </TabsContent>
{/* ═══════════ FILTERING TAB ═══════════ */} {/* ═══════════ FILTERING TAB ═══════════ */}
@@ -1977,39 +2122,39 @@ export default function RoundDetailPage() {
</p> </p>
)} )}
{aiAssignmentMutation.isPending && ( {aiAssignmentMutation.isPending && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-violet-50 border border-violet-200 dark:bg-violet-950/20 dark:border-violet-800"> <div className="flex items-center gap-3 p-3 rounded-lg bg-violet-50 border border-violet-200">
<div className="relative"> <div className="relative">
<div className="h-8 w-8 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" /> <div className="h-8 w-8 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
</div> </div>
<div> <div>
<p className="text-sm font-medium text-violet-800 dark:text-violet-200">AI is analyzing projects and jurors...</p> <p className="text-sm font-medium text-violet-800">AI is analyzing projects and jurors...</p>
<p className="text-xs text-violet-600 dark:text-violet-400"> <p className="text-xs text-violet-600">
Matching expertise, reviewing bios, and balancing workloads Matching expertise, reviewing bios, and balancing workloads
</p> </p>
</div> </div>
</div> </div>
)} )}
{aiAssignmentMutation.error && !aiAssignmentMutation.isPending && ( {aiAssignmentMutation.error && !aiAssignmentMutation.isPending && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-red-50 border border-red-200 dark:bg-red-950/20 dark:border-red-800"> <div className="flex items-center gap-3 p-3 rounded-lg bg-red-50 border border-red-200">
<AlertTriangle className="h-5 w-5 text-red-600 shrink-0" /> <AlertTriangle className="h-5 w-5 text-red-600 shrink-0" />
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium text-red-800 dark:text-red-200"> <p className="text-sm font-medium text-red-800">
AI generation failed AI generation failed
</p> </p>
<p className="text-xs text-red-600 dark:text-red-400"> <p className="text-xs text-red-600">
{aiAssignmentMutation.error.message} {aiAssignmentMutation.error.message}
</p> </p>
</div> </div>
</div> </div>
)} )}
{aiAssignmentMutation.data && !aiAssignmentMutation.isPending && ( {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 border border-emerald-200 dark:bg-emerald-950/20 dark:border-emerald-800"> <div className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 border border-emerald-200">
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" /> <CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-200"> <p className="text-sm font-medium text-emerald-800">
{aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated {aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated
</p> </p>
<p className="text-xs text-emerald-600 dark:text-emerald-400"> <p className="text-xs text-emerald-600">
{aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects {aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects
{aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'} {aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'}
</p> </p>
@@ -2198,7 +2343,8 @@ export default function RoundDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* General Round Settings */} {/* General Round Settings — hidden on MENTORING rounds (no advancement targets apply) */}
{!isMentoring && (
<Card> <Card>
<CardHeader className="border-b"> <CardHeader className="border-b">
<ConfigSectionHeader <ConfigSectionHeader
@@ -2321,6 +2467,7 @@ export default function RoundDetailPage() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
)}
{/* Round-type-specific config */} {/* Round-type-specific config */}
<RoundConfigForm <RoundConfigForm
@@ -2489,9 +2636,9 @@ export default function RoundDetailPage() {
{/* Autosave error bar — only shows when save fails */} {/* Autosave error bar — only shows when save fails */}
{autosaveStatus === 'error' && ( {autosaveStatus === 'error' && (
<div className="fixed bottom-0 left-0 right-0 z-50 border-t bg-red-50 dark:bg-red-950/50 shadow-[0_-4px_12px_rgba(0,0,0,0.1)]"> <div className="fixed bottom-0 left-0 right-0 z-50 border-t bg-red-50 shadow-[0_-4px_12px_rgba(0,0,0,0.1)]">
<div className="container flex items-center justify-between py-3 px-4 max-w-5xl mx-auto"> <div className="container flex items-center justify-between py-3 px-4 max-w-5xl mx-auto">
<div className="flex items-center gap-2 text-sm text-red-700 dark:text-red-300"> <div className="flex items-center gap-2 text-sm text-red-700">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<span>Auto-save failed</span> <span>Auto-save failed</span>
</div> </div>

View File

@@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma'
import { requireRole } from '@/lib/auth-redirect' import { requireRole } from '@/lib/auth-redirect'
import { AdminSidebar } from '@/components/layouts/admin-sidebar' import { AdminSidebar } from '@/components/layouts/admin-sidebar'
import { AdminEditionWrapper } from '@/components/layouts/admin-edition-wrapper' import { AdminEditionWrapper } from '@/components/layouts/admin-edition-wrapper'
import { RoleSwitcherPill } from '@/components/layouts/role-switcher'
export default async function AdminLayout({ export default async function AdminLayout({
children, children,
@@ -34,6 +35,12 @@ export default async function AdminLayout({
<main className="lg:pl-64"> <main className="lg:pl-64">
{/* Spacer for mobile header */} {/* Spacer for mobile header */}
<div className="h-16 lg:hidden" /> <div className="h-16 lg:hidden" />
{/* Top-bar — hosts the RoleSwitcherPill so multi-role admins
can switch dashboards from the same screen position used on
every other layout. Pill auto-hides for single-role users. */}
<div className="sticky top-0 z-30 flex h-12 items-center justify-end gap-2 border-b bg-card/80 backdrop-blur px-4">
<RoleSwitcherPill currentBasePath="/admin" />
</div>
<div className="container-app py-6 lg:py-8">{children}</div> <div className="container-app py-6 lg:py-8">{children}</div>
</main> </main>
</div> </div>

View File

@@ -8,69 +8,72 @@ 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 { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { import { Star, MessageSquare, Trophy, Vote, ShieldCheck } from 'lucide-react'
Star,
MessageSquare,
Trophy,
Vote,
TrendingUp,
BarChart3,
Award,
ShieldCheck,
} from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type Criterion = {
id?: string
type?: string
label?: string
name?: string
scale?: string
maxScore?: number
}
type Evaluation = {
id: string
submittedAt: Date | null
globalScore: number | null
criterionScores: unknown
feedbackText: string | null
criteria: unknown
}
type EvaluationRound = { type EvaluationRound = {
roundId: string roundId: string
roundName: string roundName: string
roundType: string roundType: string
evaluationCount: number evaluationCount: number
evaluations: Array<{ evaluations: Evaluation[]
id: string
submittedAt: Date | null
globalScore: number | null
criterionScores: unknown
feedbackText: string | null
criteria: unknown
}>
} }
function computeRoundStats(round: EvaluationRound) { const HIDDEN_CRITERION_TYPES = new Set(['boolean', 'advance'])
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100
function parseScaleMax(scale: string | undefined, fallback = 10): number {
if (!scale) return fallback
const m = scale.match(/^\s*\d+\s*-\s*(\d+)\s*$/)
if (m) return Number(m[1])
return fallback
}
function getCriterionMax(c: Criterion): number {
if (typeof c.maxScore === 'number' && c.maxScore > 0) return c.maxScore
return parseScaleMax(c.scale)
}
function visibleCriteria(criteria: unknown): Criterion[] {
if (!Array.isArray(criteria)) return []
return (criteria as Criterion[]).filter((c) => {
if (!c) return false
if (!c.id && !c.label && !c.name) return false
if (c.type && HIDDEN_CRITERION_TYPES.has(c.type)) return false
return true
})
}
function globalScoreSummary(round: EvaluationRound) {
if (round.roundType === 'DELIBERATION') return null
const scores = round.evaluations const scores = round.evaluations
.map((ev) => ev.globalScore) .map((ev) => ev.globalScore)
.filter((s): s is number => s !== null) .filter((s): s is number => s !== null)
const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null if (scores.length === 0) return null
const highest = scores.length > 0 ? Math.max(...scores) : null const max = 10
const lowest = scores.length > 0 ? Math.min(...scores) : null const avg = scores.reduce((a, b) => a + b, 0) / scores.length
return { maxScore, avg, highest, lowest, scores } const lowest = Math.min(...scores)
} const highest = Math.max(...scores)
return { avg, lowest, highest, max }
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 }) { function RoundIcon({ roundType, className }: { roundType: string; className?: string }) {
@@ -85,6 +88,48 @@ function roundIconBg(roundType: string) {
return 'bg-yellow-500/10' return 'bg-yellow-500/10'
} }
function CriterionBar({ value, max }: { value: number; max: number }) {
const pct = Math.max(0, Math.min(100, (value / max) * 100))
return (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-brand-blue transition-all"
style={{ width: `${pct}%` }}
/>
</div>
)
}
function NumericCriterion({ label, score, max }: { label: string; score: number | undefined; max: number }) {
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-semibold tabular-nums">
{score !== undefined ? score : '—'}
<span className="text-muted-foreground font-normal text-xs"> / {max}</span>
</span>
</div>
{score !== undefined && <CriterionBar value={score} max={max} />}
</div>
)
}
function TextCriterion({ label, value }: { label: string; value: string }) {
return (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{label}
</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 whitespace-pre-wrap">
{value}
</p>
</div>
</div>
)
}
export default function ApplicantEvaluationsPage() { export default function ApplicantEvaluationsPage() {
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery() const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
@@ -95,14 +140,6 @@ 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}>
@@ -119,34 +156,14 @@ 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>
<h1 className="text-2xl font-bold">Jury Feedback</h1> <h1 className="text-2xl font-bold">Jury Feedback</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Anonymous evaluations from jury members {hasEvaluations
? `Anonymous evaluations from jury members across ${rounds.length} round${rounds.length === 1 ? '' : 's'}.`
: 'Anonymous evaluations from jury members.'}
</p> </p>
</div> </div>
@@ -164,174 +181,100 @@ export default function ApplicantEvaluationsPage() {
</Card> </Card>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
{/* Stats Summary Strip */}
<AnimatedCard index={0}>
<Card className="p-0 overflow-hidden">
<div className="grid grid-cols-3 divide-x divide-border">
<div className="p-4 text-center">
<div className="flex items-center justify-center gap-1.5 mb-1">
<BarChart3 className="h-3.5 w-3.5 text-blue-500" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Reviews</span>
</div>
<p className="text-2xl font-bold tabular-nums">{totalEvaluations}</p>
</div>
<div className="p-4 text-center">
<div className="flex items-center justify-center gap-1.5 mb-1">
<TrendingUp className="h-3.5 w-3.5 text-emerald-500" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Avg Score</span>
</div>
<p className="text-2xl font-bold tabular-nums">
{globalAvg !== null ? globalAvg.toFixed(1) : '—'}
{globalAvg !== null && <span className="text-sm font-normal text-muted-foreground"> / 100</span>}
</p>
</div>
<div className="p-4 text-center">
<div className="flex items-center justify-center gap-1.5 mb-1">
<Award className="h-3.5 w-3.5 text-amber-500" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Highest</span>
</div>
<p className="text-2xl font-bold tabular-nums">
{globalHighest !== null ? globalHighest : '—'}
{globalHighest !== null && <span className="text-sm font-normal text-muted-foreground"> / 100</span>}
</p>
</div>
</div>
</Card>
</AnimatedCard>
{/* Per-Round Cards */}
{rounds.map((round, roundIdx) => { {rounds.map((round, roundIdx) => {
const { maxScore, avg, highest, lowest } = computeRoundStats(round) const summary = globalScoreSummary(round)
return ( return (
<AnimatedCard key={round.roundId} index={roundIdx + 1}> <AnimatedCard key={round.roundId} index={roundIdx}>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-start justify-between gap-3">
<CardTitle className="flex items-center gap-2.5"> <CardTitle className="flex items-center gap-2.5">
<div className={cn('rounded-lg p-1.5', roundIconBg(round.roundType))}> <div className={cn('rounded-lg p-1.5', roundIconBg(round.roundType))}>
<RoundIcon roundType={round.roundType} /> <RoundIcon roundType={round.roundType} />
</div> </div>
<div> <div>
<span>{round.roundName}</span> <span>{round.roundName}</span>
{avg !== null && round.roundType !== 'DELIBERATION' && ( {summary && (
<p className="text-sm font-normal text-muted-foreground mt-0.5"> <p className="text-sm font-normal text-muted-foreground mt-0.5">
Average: <span className="font-semibold text-foreground">{avg.toFixed(1)}</span> / {maxScore} Average <span className="font-semibold text-foreground">{summary.avg.toFixed(1)}</span> / {summary.max}
{highest !== null && lowest !== null && highest !== lowest && ( {summary.lowest !== summary.highest && (
<span className="ml-2"> <span className="ml-2">· Lowest {summary.lowest} · Highest {summary.highest}</span>
Range: {lowest}{highest}
</span>
)} )}
</p> </p>
)} )}
</div> </div>
</CardTitle> </CardTitle>
<Badge variant="secondary"> <Badge variant="secondary" className="shrink-0">
{round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'evaluation'}{round.evaluationCount !== 1 ? 's' : ''} {round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'review'}{round.evaluationCount !== 1 ? 's' : ''}
</Badge> </Badge>
</div> </div>
</CardHeader> </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"> <CardContent className="p-0">
<div className="divide-y"> <div className="divide-y">
{round.evaluations.map((ev, idx) => ( {round.evaluations.map((ev, idx) => {
<div const criteria = visibleCriteria(ev.criteria)
key={ev.id} const scores = (ev.criterionScores ?? {}) as Record<string, unknown>
className="px-6 py-4 space-y-3"
> return (
<div className="flex items-center justify-between"> <div key={ev.id} className="px-6 py-4 space-y-4">
<span className="font-medium text-sm"> <div className="flex items-center justify-between">
{round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`} <span className="font-medium text-sm">
</span> {round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`}
<div className="flex items-center gap-3"> </span>
{ev.globalScore !== null && round.roundType !== 'DELIBERATION' && ( <div className="flex items-center gap-3">
<span className="flex items-center gap-1"> {ev.globalScore !== null && round.roundType !== 'DELIBERATION' && (
<Star className="h-3.5 w-3.5 text-yellow-500" /> <span className="flex items-center gap-1">
<span className="text-sm font-bold tabular-nums">{ev.globalScore}</span> <Star className="h-3.5 w-3.5 text-yellow-500" />
<span className="text-xs text-muted-foreground">/ {maxScore}</span> <span className="text-sm font-bold tabular-nums">{ev.globalScore}</span>
</span> <span className="text-xs text-muted-foreground">/ 10</span>
)} </span>
{ev.submittedAt && ( )}
<span className="text-xs text-muted-foreground"> {ev.submittedAt && (
{new Date(ev.submittedAt).toLocaleDateString()} <span className="text-xs text-muted-foreground">
</span> {new Date(ev.submittedAt).toLocaleDateString()}
)} </span>
)}
</div>
</div> </div>
{criteria.length > 0 && (
<div className="space-y-3">
{criteria.map((c, ci) => {
const key = c.id || String(ci)
const label = c.label || c.name || `Criterion ${ci + 1}`
const raw = scores[key]
if (c.type === 'text') {
if (typeof raw !== 'string' || raw.trim() === '') return null
return <TextCriterion key={key} label={label} value={raw} />
}
// numeric (default)
const score = typeof raw === 'number' ? raw : undefined
const max = getCriterionMax(c)
return <NumericCriterion key={key} label={label} score={score} max={max} />
})}
</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 whitespace-pre-wrap">
{ev.feedbackText}
</p>
</div>
</div>
)}
</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> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -339,7 +282,6 @@ export default function ApplicantEvaluationsPage() {
) )
})} })}
{/* Confidentiality Footer */}
<div className="flex items-center justify-center gap-2 py-2"> <div className="flex items-center justify-center gap-2 py-2">
<ShieldCheck className="h-4 w-4 text-muted-foreground/60" /> <ShieldCheck className="h-4 w-4 text-muted-foreground/60" />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">

View File

@@ -1,6 +1,8 @@
'use client' 'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { format } from 'date-fns'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { import {
Card, Card,
@@ -9,12 +11,17 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { MentorChat } from '@/components/shared/mentor-chat' import { MentorChat } from '@/components/shared/mentor-chat'
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
import { RequestChangeDialog } from './request-change-dialog'
import { import {
MessageSquare, MessageSquare,
UserCircle, UserCircle,
FileText, FileText,
UserCog,
} from 'lucide-react' } from 'lucide-react'
export default function ApplicantMentorPage() { export default function ApplicantMentorPage() {
@@ -40,6 +47,8 @@ export default function ApplicantMentorPage() {
}, },
}) })
const [isChangeOpen, setIsChangeOpen] = useState(false)
if (dashLoading) { if (dashLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -71,7 +80,20 @@ export default function ApplicantMentorPage() {
) )
} }
const mentor = dashboardData?.project?.mentorAssignment?.mentor const assignments = dashboardData?.project?.mentorAssignments ?? []
const hasMentors = assignments.length > 0
const primaryAssignment = assignments[0] ?? null
const primaryMentor = primaryAssignment?.mentor
const hasPendingChangeRequest = !!dashboardData?.hasPendingMentorChangeRequest
const dialogMentors = assignments
.filter((a) => !!a.mentor)
.map((a) => ({
assignmentId: a.id,
name: a.mentor?.name || a.mentor?.email || 'Mentor',
}))
const teamHeading = assignments.length > 1 ? 'Your mentor team' : 'Your mentor'
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -82,23 +104,72 @@ export default function ApplicantMentorPage() {
Mentor Communication Mentor Communication
</h1> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Chat with your assigned mentor {assignments.length > 1
? 'Chat with your assigned mentor team'
: 'Chat with your assigned mentor'}
</p> </p>
</div> </div>
{/* Mentor info */} {/* Mentor list */}
{mentor ? ( {hasMentors ? (
<Card className="bg-muted/50"> <section className="space-y-3">
<CardContent className="p-4"> <h2 className="text-lg font-semibold tracking-tight">{teamHeading}</h2>
<div className="flex items-center gap-3"> <div className="grid gap-3 md:grid-cols-2">
<UserCircle className="h-10 w-10 text-muted-foreground" /> {assignments.map((assignment) => {
<div> const mentor = assignment.mentor
<p className="font-medium">{mentor.name || 'Mentor'}</p> if (!mentor) return null
<p className="text-sm text-muted-foreground">{mentor.email}</p> const expertise = mentor.expertiseTags ?? []
</div> return (
</div> <Card key={assignment.id} className="bg-muted/50">
</CardContent> <CardContent className="p-4 space-y-3">
</Card> <div className="flex items-start gap-3">
<UserCircle className="h-10 w-10 text-muted-foreground shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-medium truncate">
{mentor.name || 'Mentor'}
</p>
<p className="text-sm text-muted-foreground truncate">
{mentor.email}
</p>
{assignment.assignedAt && (
<p className="text-xs text-muted-foreground mt-1">
Assigned since {format(new Date(assignment.assignedAt), 'MMM d, yyyy')}
</p>
)}
</div>
</div>
{expertise.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{expertise.map((tag) => (
<Badge key={tag} variant="secondary" className="font-normal">
{tag}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
)
})}
</div>
{/* Request change action */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pt-1">
<p className="text-sm text-muted-foreground">
{hasPendingChangeRequest
? "You have a pending mentor change request — admins will follow up soon."
: 'Need a different match? Let the program admins know.'}
</p>
<Button
variant="outline"
onClick={() => setIsChangeOpen(true)}
disabled={hasPendingChangeRequest}
>
<UserCog className="mr-2 h-4 w-4" />
{hasPendingChangeRequest ? 'Change requested' : 'Request a mentor change'}
</Button>
</div>
</section>
) : ( ) : (
<Card className="bg-muted/50"> <Card className="bg-muted/50">
<CardContent className="flex flex-col items-center justify-center py-8"> <CardContent className="flex flex-col items-center justify-center py-8">
@@ -112,12 +183,14 @@ export default function ApplicantMentorPage() {
)} )}
{/* Chat */} {/* Chat */}
{mentor && ( {primaryMentor && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Messages</CardTitle> <CardTitle>Messages</CardTitle>
<CardDescription> <CardDescription>
Your conversation history with {mentor.name || 'your mentor'} {assignments.length > 1
? 'Your conversation history with your mentor team'
: `Your conversation history with ${primaryMentor.name || 'your mentor'}`}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -133,6 +206,25 @@ export default function ApplicantMentorPage() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Files */}
{primaryAssignment?.id && projectId && (
<WorkspaceFilesPanel
projectId={projectId}
mentorAssignmentId={primaryAssignment.id}
asApplicant
/>
)}
{/* Request change dialog */}
{projectId && (
<RequestChangeDialog
projectId={projectId}
mentors={dialogMentors}
open={isChangeOpen}
onOpenChange={setIsChangeOpen}
/>
)}
</div> </div>
) )
} }

View File

@@ -0,0 +1,179 @@
'use client'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Loader2 } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const REASON_MIN = 10
const REASON_MAX = 2000
const TARGET_ANY = '__any__'
type MentorOption = {
assignmentId: string
name: string
}
type RequestChangeDialogProps = {
projectId: string
mentors: MentorOption[]
open: boolean
onOpenChange: (open: boolean) => void
}
export function RequestChangeDialog({
projectId,
mentors,
open,
onOpenChange,
}: RequestChangeDialogProps) {
const [reason, setReason] = useState('')
const [target, setTarget] = useState<string>(TARGET_ANY)
const [touched, setTouched] = useState(false)
const utils = trpc.useUtils()
const requestChange = trpc.mentor.requestChange.useMutation({
onSuccess: async () => {
toast.success(
"Your request has been sent to the program admins. We'll review it and follow up.",
)
onOpenChange(false)
// Refresh dashboard so the disabled state for the button updates.
await utils.applicant.getMyDashboard.invalidate()
},
onError: (error) => {
toast.error(error.message || 'Could not send your request. Please try again.')
},
})
// Reset form when the dialog is closed.
useEffect(() => {
if (!open) {
setReason('')
setTarget(TARGET_ANY)
setTouched(false)
}
}, [open])
const trimmedReason = reason.trim()
const reasonTooShort = trimmedReason.length < REASON_MIN
const reasonTooLong = trimmedReason.length > REASON_MAX
const reasonInvalid = reasonTooShort || reasonTooLong
const showReasonError = touched && reasonInvalid
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setTouched(true)
if (reasonInvalid) return
requestChange.mutate({
projectId,
targetAssignmentId: target === TARGET_ANY ? undefined : target,
reason: trimmedReason,
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Request a mentor change</DialogTitle>
<DialogDescription>
Share a few details so the program admins can follow up with you.
Your current mentor will not see this message.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{mentors.length > 0 && (
<div className="space-y-2">
<Label htmlFor="targetMentor">About a specific mentor</Label>
<Select value={target} onValueChange={setTarget}>
<SelectTrigger id="targetMentor">
<SelectValue placeholder="Any / general" />
</SelectTrigger>
<SelectContent>
<SelectItem value={TARGET_ANY}>Any / general</SelectItem>
{mentors.map((m) => (
<SelectItem key={m.assignmentId} value={m.assignmentId}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Optional. Use this if your request is about one of your co-mentors in particular.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="reason">
Why would you like a change?
</Label>
<Textarea
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
onBlur={() => setTouched(true)}
placeholder="Tell us why you'd like a change. The admin team will follow up."
rows={6}
maxLength={REASON_MAX}
aria-invalid={showReasonError || undefined}
required
/>
<div className="flex items-center justify-between text-xs">
{showReasonError ? (
<p className="text-destructive">
{reasonTooShort
? `Please provide at least ${REASON_MIN} characters.`
: `Please keep your message under ${REASON_MAX} characters.`}
</p>
) : (
<p className="text-muted-foreground">
{REASON_MIN}{REASON_MAX} characters.
</p>
)}
<p className="text-muted-foreground tabular-nums">
{trimmedReason.length}/{REASON_MAX}
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={requestChange.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={requestChange.isPending}>
{requestChange.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Send request
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -17,6 +17,10 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline' import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card' import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card'
import { AttendingMembersCard } from '@/components/applicant/attending-members-card'
import { LunchBanner } from '@/components/applicant/lunch-banner'
import { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload' import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
@@ -215,12 +219,12 @@ export default function ApplicantDashboardPage() {
key={round.id} key={round.id}
className={`flex flex-col sm:flex-row items-start sm:items-center gap-3 rounded-lg border px-4 py-3 ${ className={`flex flex-col sm:flex-row items-start sm:items-center gap-3 rounded-lg border px-4 py-3 ${
isUrgent isUrgent
? 'border-amber-500/50 bg-amber-50 dark:bg-amber-950/20' ? 'border-amber-500/50 bg-amber-50'
: 'border-primary/20 bg-primary/5' : 'border-primary/20 bg-primary/5'
}`} }`}
> >
<div className="flex items-center gap-2 min-w-0"> <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'}`} /> <Clock className={`h-4 w-4 shrink-0 ${isUrgent ? 'text-amber-600' : 'text-primary'}`} />
<span className="font-medium text-sm truncate">{round.name}</span> <span className="font-medium text-sm truncate">{round.name}</span>
<Badge variant={isUrgent ? 'warning' : 'default'} className="shrink-0"> <Badge variant={isUrgent ? 'warning' : 'default'} className="shrink-0">
{remaining > 0 ? formatCountdown(remaining) + ' left' : 'Closed'} {remaining > 0 ? formatCountdown(remaining) + ' left' : 'Closed'}
@@ -401,6 +405,19 @@ export default function ApplicantDashboardPage() {
</AnimatedCard> </AnimatedCard>
))} ))}
{/* Lunch banner (auto-hides when lunch event disabled or unconfigured) */}
<LunchBanner programId={project.programId} />
{/* External lunch attendees attached to this team (auto-hides if none) */}
<ExternalAttendeesStrip projectId={project.id} />
{/* Grand finale attendee roster (auto-hides until confirmation status is CONFIRMED) */}
<AttendingMembersCard />
{/* Conversation with assigned mentor (auto-hides when no mentor assigned) */}
<MentorConversationCard projectId={project.id} />
{/* Jury Feedback Card */} {/* Jury Feedback Card */}
{totalEvaluations > 0 && ( {totalEvaluations > 0 && (
<AnimatedCard index={4}> <AnimatedCard index={4}>
@@ -422,13 +439,14 @@ export default function ApplicantDashboardPage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{evaluations?.map((round) => { {evaluations?.map((round) => {
const showScore = round.roundType !== 'DELIBERATION'
const scores = round.evaluations const scores = round.evaluations
.map((ev) => ev.globalScore) .map((ev) => ev.globalScore)
.filter((s): s is number => s !== null) .filter((s): s is number => s !== null)
const avgScore = scores.length > 0 const avgScore = showScore && scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length ? scores.reduce((a, b) => a + b, 0) / scores.length
: null : null
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100 const maxScore = 10
const pct = avgScore !== null ? (avgScore / maxScore) * 100 : 0 const pct = avgScore !== null ? (avgScore / maxScore) * 100 : 0
const roundIcon = round.roundType === 'LIVE_FINAL' const roundIcon = round.roundType === 'LIVE_FINAL'
? <Trophy className="h-3.5 w-3.5 text-amber-500" /> ? <Trophy className="h-3.5 w-3.5 text-amber-500" />

View File

@@ -357,15 +357,33 @@ export default function ApplicantProjectPage() {
)} )}
</div> </div>
{/* Mentor info */} {(() => {
{project.mentorAssignment?.mentor && ( type MentorAssignment = {
<div className="rounded-lg border p-3 bg-muted/50"> droppedAt: Date | string | null
<p className="text-sm font-medium mb-1">Assigned Mentor</p> mentor: { name: string | null; email: string } | null
<p className="text-sm text-muted-foreground"> }
{project.mentorAssignment.mentor.name} ({project.mentorAssignment.mentor.email}) const active = (
</p> (project.mentorAssignments as MentorAssignment[] | undefined) ?? []
</div> ).filter((a) => !a.droppedAt && a.mentor)
)} if (active.length === 0) return null
return (
<div className="rounded-lg border p-3 bg-muted/50">
<p className="text-sm font-medium mb-1">
{active.length === 1 ? 'Assigned Mentor' : `Assigned Mentors (${active.length})`}
</p>
<ul className="space-y-0.5">
{active.map((a, idx) => (
<li key={`${a.mentor!.email}-${idx}`} className="text-sm text-muted-foreground">
{a.mentor!.name ?? a.mentor!.email}
{a.mentor!.name && (
<span className="text-xs"> ({a.mentor!.email})</span>
)}
</li>
))}
</ul>
</div>
)
})()}
{/* Tags */} {/* Tags */}
{project.tags && project.tags.length > 0 && ( {project.tags && project.tags.length > 0 && (

View File

@@ -160,8 +160,12 @@ function AcceptInviteContent() {
setState('error') setState('error')
setErrorType('AUTH_FAILED') setErrorType('AUTH_FAILED')
} else if (result?.ok) { } else if (result?.ok) {
// Redirect to set-password (middleware will enforce this since mustSetPassword=true) // Let app/page.tsx route by role. Middleware will detour to
window.location.href = '/set-password' // /set-password if the user still needs to set one (first-time
// setup); for users who already had a password (admin-issued
// access link, magic-login style) it'll go straight to their
// dashboard.
window.location.href = '/'
} }
} catch { } catch {
setState('error') setState('error')

View File

@@ -1,581 +0,0 @@
'use client'
import { use, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { Separator } from '@/components/ui/separator'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { toast } from 'sonner'
import {
ArrowLeft,
Trophy,
CheckCircle2,
Loader2,
ChevronDown,
ChevronUp,
FileText,
Star,
Users,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { CountryDisplay } from '@/components/shared/country-display'
import { ProjectFilesSection } from '@/components/jury/project-files-section'
import { ProjectLogo } from '@/components/shared/project-logo'
export default function AwardMasterVotingPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: awardId } = use(params)
const router = useRouter()
// State
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null
)
const [expandedProjectId, setExpandedProjectId] = useState<string | null>(
null
)
const [justification, setJustification] = useState('')
// Queries & mutations
const utils = trpc.useUtils()
const { data, isLoading } =
trpc.specialAward.getMyAwardDetailEnhanced.useQuery({ awardId })
const submitVote = trpc.specialAward.submitAwardMasterVote.useMutation({
onSuccess: () => {
utils.specialAward.getMyAwardDetailEnhanced.invalidate({ awardId })
toast.success('Vote submitted')
},
onError: (err) => toast.error(err.message),
})
const confirmWinner = trpc.specialAward.confirmWinner.useMutation({
onSuccess: () => {
utils.specialAward.getMyAwardDetailEnhanced.invalidate({ awardId })
toast.success('Winner confirmed and award closed')
},
onError: (err) => toast.error(err.message),
})
// Initialize selection from existing vote
const initializedRef = useRef(false)
if (data && !initializedRef.current && data.myVotes.length > 0) {
initializedRef.current = true
setSelectedProjectId(data.myVotes[0].projectId)
if (data.myVotes[0].justification) {
setJustification(data.myVotes[0].justification)
}
}
// Loading state
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-6 w-72" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-44" />
))}
</div>
</div>
)
}
if (!data) return null
// Destructure data
const { award, projects, myVotes, isChair, otherVotes, totalJurors } = data
const hasVoted = myVotes.length > 0
const isVotingOpen = award.status === 'VOTING_OPEN'
const isClosed = award.status === 'CLOSED'
const selectedProject = projects.find((p) => p.id === selectedProjectId)
// Toggle project expansion
const handleProjectClick = (projectId: string) => {
if (isVotingOpen) setSelectedProjectId(projectId)
setExpandedProjectId(expandedProjectId === projectId ? null : projectId)
}
// Submit vote handler
const handleSubmitVote = () => {
if (!selectedProjectId) return
submitVote.mutate({
awardId,
projectId: selectedProjectId,
justification: justification.trim() || undefined,
})
}
// Confirm winner handler
const handleConfirmWinner = () => {
confirmWinner.mutate({ awardId })
}
// Find the winner project for closed state
const winnerProject = isClosed
? projects.find((p) => p.id === award.winnerProjectId)
: null
return (
<div className="space-y-6">
{/* Back button */}
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => router.push('/award-master' as Route)}
className="-ml-4"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</div>
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Trophy className="h-6 w-6 text-amber-500" />
{award.name}
</h1>
<div className="mt-1 flex items-center gap-2">
<Badge
variant={
isVotingOpen
? 'default'
: isClosed
? 'secondary'
: 'outline'
}
>
{award.status.replace('_', ' ')}
</Badge>
{hasVoted && !isClosed && (
<Badge variant="outline" className="text-green-600">
<CheckCircle2 className="mr-1 h-3 w-3" />
Voted
</Badge>
)}
{award.competition && (
<span className="text-sm text-muted-foreground">
{award.competition.name}
</span>
)}
</div>
{award.criteriaText && (
<Card className="mt-3 bg-muted/30">
<CardContent className="py-3 px-4">
<p className="text-sm text-muted-foreground leading-relaxed">
<Star className="mr-1.5 inline h-4 w-4 text-amber-500" />
<span className="font-medium text-foreground">Criteria: </span>
{award.criteriaText}
</p>
</CardContent>
</Card>
)}
</div>
{/* Closed State */}
{isClosed ? (
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-full bg-amber-100 p-4 dark:bg-amber-900/40">
<Trophy className="h-12 w-12 text-amber-500" />
</div>
<p className="mt-4 text-lg font-semibold">Award Finalized</p>
{winnerProject ? (
<div className="mt-3 space-y-1">
<p className="text-xl font-bold text-[#053d57] dark:text-blue-300">
{winnerProject.title}
</p>
{winnerProject.teamName && (
<p className="text-sm text-muted-foreground">
{winnerProject.teamName}
</p>
)}
</div>
) : (
<p className="mt-2 text-sm text-muted-foreground">
This award has been finalized
</p>
)}
</CardContent>
</Card>
) : (
<>
{/* Project Grid */}
<div>
<h2 className="text-lg font-semibold mb-3">
Eligible Projects ({projects.length})
</h2>
{isVotingOpen && (
<p className="text-sm text-muted-foreground mb-4">
Click a project to select it as your pick and expand details
</p>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<div
key={project.id}
className={cn(
expandedProjectId === project.id && 'sm:col-span-2 lg:col-span-3'
)}
>
<Card
className={cn(
'cursor-pointer transition-all',
selectedProjectId === project.id
? 'ring-2 ring-primary bg-primary/5'
: 'hover:bg-muted/50'
)}
onClick={() => handleProjectClick(project.id)}
>
<CardHeader className="pb-2">
<div className="flex items-start gap-3">
<ProjectLogo project={project} logoUrl={project.logoUrl} size="sm" fallback="initials" className="mt-0.5" />
<div className="flex-1 min-w-0">
<CardTitle className="text-base">
{project.title}
</CardTitle>
{project.teamName && (
<CardDescription className="mt-0.5">
{project.teamName}
</CardDescription>
)}
</div>
<div className="ml-2 shrink-0">
{expandedProjectId === project.id ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-1.5">
{project.competitionCategory && (
<Badge variant="outline" className="text-xs">
{project.competitionCategory.replace(/_/g, ' ')}
</Badge>
)}
{project.country && (
<Badge variant="outline" className="text-xs">
<CountryDisplay country={project.country} />
</Badge>
)}
{project.evaluationScore && (
<Badge
variant="secondary"
className="text-xs bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300"
>
<Star className="mr-0.5 h-3 w-3" />
Avg: {project.evaluationScore.avg.toFixed(1)}/10 (
{project.evaluationScore.count}{' '}
{project.evaluationScore.count === 1
? 'review'
: 'reviews'}
)
</Badge>
)}
{selectedProjectId === project.id && (
<Badge className="text-xs bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300">
<CheckCircle2 className="mr-0.5 h-3 w-3" />
Selected
</Badge>
)}
</div>
</CardContent>
</Card>
{/* Expanded Project Detail */}
{expandedProjectId === project.id && (
<Card className="mt-2 border-dashed">
<CardContent className="space-y-4 py-4">
{project.description && (
<div>
<h4 className="text-sm font-medium mb-1 flex items-center gap-1.5">
<FileText className="h-4 w-4 text-muted-foreground" />
Description
</h4>
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-line">
{project.description}
</p>
</div>
)}
{award.evaluationRoundId && (
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-1.5">
<FileText className="h-4 w-4 text-muted-foreground" />
Documents
</h4>
<ProjectFilesSection
projectId={project.id}
roundId={award.evaluationRoundId}
/>
</div>
)}
{project.evaluationScore && (
<Card className="bg-blue-50/50 dark:bg-blue-950/20">
<CardContent className="py-3 px-4">
<div className="flex items-center gap-2">
<Star className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<div>
<p className="text-sm font-medium">
Evaluation Score
</p>
<p className="text-lg font-bold text-blue-700 dark:text-blue-300">
{project.evaluationScore.avg.toFixed(1)} / 10
</p>
<p className="text-xs text-muted-foreground">
Based on {project.evaluationScore.count}{' '}
{project.evaluationScore.count === 1
? 'evaluation'
: 'evaluations'}
</p>
</div>
</div>
</CardContent>
</Card>
)}
</CardContent>
</Card>
)}
</div>
))}
</div>
</div>
{/* Vote Section */}
{isVotingOpen && (
<Card>
<CardHeader>
<CardTitle className="text-base">Your Vote</CardTitle>
<CardDescription>
{hasVoted
? 'You can update your vote until the award is finalized'
: 'Select a project above and submit your vote'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{selectedProject ? (
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-sm text-muted-foreground">
Your selection
</p>
<p className="font-semibold">{selectedProject.title}</p>
{selectedProject.teamName && (
<p className="text-sm text-muted-foreground">
{selectedProject.teamName}
</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground italic">
No project selected. Click a project card above to select it.
</p>
)}
<div className="space-y-2">
<label
htmlFor="justification"
className="text-sm font-medium"
>
Justification
</label>
<Textarea
id="justification"
value={justification}
onChange={(e) => setJustification(e.target.value)}
placeholder="Why did you choose this project? (optional)"
maxLength={2000}
rows={4}
/>
<p className="text-xs text-muted-foreground text-right">
{justification.length} / 2000
</p>
</div>
<div className="flex justify-end">
<Button
onClick={handleSubmitVote}
disabled={!selectedProjectId || submitVote.isPending}
>
{submitVote.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
{hasVoted ? 'Update Vote' : 'Submit Vote'}
</Button>
</div>
</CardContent>
</Card>
)}
{/* Chair Section */}
{isChair && isVotingOpen && (
<>
<Separator />
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-5 w-5 text-muted-foreground" />
Team Votes
</CardTitle>
<CardDescription>
As chair, you can view team votes and confirm the winner
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{otherVotes.length > 0 ? (
<div className="space-y-3">
{otherVotes.map((vote) => {
const votedProject = projects.find(
(p) => p.id === vote.projectId
)
return (
<div
key={vote.userId}
className="rounded-lg border p-3 space-y-1"
>
<div className="flex items-center justify-between">
<p className="font-medium text-sm">
{vote.userName || 'Anonymous Juror'}
</p>
<Badge variant="outline" className="text-xs">
voted for
</Badge>
</div>
<p className="text-sm font-semibold">
{votedProject?.title || 'Unknown project'}
</p>
{vote.justification && (
<p className="text-sm text-muted-foreground italic">
&ldquo;{vote.justification}&rdquo;
</p>
)}
</div>
)
})}
</div>
) : (
<p className="text-sm text-muted-foreground italic">
Waiting for other team members to vote
</p>
)}
{/* Vote tally */}
<div className="rounded-lg bg-muted/30 p-3">
<p className="text-sm font-medium">Vote Summary</p>
<p className="text-sm text-muted-foreground">
{otherVotes.length + (hasVoted ? 1 : 0)} of{' '}
{totalJurors} jurors have voted
</p>
{(() => {
const allVotes = [
...otherVotes.map((v) => v.projectId),
...(hasVoted && myVotes[0]
? [myVotes[0].projectId]
: []),
]
const tally = new Map<string, number>()
for (const pid of allVotes) {
tally.set(pid, (tally.get(pid) || 0) + 1)
}
const sorted = [...tally.entries()].sort(
(a, b) => b[1] - a[1]
)
if (sorted.length === 0) return null
return (
<div className="mt-2 space-y-1">
{sorted.map(([pid, count]) => {
const proj = projects.find((p) => p.id === pid)
return (
<div
key={pid}
className="flex items-center justify-between text-sm"
>
<span>{proj?.title || 'Unknown'}</span>
<Badge variant="secondary" className="text-xs">
{count} {count === 1 ? 'vote' : 'votes'}
</Badge>
</div>
)
})}
</div>
)
})()}
</div>
{/* Confirm Winner button */}
<div className="flex justify-end">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="default"
disabled={!hasVoted || confirmWinner.isPending}
>
{confirmWinner.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trophy className="mr-2 h-4 w-4" />
)}
Confirm Winner
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Confirm Award Winner
</AlertDialogTitle>
<AlertDialogDescription>
This will finalize the winner and close the award.
This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmWinner}>
Confirm Winner
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
</>
)}
</>
)}
</div>
)
}

View File

@@ -1,91 +0,0 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Trophy } from 'lucide-react'
export default function AwardMasterDashboard() {
const { data: awards, isLoading } = trpc.specialAward.getMyAwards.useQuery()
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<div className="grid gap-4 sm:grid-cols-2">
{[...Array(2)].map((_, i) => (
<Skeleton key={i} className="h-40" />
))}
</div>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Award Master Dashboard
</h1>
<p className="text-muted-foreground">
Review eligible projects and select award winners
</p>
</div>
{awards && awards.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{awards.map((award) => (
<Link key={award.id} href={`/award-master/awards/${award.id}` as Route}>
<Card className="transition-colors hover:bg-muted/50 cursor-pointer h-full">
<CardHeader>
<div className="flex items-start justify-between">
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-amber-500" />
{award.name}
</CardTitle>
<Badge
variant={
award.status === 'VOTING_OPEN' ? 'default' : 'secondary'
}
>
{award.status.replace('_', ' ')}
</Badge>
</div>
{award.description && (
<CardDescription className="line-clamp-2">
{award.description}
</CardDescription>
)}
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{award._count.eligibilities} eligible projects
</p>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No awards assigned</p>
<p className="text-sm text-muted-foreground">
You will see your awards here when they are assigned to you
</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -1,24 +0,0 @@
import { requireRole } from '@/lib/auth-redirect'
import { AwardMasterNav } from '@/components/layouts/award-master-nav'
export const dynamic = 'force-dynamic'
export default async function AwardMasterLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await requireRole('AWARD_MASTER', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
return (
<div className="min-h-screen bg-background">
<AwardMasterNav
user={{
name: session.user.name,
email: session.user.email,
}}
/>
<main className="container-app py-6 lg:py-8">{children}</main>
</div>
)
}

View File

@@ -13,16 +13,29 @@ import {
} from '@/components/ui/card' } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
ArrowLeft, ArrowLeft,
Trophy, Trophy,
CheckCircle2, CheckCircle2,
Loader2, Loader2,
GripVertical,
ChevronDown, ChevronDown,
Users, Users,
Tag, Tag,
Star,
Gavel,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { CountryDisplay } from '@/components/shared/country-display' import { CountryDisplay } from '@/components/shared/country-display'
@@ -50,11 +63,19 @@ export default function JuryAwardVotingPage({
utils.specialAward.getMyAwardDetail.invalidate({ awardId }) utils.specialAward.getMyAwardDetail.invalidate({ awardId })
}, },
}) })
const confirmWinner = trpc.specialAward.confirmWinner.useMutation({
onSuccess: () => {
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
toast.success('Winner confirmed and award closed')
},
onError: (err) => toast.error(err.message),
})
const [selectedProjectId, setSelectedProjectId] = useState<string | null>( const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null null
) )
const [rankedIds, setRankedIds] = useState<string[]>([]) const [rankedIds, setRankedIds] = useState<string[]>([])
const [justification, setJustification] = useState('')
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set()) const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set())
const toggleExpanded = (projectId: string) => { const toggleExpanded = (projectId: string) => {
@@ -71,6 +92,9 @@ export default function JuryAwardVotingPage({
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) { if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
if (data.award.scoringMode === 'PICK_WINNER') { if (data.award.scoringMode === 'PICK_WINNER') {
setSelectedProjectId(data.myVotes[0]?.projectId || null) setSelectedProjectId(data.myVotes[0]?.projectId || null)
if (data.myVotes[0]?.justification) {
setJustification(data.myVotes[0].justification)
}
} else if (data.award.scoringMode === 'RANKED') { } else if (data.award.scoringMode === 'RANKED') {
const sorted = [...data.myVotes] const sorted = [...data.myVotes]
.sort((a, b) => (a.rank || 0) - (b.rank || 0)) .sort((a, b) => (a.rank || 0) - (b.rank || 0))
@@ -84,7 +108,10 @@ export default function JuryAwardVotingPage({
try { try {
await submitVote.mutateAsync({ await submitVote.mutateAsync({
awardId, awardId,
votes: [{ projectId: selectedProjectId }], votes: [{
projectId: selectedProjectId,
justification: justification.trim() || undefined,
}],
}) })
toast.success('Vote submitted') toast.success('Vote submitted')
refetch() refetch()
@@ -136,9 +163,10 @@ export default function JuryAwardVotingPage({
if (!data) return null if (!data) return null
const { award, projects, myVotes } = data const { award, projects, myVotes, isChair, otherVotes, totalJurors } = data
const hasVoted = myVotes.length > 0 const hasVoted = myVotes.length > 0
const isVotingOpen = award.status === 'VOTING_OPEN' const isVotingOpen = award.status === 'VOTING_OPEN'
const isClosed = award.status === 'CLOSED'
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -197,10 +225,30 @@ export default function JuryAwardVotingPage({
isExpanded={expandedProjects.has(project.id)} isExpanded={expandedProjects.has(project.id)}
onSelect={() => setSelectedProjectId(project.id)} onSelect={() => setSelectedProjectId(project.id)}
onToggleExpand={() => toggleExpanded(project.id)} onToggleExpand={() => toggleExpanded(project.id)}
/> />
))} ))}
</div> </div>
{selectedProjectId && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Justification (optional)</CardTitle>
<CardDescription>
Visible to the jury chair when they finalize the award.
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
rows={3}
maxLength={2000}
placeholder="Why this project? (optional)"
value={justification}
onChange={(e) => setJustification(e.target.value)}
/>
</CardContent>
</Card>
)}
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
onClick={handleSubmitPickWinner} onClick={handleSubmitPickWinner}
@@ -214,6 +262,18 @@ export default function JuryAwardVotingPage({
{hasVoted ? 'Update Vote' : 'Submit Vote'} {hasVoted ? 'Update Vote' : 'Submit Vote'}
</Button> </Button>
</div> </div>
{isChair && totalJurors > 1 && (
<ChairPanel
award={award}
projects={projects}
otherVotes={otherVotes}
totalJurors={totalJurors}
hasVoted={hasVoted}
onConfirm={() => confirmWinner.mutate({ awardId })}
isPending={confirmWinner.isPending}
/>
)}
</div> </div>
) : award.scoringMode === 'RANKED' ? ( ) : award.scoringMode === 'RANKED' ? (
/* RANKED Mode */ /* RANKED Mode */
@@ -332,6 +392,7 @@ type ProjectData = {
tags: string[] tags: string[]
logoKey?: string | null logoKey?: string | null
logoUrl?: string | null logoUrl?: string | null
evaluationScore?: { avg: number; count: number } | null
files: Array<{ files: Array<{
id: string id: string
fileName: string fileName: string
@@ -355,9 +416,31 @@ type ProjectData = {
}> }>
} }
type OtherVote = {
userId: string
userName: string | null
projectId: string
justification: string | null
}
function ProjectDetails({ project }: { project: ProjectData }) { function ProjectDetails({ project }: { project: ProjectData }) {
return ( return (
<div className="px-4 pb-4 pt-2 space-y-4 border-t mt-2"> <div className="px-4 pb-4 pt-2 space-y-4 border-t mt-2">
{project.evaluationScore && (
<div className="flex items-center gap-2 rounded-md bg-blue-50/50 px-3 py-2">
<Star className="h-4 w-4 text-blue-600 shrink-0" />
<div className="text-sm">
<span className="font-semibold text-blue-700">
{project.evaluationScore.avg.toFixed(1)} / 10
</span>
<span className="text-muted-foreground ml-2">
from {project.evaluationScore.count}{' '}
{project.evaluationScore.count === 1 ? 'evaluation' : 'evaluations'}
</span>
</div>
</div>
)}
{project.description && ( {project.description && (
<p className="text-sm text-muted-foreground whitespace-pre-line">{project.description}</p> <p className="text-sm text-muted-foreground whitespace-pre-line">{project.description}</p>
)} )}
@@ -435,7 +518,7 @@ function ProjectCard({
isExpanded && 'rotate-180' isExpanded && 'rotate-180'
)} /> )} />
<div className="min-w-0"> <div className="min-w-0">
<h3 className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors"> <h3 className="font-semibold text-sm group-hover:text-brand-blue transition-colors">
{project.title} {project.title}
</h3> </h3>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
@@ -469,3 +552,139 @@ function ProjectCard({
</Card> </Card>
) )
} }
function ChairPanel({
award,
projects,
otherVotes,
totalJurors,
hasVoted,
onConfirm,
isPending,
}: {
award: { id: string; status: string }
projects: ProjectData[]
otherVotes: OtherVote[]
totalJurors: number
hasVoted: boolean
onConfirm: () => void
isPending: boolean
}) {
const projectMap = new Map(projects.map((p) => [p.id, p]))
const tally = new Map<string, number>()
for (const v of otherVotes) {
tally.set(v.projectId, (tally.get(v.projectId) ?? 0) + 1)
}
const ranked = Array.from(tally.entries())
.map(([projectId, votes]) => ({
project: projectMap.get(projectId),
votes,
}))
.filter((r) => r.project)
.sort((a, b) => b.votes - a.votes)
const votedCount = new Set(otherVotes.map((v) => v.userId)).size + (hasVoted ? 1 : 0)
const isClosed = award.status === 'CLOSED'
return (
<Card className="border-amber-200">
<CardHeader>
<div className="flex items-center gap-2">
<Gavel className="h-5 w-5 text-amber-600" />
<CardTitle className="text-base">Chair tools</CardTitle>
</div>
<CardDescription>
{votedCount} of {totalJurors} jurors have voted. As the chair you
can review their picks and finalize the award.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{ranked.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No other juror votes yet.
</p>
) : (
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Tally so far
</p>
{ranked.map(({ project, votes }) => (
<div key={project!.id} className="flex items-center justify-between rounded-md border px-3 py-2">
<span className="text-sm font-medium truncate">{project!.title}</span>
<Badge variant="secondary">{votes} {votes === 1 ? 'vote' : 'votes'}</Badge>
</div>
))}
</div>
)}
{otherVotes.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Justifications
</p>
{otherVotes.map((v) => {
const project = projectMap.get(v.projectId)
return (
<div key={v.userId} className="rounded-md border p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">
{v.userName || 'Anonymous juror'}
</span>
<span className="text-xs text-muted-foreground truncate">
{project?.title || 'Unknown project'}
</span>
</div>
{v.justification && (
<p className="text-xs text-muted-foreground mt-2 whitespace-pre-line">
{v.justification}
</p>
)}
</div>
)
})}
</div>
)}
{!isClosed && (
<div className="flex justify-end pt-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button disabled={!hasVoted || isPending}>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trophy className="mr-2 h-4 w-4" />
)}
Confirm winner & close award
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Finalize the award?</AlertDialogTitle>
<AlertDialogDescription>
The project with the most votes will be set as the
winner. If there&apos;s a tie, your own vote breaks it.
Voting will close immediately and this can&apos;t be
reopened from this page.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
{!hasVoted && (
<p className="text-xs text-muted-foreground text-right">
You must submit your own vote before finalizing.
</p>
)}
</CardContent>
</Card>
)
}

View File

@@ -44,7 +44,7 @@ export default function JuryRoundDetailPage() {
Back Back
</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">
{round?.name || 'Round Details'} {round?.name || 'Round Details'}
</h1> </h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">

View File

@@ -460,7 +460,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</div> </div>
<Card className="border-l-4 border-l-amber-500"> <Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-4 p-6"> <CardContent className="flex items-start gap-4 p-6">
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3"> <div className="rounded-xl bg-amber-50 p-3">
<Clock className="h-6 w-6 text-amber-600" /> <Clock className="h-6 w-6 text-amber-600" />
</div> </div>
<div> <div>
@@ -495,7 +495,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</Link> </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">
Evaluate Project Evaluate Project
</h1> </h1>
<p className="text-muted-foreground mt-1">{project.title}</p> <p className="text-muted-foreground mt-1">{project.title}</p>
@@ -526,7 +526,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</Link> </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">
Evaluate Project Evaluate Project
</h1> </h1>
<p className="text-muted-foreground mt-1">{project.title}</p> <p className="text-muted-foreground mt-1">{project.title}</p>
@@ -534,7 +534,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</div> </div>
<Card className="border-l-4 border-l-amber-500"> <Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-4 p-6"> <CardContent className="flex items-start gap-4 p-6">
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3"> <div className="rounded-xl bg-amber-50 p-3">
<ShieldAlert className="h-6 w-6 text-amber-600" /> <ShieldAlert className="h-6 w-6 text-amber-600" />
</div> </div>
<div> <div>
@@ -573,7 +573,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</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">
{isReadOnly ? 'Submitted Evaluation' : '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">
@@ -583,8 +583,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
variant="secondary" variant="secondary"
className={ className={
project.competitionCategory === 'STARTUP' project.competitionCategory === 'STARTUP'
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300' ? 'bg-violet-100 text-violet-700 border-violet-200'
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300' : 'bg-sky-100 text-sky-700 border-sky-200'
} }
> >
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
@@ -595,7 +595,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</div> </div>
{isReadOnly && ( {isReadOnly && (
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50 dark:bg-blue-950/20"> <Card className="border-l-4 border-l-blue-500 bg-blue-50/50">
<CardContent className="flex items-start gap-3 p-4"> <CardContent className="flex items-start gap-3 p-4">
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" /> <Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1">

View File

@@ -98,8 +98,8 @@ export default function JuryProjectDetailPage() {
variant="secondary" variant="secondary"
className={ className={
project.competitionCategory === 'STARTUP' project.competitionCategory === 'STARTUP'
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300' ? 'bg-violet-100 text-violet-700 border-violet-200'
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300' : 'bg-sky-100 text-sky-700 border-sky-200'
} }
> >
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}

View File

@@ -54,7 +54,7 @@ export default function JuryAssignmentsPage() {
<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> <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">
My Assignments My Assignments
</h1> </h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">

View File

@@ -262,7 +262,7 @@ async function JuryDashboardContent() {
<div className="h-1 w-full bg-gradient-to-r from-brand-teal/40 via-brand-blue/40 to-brand-teal/40" /> <div className="h-1 w-full bg-gradient-to-r from-brand-teal/40 via-brand-blue/40 to-brand-teal/40" />
<CardContent className="py-8 px-6"> <CardContent className="py-8 px-6">
<div className="flex flex-col items-center text-center mb-6"> <div className="flex flex-col items-center text-center mb-6">
<div className="rounded-2xl bg-gradient-to-br from-brand-teal/10 to-brand-blue/10 p-4 mb-3 dark:from-brand-teal/20 dark:to-brand-blue/20"> <div className="rounded-2xl bg-gradient-to-br from-brand-teal/10 to-brand-blue/10 p-4 mb-3">
<ClipboardList className="h-8 w-8 text-brand-teal/60" /> <ClipboardList className="h-8 w-8 text-brand-teal/60" />
</div> </div>
<p className="text-lg font-semibold">No assignments yet</p> <p className="text-lg font-semibold">No assignments yet</p>
@@ -273,13 +273,13 @@ async function JuryDashboardContent() {
<div className={`grid gap-3 max-w-md mx-auto ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}> <div className={`grid gap-3 max-w-md mx-auto ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
<Link <Link
href="/jury/competitions" href="/jury/competitions"
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5" className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md"
> >
<div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40"> <div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100">
<ClipboardList className="h-4 w-4 text-blue-600 dark:text-blue-400" /> <ClipboardList className="h-4 w-4 text-blue-600" />
</div> </div>
<div className="text-left"> <div className="text-left">
<p className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">All Assignments</p> <p className="font-semibold text-sm group-hover:text-brand-blue transition-colors">All Assignments</p>
<p className="text-xs text-muted-foreground">View evaluations</p> <p className="text-xs text-muted-foreground">View evaluations</p>
</div> </div>
</Link> </Link>
@@ -288,7 +288,7 @@ async function JuryDashboardContent() {
href="/jury/competitions" href="/jury/competitions"
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md" className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
> >
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40"> <div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100">
<GitCompare className="h-4 w-4 text-brand-teal" /> <GitCompare className="h-4 w-4 text-brand-teal" />
</div> </div>
<div className="text-left"> <div className="text-left">
@@ -314,8 +314,8 @@ async function JuryDashboardContent() {
<div className="rounded-[7px] bg-background"> <div className="rounded-[7px] bg-background">
<CardHeader className="pb-2 pt-4 px-5"> <CardHeader className="pb-2 pt-4 px-5">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/40"> <div className="rounded-lg bg-amber-100 p-1.5">
<Trophy className="h-4 w-4 text-amber-600 dark:text-amber-400" /> <Trophy className="h-4 w-4 text-amber-600" />
</div> </div>
<CardTitle className="text-lg">Special Awards Voting Open</CardTitle> <CardTitle className="text-lg">Special Awards Voting Open</CardTitle>
</div> </div>
@@ -333,27 +333,27 @@ async function JuryDashboardContent() {
className={cn( className={cn(
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md', 'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
hasVoted hasVoted
? 'border-green-200/60 bg-green-50/30 dark:border-green-800/40 dark:bg-green-950/10' ? 'border-green-200/60 bg-green-50/30'
: isUrgent : isUrgent
? 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20' ? 'border-red-200 bg-red-50/50'
: 'border-amber-200/60 bg-amber-50/30 dark:border-amber-800/40 dark:bg-amber-950/10' : 'border-amber-200/60 bg-amber-50/30'
)} )}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h3 className={cn('font-semibold', hasVoted ? 'text-green-700 dark:text-green-400' : 'text-amber-700 dark:text-amber-400')}>{award.name}</h3> <h3 className={cn('font-semibold', hasVoted ? 'text-green-700' : 'text-amber-700')}>{award.name}</h3>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{award._count.eligibilities} project{award._count.eligibilities !== 1 ? 's' : ''} to review {award._count.eligibilities} project{award._count.eligibilities !== 1 ? 's' : ''} to review
{record.isChair && ' · You are the Chair'} {record.isChair && ' · You are the Chair'}
</p> </p>
</div> </div>
{hasVoted ? ( {hasVoted ? (
<Badge className="bg-green-100 text-green-800 border-green-300 dark:bg-green-950 dark:text-green-300 dark:border-green-700"> <Badge className="bg-green-100 text-green-800 border-green-300">
<CheckCircle2 className="mr-1 h-3 w-3" /> <CheckCircle2 className="mr-1 h-3 w-3" />
Submitted Submitted
</Badge> </Badge>
) : ( ) : (
<Badge className="bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-700"> <Badge className="bg-amber-100 text-amber-800 border-amber-300">
Vote Now Vote Now
</Badge> </Badge>
)} )}
@@ -452,8 +452,8 @@ async function JuryDashboardContent() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="rounded-lg bg-brand-blue/10 p-1.5 dark:bg-brand-blue/20"> <div className="rounded-lg bg-brand-blue/10 p-1.5">
<ClipboardList className="h-4 w-4 text-brand-blue dark:text-brand-teal" /> <ClipboardList className="h-4 w-4 text-brand-blue" />
</div> </div>
<CardTitle className="text-lg">My Assignments</CardTitle> <CardTitle className="text-lg">My Assignments</CardTitle>
</div> </div>
@@ -487,14 +487,14 @@ async function JuryDashboardContent() {
href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}`} href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}`}
className="flex-1 min-w-0 group" className="flex-1 min-w-0 group"
> >
<p className="text-sm font-medium truncate group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors"> <p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
{assignment.project.title} {assignment.project.title}
</p> </p>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground truncate"> <span className="text-xs text-muted-foreground truncate">
{assignment.project.teamName} {assignment.project.teamName}
</span> </span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-brand-blue/5 text-brand-blue/80 dark:bg-brand-teal/10 dark:text-brand-teal/80 border-0"> <Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-brand-blue/5 text-brand-blue/80 border-0">
{assignment.round.name} {assignment.round.name}
</Badge> </Badge>
</div> </div>
@@ -506,7 +506,7 @@ async function JuryDashboardContent() {
Done Done
</Badge> </Badge>
) : isDraft && isVotingOpen ? ( ) : isDraft && isVotingOpen ? (
<Badge className="text-xs bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-700 animate-pulse"> <Badge className="text-xs bg-amber-100 text-amber-800 border-amber-300 animate-pulse">
<Send className="mr-1 h-3 w-3" /> <Send className="mr-1 h-3 w-3" />
Ready to submit Ready to submit
</Badge> </Badge>
@@ -571,7 +571,7 @@ async function JuryDashboardContent() {
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<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">
<Zap className="h-4 w-4 text-brand-teal" /> <Zap className="h-4 w-4 text-brand-teal" />
</div> </div>
<CardTitle className="text-lg">Quick Actions</CardTitle> <CardTitle className="text-lg">Quick Actions</CardTitle>
@@ -581,13 +581,13 @@ async function JuryDashboardContent() {
<div className={`grid gap-3 ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}> <div className={`grid gap-3 ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
<Link <Link
href="/jury/competitions" href="/jury/competitions"
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5" className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md"
> >
<div className="rounded-xl bg-blue-50 p-3 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40 dark:group-hover:bg-blue-950/60"> <div className="rounded-xl bg-blue-50 p-3 transition-colors group-hover:bg-blue-100">
<ClipboardList className="h-5 w-5 text-blue-600 dark:text-blue-400" /> <ClipboardList className="h-5 w-5 text-blue-600" />
</div> </div>
<div className="text-left"> <div className="text-left">
<p className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">All Assignments</p> <p className="font-semibold text-sm group-hover:text-brand-blue transition-colors">All Assignments</p>
<p className="text-xs text-muted-foreground mt-0.5">View and manage evaluations</p> <p className="text-xs text-muted-foreground mt-0.5">View and manage evaluations</p>
</div> </div>
</Link> </Link>
@@ -596,7 +596,7 @@ async function JuryDashboardContent() {
href="/jury/competitions" href="/jury/competitions"
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md" className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
> >
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40 dark:group-hover:bg-teal-950/60"> <div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100">
<GitCompare className="h-5 w-5 text-brand-teal" /> <GitCompare className="h-5 w-5 text-brand-teal" />
</div> </div>
<div className="text-left"> <div className="text-left">
@@ -620,8 +620,8 @@ async function JuryDashboardContent() {
<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="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="rounded-lg bg-brand-blue/10 p-1.5 dark:bg-brand-blue/20"> <div className="rounded-lg bg-brand-blue/10 p-1.5">
<Waves className="h-4 w-4 text-brand-blue dark:text-brand-teal" /> <Waves className="h-4 w-4 text-brand-blue" />
</div> </div>
<div> <div>
<CardTitle className="text-lg">Active Voting Stages</CardTitle> <CardTitle className="text-lg">Active Voting Stages</CardTitle>
@@ -650,13 +650,13 @@ async function JuryDashboardContent() {
className={cn( className={cn(
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md', 'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isUrgent isUrgent
? 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20' ? 'border-red-200 bg-red-50/50'
: 'border-border/60 bg-muted/20 dark:bg-muted/10' : 'border-border/60 bg-muted/20'
)} )}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{round.name}</h3> <h3 className="font-semibold text-brand-blue">{round.name}</h3>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{program.name} &middot; {program.year} {program.name} &middot; {program.year}
</p> </p>
@@ -716,7 +716,7 @@ async function JuryDashboardContent() {
<AnimatedCard index={8}> <AnimatedCard index={8}>
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-6 text-center"> <CardContent className="flex flex-col items-center justify-center py-6 text-center">
<div className="rounded-2xl bg-brand-teal/10 p-3 mb-2 dark:bg-brand-teal/20"> <div className="rounded-2xl bg-brand-teal/10 p-3 mb-2">
<Clock className="h-6 w-6 text-brand-teal/70" /> <Clock className="h-6 w-6 text-brand-teal/70" />
</div> </div>
<p className="font-semibold text-sm">No active voting stages</p> <p className="font-semibold text-sm">No active voting stages</p>
@@ -734,7 +734,7 @@ async function JuryDashboardContent() {
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<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">
<BarChart3 className="h-4 w-4 text-brand-teal" /> <BarChart3 className="h-4 w-4 text-brand-teal" />
</div> </div>
<CardTitle className="text-lg">Round Summary</CardTitle> <CardTitle className="text-lg">Round Summary</CardTitle>
@@ -750,7 +750,7 @@ async function JuryDashboardContent() {
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="font-medium truncate">{round.name}</span> <span className="font-medium truncate">{round.name}</span>
<div className="flex items-baseline gap-1 shrink-0 ml-2"> <div className="flex items-baseline gap-1 shrink-0 ml-2">
<span className="font-bold tabular-nums text-brand-blue dark:text-brand-teal">{pct}%</span> <span className="font-bold tabular-nums text-brand-blue">{pct}%</span>
<span className="text-xs text-muted-foreground">({done}/{total})</span> <span className="text-xs text-muted-foreground">({done}/{total})</span>
</div> </div>
</div> </div>
@@ -852,7 +852,7 @@ export default async function JuryDashboardPage() {
<div className="space-y-4"> <div className="space-y-4">
{/* Header */} {/* Header */}
<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">
{getGreeting()}, {session?.user?.name || 'Juror'} {getGreeting()}, {session?.user?.name || 'Juror'}
</h1> </h1>
<p className="text-muted-foreground mt-0.5"> <p className="text-muted-foreground mt-0.5">

View File

@@ -41,6 +41,7 @@ import {
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' import { CountryDisplay } from '@/components/shared/country-display'
import { RecentMessagesCard } from '@/components/mentor/recent-messages-card'
// Status badge colors // Status badge colors
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = { const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
@@ -117,6 +118,9 @@ export default function MentorDashboard() {
</p> </p>
</div> </div>
{/* Recent unread messages from teams */}
<RecentMessagesCard />
{/* Stats */} {/* Stats */}
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<AnimatedCard index={0}> <AnimatedCard index={0}>

View File

@@ -2,6 +2,7 @@
import { Suspense, use, useState, useEffect } from 'react' import { Suspense, use, useState, useEffect } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { import {
Card, Card,
@@ -31,6 +32,7 @@ import { AnimatedCard } from '@/components/shared/animated-container'
import { FileViewer } from '@/components/shared/file-viewer' import { FileViewer } from '@/components/shared/file-viewer'
import { MentorChat } from '@/components/shared/mentor-chat' import { MentorChat } from '@/components/shared/mentor-chat'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url' import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
import { DropAssignmentDialog } from '@/components/mentor/drop-assignment-dialog'
import { import {
ArrowLeft, ArrowLeft,
AlertCircle, AlertCircle,
@@ -76,6 +78,7 @@ const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'ou
function ProjectDetailContent({ projectId }: { projectId: string }) { function ProjectDetailContent({ projectId }: { projectId: string }) {
const router = useRouter() const router = useRouter()
const { data: session } = useSession()
const { data: project, isLoading, error } = trpc.mentor.getProjectDetail.useQuery({ const { data: project, isLoading, error } = trpc.mentor.getProjectDetail.useQuery({
projectId, projectId,
}) })
@@ -91,14 +94,18 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
}, },
}) })
// TODO(PR8 Task 9): show co-mentors. For now we pick the first assignment
// to keep tracking + chat working unchanged.
const primaryAssignment = project?.mentorAssignments?.[0] ?? null
// Track view when project loads // Track view when project loads
const trackView = trpc.mentor.trackView.useMutation() const trackView = trpc.mentor.trackView.useMutation()
useEffect(() => { useEffect(() => {
if (project?.mentorAssignment?.id) { if (primaryAssignment?.id) {
trackView.mutate({ mentorAssignmentId: project.mentorAssignment.id }) trackView.mutate({ mentorAssignmentId: primaryAssignment.id })
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [project?.mentorAssignment?.id]) }, [primaryAssignment?.id])
if (isLoading) { if (isLoading) {
return <ProjectDetailSkeleton /> return <ProjectDetailSkeleton />
@@ -132,8 +139,15 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD') const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || [] const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || []
const mentorAssignmentId = project.mentorAssignment?.id const mentorAssignment = primaryAssignment
const mentorAssignmentId = mentorAssignment?.id
const programId = project.program?.id const programId = project.program?.id
const viewerIsAssignedMentor =
!!mentorAssignment && session?.user?.id === mentorAssignment.mentor?.id
const canDrop =
viewerIsAssignedMentor &&
!mentorAssignment.droppedAt &&
mentorAssignment.completionStatus !== 'completed'
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -179,6 +193,12 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
)} )}
</div> </div>
</div> </div>
{canDrop && mentorAssignmentId && (
<DropAssignmentDialog
assignmentId={mentorAssignmentId}
projectTitle={project.title}
/>
)}
</div> </div>
{project.assignedAt && ( {project.assignedAt && (
@@ -461,7 +481,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<CardContent> <CardContent>
<MentorChat <MentorChat
messages={mentorMessages || []} messages={mentorMessages || []}
currentUserId={project.mentorAssignment?.mentor?.id || ''} currentUserId={primaryAssignment?.mentor?.id || ''}
onSendMessage={async (message) => { onSendMessage={async (message) => {
await sendMessage.mutateAsync({ projectId, message }) await sendMessage.mutateAsync({ projectId, message })
}} }}
@@ -576,7 +596,7 @@ function MilestonesSection({
<div <div
key={milestone.id} key={milestone.id}
className={`flex items-start gap-3 p-3 rounded-lg border transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm ${ className={`flex items-start gap-3 p-3 rounded-lg border transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm ${
isCompleted ? 'bg-green-50/50 border-green-200 dark:bg-green-950/20 dark:border-green-900' : '' isCompleted ? 'bg-green-50/50 border-green-200' : ''
}`} }`}
> >
<Checkbox <Checkbox

View File

@@ -1,20 +1,29 @@
'use client' 'use client'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
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'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { WorkspaceChat } from '@/components/mentor/workspace-chat' import { WorkspaceChat } from '@/components/mentor/workspace-chat'
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel' import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
import { ArrowLeft, MessageSquare, FileText, Upload } from 'lucide-react' import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
import { ArrowLeft, MessageSquare, FileText, Upload, Users } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
export default function MentorWorkspaceDetailPage() { export default function MentorWorkspaceDetailPage() {
const params = useParams() const params = useParams()
const router = useRouter() const router = useRouter()
const { data: session } = useSession()
const projectId = params.projectId as string const projectId = params.projectId as string
// Get mentor assignment for this project // Get mentor assignment for this project
@@ -26,6 +35,22 @@ export default function MentorWorkspaceDetailPage() {
{ enabled: !!projectId } { enabled: !!projectId }
) )
// Co-mentor visibility (PR8 multi-mentor): show who else is on the team.
// Gracefully tolerates stale tabs where the caller no longer has access
// (assignment dropped) — query just returns nothing in that case.
const { data: projectMentors } = trpc.mentor.getProjectMentors.useQuery(
{ projectId },
{ enabled: !!projectId, retry: false }
)
const currentUserId = session?.user?.id
const coMentors = (projectMentors ?? []).filter(
a => a.mentor.id !== currentUserId
)
const coMentorNames = coMentors.map(a => a.mentor.name ?? 'Unnamed mentor')
const visibleCoMentors = coMentorNames.slice(0, 3)
const hiddenCoMentors = coMentorNames.slice(3)
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -69,6 +94,37 @@ export default function MentorWorkspaceDetailPage() {
{project.teamName && ( {project.teamName && (
<p className="text-muted-foreground mt-1">{project.teamName}</p> <p className="text-muted-foreground mt-1">{project.teamName}</p>
)} )}
{coMentors.length > 0 && (
<div className="mt-2 flex items-center gap-1.5 text-sm text-muted-foreground">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>
You + {coMentors.length} co-mentor
{coMentors.length === 1 ? '' : 's'}:{' '}
<span className="text-foreground">
{visibleCoMentors.join(', ')}
</span>
{hiddenCoMentors.length > 0 && (
<>
{' '}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help underline decoration-dotted underline-offset-2">
+{hiddenCoMentors.length} more
</span>
</TooltipTrigger>
<TooltipContent>
<div className="text-xs">
{hiddenCoMentors.join(', ')}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
</span>
</div>
)}
</div> </div>
</div> </div>
@@ -102,25 +158,24 @@ export default function MentorWorkspaceDetailPage() {
</TabsContent> </TabsContent>
<TabsContent value="files" className="mt-6"> <TabsContent value="files" className="mt-6">
<Card> {assignment ? (
<CardHeader> <WorkspaceFilesPanel
<CardTitle>Workspace Files</CardTitle> projectId={projectId}
<CardDescription> mentorAssignmentId={assignment.id}
Files shared in the mentor workspace />
</CardDescription> ) : (
</CardHeader> <Card>
<CardContent className="text-center py-8"> <CardContent className="text-center py-8">
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" /> <FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Loading workspace</p>
File listing feature coming soon </CardContent>
</p> </Card>
</CardContent> )}
</Card>
</TabsContent> </TabsContent>
<TabsContent value="promotion" className="mt-6"> <TabsContent value="promotion" className="mt-6">
{assignment ? ( {assignment ? (
<FilePromotionPanel mentorAssignmentId={assignment.id} /> <FilePromotionPanel projectId={projectId} />
) : ( ) : (
<Card> <Card>
<CardContent className="text-center py-8"> <CardContent className="text-center py-8">

View File

@@ -0,0 +1,421 @@
'use client'
import { Suspense, use, useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { AlertCircle, CheckCircle2, Loader2, PartyPopper, XCircle } from 'lucide-react'
import { TRPCClientError } from '@trpc/client'
interface PageProps {
params: Promise<{ token: string }>
}
function formatDeadline(d: Date): string {
const main = new Intl.DateTimeFormat(undefined, {
dateStyle: 'long',
timeStyle: 'short',
}).format(d)
const tzPart = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' })
.formatToParts(d)
.find((p) => p.type === 'timeZoneName')?.value
return tzPart ? `${main} (${tzPart})` : main
}
function CountdownLabel({ deadline }: { deadline: Date }) {
const [now, setNow] = useState<number>(Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [])
const ms = deadline.getTime() - now
if (ms <= 0) return <span className="text-destructive font-medium">expired</span>
const totalSec = Math.floor(ms / 1000)
const hours = Math.floor(totalSec / 3600)
const minutes = Math.floor((totalSec % 3600) / 60)
const seconds = totalSec % 60
if (hours >= 24) {
const days = Math.floor(hours / 24)
const remHours = hours % 24
return (
<span className="font-medium tabular-nums">
{days}d {remHours}h remaining
</span>
)
}
return (
<span className="font-medium tabular-nums">
{hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:
{seconds.toString().padStart(2, '0')} remaining
</span>
)
}
function FriendlyError({
title,
message,
icon: Icon,
}: {
title: string
message: string
icon: typeof AlertCircle
}) {
return (
<Card className="mx-auto max-w-xl">
<CardHeader>
<div className="flex items-center gap-2">
<Icon className="text-muted-foreground h-5 w-5" />
<CardTitle>{title}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{message}</p>
</CardContent>
</Card>
)
}
function FinalistConfirmContent({ token }: { token: string }) {
const { data, isLoading, error } = trpc.finalist.getByToken.useQuery({ token }, { retry: false })
const confirmMutation = trpc.finalist.confirm.useMutation()
const declineMutation = trpc.finalist.decline.useMutation()
const [selected, setSelected] = useState<Set<string>>(new Set())
const [visa, setVisa] = useState<Record<string, boolean>>({})
const [declineReason, setDeclineReason] = useState('')
const [submitState, setSubmitState] = useState<'idle' | 'confirmed' | 'declined' | 'error'>(
'idle',
)
const [submitError, setSubmitError] = useState<string | null>(null)
// Default-select all team members once data arrives
useEffect(() => {
if (data?.project.teamMembers && selected.size === 0 && submitState === 'idle') {
const cap = data.project.program.defaultAttendeeCap
const initial = new Set(
data.project.teamMembers.slice(0, cap).map((tm) => tm.userId),
)
setSelected(initial)
}
}, [data, selected.size, submitState])
// ── Loading
if (isLoading) {
return (
<div className="mx-auto max-w-xl space-y-4">
<Skeleton className="h-8 w-2/3" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-48 w-full" />
</div>
)
}
// ── Token errors → friendly states
if (error) {
const msg = error.message ?? ''
if (/expired/i.test(msg)) {
return (
<FriendlyError
icon={AlertCircle}
title="This link has expired"
message="If your team still wants to attend, please contact us at info@monaco-opc.com."
/>
)
}
if (/signature|malformed|payload/i.test(msg)) {
return (
<FriendlyError
icon={AlertCircle}
title="This link is not valid"
message="Please check your email or contact us at info@monaco-opc.com."
/>
)
}
return (
<FriendlyError
icon={AlertCircle}
title="Something went wrong"
message={msg || 'Please try again or contact us at info@monaco-opc.com.'}
/>
)
}
if (!data) {
return (
<FriendlyError
icon={AlertCircle}
title="Confirmation not found"
message="Please check your email link or contact us at info@monaco-opc.com."
/>
)
}
// ── Status branches: only PENDING is interactive
if (submitState === 'confirmed' || data.status === 'CONFIRMED') {
return (
<Card className="mx-auto max-w-xl">
<CardHeader>
<div className="flex items-center gap-2">
<PartyPopper className="text-primary h-5 w-5" />
<CardTitle>You&apos;re in!</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="mb-2">
Your team&apos;s attendance for <strong>{data.project.title}</strong> is confirmed.
</p>
<p className="text-muted-foreground text-sm">
We&apos;ll be in touch shortly with travel and lunch logistics. You can edit your team
selection from your project page closer to the event.
</p>
</CardContent>
</Card>
)
}
if (submitState === 'declined' || data.status === 'DECLINED') {
return (
<FriendlyError
icon={XCircle}
title="Your team has declined"
message="If this was a mistake, please contact us at info@monaco-opc.com."
/>
)
}
if (data.status === 'EXPIRED') {
return (
<FriendlyError
icon={AlertCircle}
title="The confirmation deadline has passed"
message="If your team still wants to attend, please contact us at info@monaco-opc.com."
/>
)
}
if (data.status === 'SUPERSEDED') {
return (
<FriendlyError
icon={AlertCircle}
title="This confirmation is no longer active"
message="Please contact us at info@monaco-opc.com for details."
/>
)
}
// ── PENDING: render the form
const cap = data.project.program.defaultAttendeeCap
const deadline = new Date(data.deadline)
const overCap = selected.size > cap
const noneSelected = selected.size === 0
const toggle = (userId: string, checked: boolean) => {
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(userId)
else next.delete(userId)
return next
})
}
const toggleVisa = (userId: string, checked: boolean) => {
setVisa((prev) => ({ ...prev, [userId]: checked }))
}
const handleConfirm = async () => {
setSubmitError(null)
try {
await confirmMutation.mutateAsync({
token,
attendingUserIds: Array.from(selected),
visaFlags: Object.fromEntries(
Array.from(selected).map((uid) => [uid, !!visa[uid]]),
),
})
setSubmitState('confirmed')
} catch (err) {
setSubmitState('error')
const msg =
err instanceof TRPCClientError ? err.message : err instanceof Error ? err.message : 'Failed'
setSubmitError(msg)
}
}
const handleDecline = async () => {
setSubmitError(null)
try {
await declineMutation.mutateAsync({
token,
reason: declineReason.trim() || undefined,
})
setSubmitState('declined')
} catch (err) {
setSubmitState('error')
const msg =
err instanceof TRPCClientError ? err.message : err instanceof Error ? err.message : 'Failed'
setSubmitError(msg)
}
}
return (
<div className="mx-auto max-w-xl space-y-6">
<Card className="border-primary/40 bg-primary/5">
<CardHeader>
<div className="flex items-center gap-2">
<PartyPopper className="text-primary h-5 w-5" />
<CardTitle>Congratulations!</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-2">
<p>
Your project <strong>{data.project.title}</strong> is a finalist for the Monaco Ocean
Protection Challenge grand finale.
</p>
<div className="bg-background border-amber-300 mt-3 rounded-md border-l-4 p-3">
<p className="text-sm">
<strong>Confirm by {formatDeadline(deadline)}.</strong>
</p>
<p className="text-muted-foreground mt-1 text-xs">
<CountdownLabel deadline={deadline} />
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Who from your team will attend?</CardTitle>
<p className="text-muted-foreground text-sm">
You can select up to <strong>{cap}</strong> team members. Indicate who needs visa
support so we can prepare documents in time.
</p>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{data.project.teamMembers.map((tm) => {
const checked = selected.has(tm.userId)
return (
<li key={tm.userId} className="flex items-start justify-between gap-4">
<label className="flex flex-1 items-start gap-3 cursor-pointer">
<Checkbox
checked={checked}
onCheckedChange={(c) => toggle(tm.userId, c === true)}
className="mt-0.5"
/>
<div>
<div className="font-medium">{tm.user.name ?? tm.user.email}</div>
<div className="text-muted-foreground text-xs">
{tm.user.email}
{tm.role && tm.role !== 'MEMBER' ? ` · ${tm.role.toLowerCase()}` : ''}
</div>
</div>
</label>
{checked && (
<label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Needs visa?</span>
<Switch
checked={!!visa[tm.userId]}
onCheckedChange={(c) => toggleVisa(tm.userId, c)}
/>
</label>
)}
</li>
)
})}
</ul>
{overCap && (
<p className="text-destructive mt-3 text-sm">
Please select no more than {cap} member{cap === 1 ? '' : 's'}.
</p>
)}
</CardContent>
</Card>
{submitError && (
<div className="border-destructive bg-destructive/10 text-destructive rounded-md border p-3 text-sm">
{submitError}
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" className="text-muted-foreground">
We can&apos;t attend
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Decline finalist slot?</AlertDialogTitle>
<AlertDialogDescription>
If your team can&apos;t attend, we&apos;ll offer the slot to a waitlisted team. This
action can&apos;t be undone from this page.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
<label className="text-muted-foreground text-sm" htmlFor="decline-reason">
Reason (optional, helps us improve future editions)
</label>
<Textarea
id="decline-reason"
value={declineReason}
onChange={(e) => setDeclineReason(e.target.value)}
placeholder="e.g. logistics conflict, team disbanded, no longer interested"
rows={3}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDecline}
disabled={declineMutation.isPending}
className="bg-destructive hover:bg-destructive/90"
>
{declineMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Decline finalist slot'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button
size="lg"
onClick={handleConfirm}
disabled={overCap || noneSelected || confirmMutation.isPending}
>
{confirmMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Confirming
</>
) : (
<>
<CheckCircle2 className="mr-2 h-4 w-4" />
Confirm Attendance
</>
)}
</Button>
</div>
</div>
)
}
export default function FinalistConfirmPage({ params }: PageProps) {
const { token } = use(params)
return (
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
<FinalistConfirmContent token={token} />
</Suspense>
)
}

View File

@@ -106,7 +106,6 @@ export default function ProfileSettingsPage() {
const handleSaveProfile = async () => { const handleSaveProfile = async () => {
try { try {
await updateProfile.mutateAsync({ await updateProfile.mutateAsync({
email: email || undefined,
name: name || undefined, name: name || undefined,
bio, bio,
phoneNumber: phoneNumber || null, phoneNumber: phoneNumber || null,
@@ -229,11 +228,13 @@ export default function ProfileSettingsPage() {
id="email" id="email"
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} readOnly
disabled
placeholder="you@example.com" placeholder="you@example.com"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
This will be used for login and all notification emails. Used for login and notifications. Contact an administrator to
change your email address.
</p> </p>
</div> </div>

View File

@@ -0,0 +1,17 @@
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { expirePendingPastDeadline } from '@/server/services/finalist-confirmation'
export async function GET(request: NextRequest): Promise<NextResponse> {
const cronSecret = request.headers.get('x-cron-secret')
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const result = await expirePendingPastDeadline(prisma)
return NextResponse.json({ ok: true, ...result })
} catch (error) {
console.error('[Cron] finalist-confirmations failed:', error)
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,69 @@
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { sendLunchRecapEmail } from '@/lib/email'
import { buildRecapPayload } from '@/server/services/lunch-recap'
import { logAudit } from '@/server/utils/audit'
/**
* Cron: when a lunch event is past its change deadline and admins have
* left auto-recap on (cronEnabled), send the recap to admins +
* extraRecipients and stamp recapSentAt. Idempotent.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
const cronSecret = request.headers.get('x-cron-secret')
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const now = new Date()
const events = await prisma.lunchEvent.findMany({
where: {
enabled: true,
cronEnabled: true,
recapSentAt: null,
eventAt: { not: null },
},
})
let sent = 0
for (const event of events) {
try {
if (!event.eventAt) continue
const deadline = new Date(
event.eventAt.getTime() - event.changeCutoffHours * 3_600_000,
)
if (now < deadline) continue
const payload = await buildRecapPayload(prisma, event.programId)
const adminUsers = await prisma.user.findMany({
where: {
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
email: { not: '' },
},
select: { email: true },
})
const recipients = [
...adminUsers.map((u) => u.email).filter(Boolean),
...event.extraRecipients,
]
try {
await sendLunchRecapEmail(recipients, payload)
} catch (e) {
console.error('[lunch-recap] email send failed', event.id, e)
}
await prisma.lunchEvent.update({
where: { id: event.id },
data: { recapSentAt: new Date() },
})
await logAudit({
prisma,
userId: null,
action: 'LUNCH_RECAP_SENT',
entityType: 'LunchEvent',
entityId: event.id,
detailsJson: { recipientCount: recipients.length, source: 'cron' },
})
sent++
} catch (e) {
console.error('[lunch-recap] event failed', event.id, e)
}
}
return NextResponse.json({ ok: true, sent, processedEvents: events.length })
}

View File

@@ -0,0 +1,73 @@
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { sendLunchReminderEmail } from '@/lib/email'
/**
* Cron: send a single reminder email per attending member who hasn't picked
* a lunch dish yet, when we're inside the reminder window
* (deadline - reminderHoursBeforeDeadline) <= now < deadline.
*
* Idempotent — `LunchEvent.reminderSentAt` blocks repeat sends.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
const cronSecret = request.headers.get('x-cron-secret')
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const now = new Date()
const events = await prisma.lunchEvent.findMany({
where: {
enabled: true,
reminderSentAt: null,
reminderHoursBeforeDeadline: { not: null },
eventAt: { not: null },
},
})
let sent = 0
for (const event of events) {
try {
if (!event.eventAt || event.reminderHoursBeforeDeadline == null) continue
const deadline = new Date(
event.eventAt.getTime() - event.changeCutoffHours * 3_600_000,
)
const reminderAt = new Date(
deadline.getTime() - event.reminderHoursBeforeDeadline * 3_600_000,
)
if (now < reminderAt || now >= deadline) continue
const ams = await prisma.attendingMember.findMany({
where: {
confirmation: {
project: { programId: event.programId },
status: 'CONFIRMED',
},
lunchPick: { is: { pickedAt: null } },
},
include: { user: { select: { name: true, email: true } } },
})
for (const am of ams) {
if (!am.user.email) continue
try {
await sendLunchReminderEmail({
to: am.user.email,
memberName: am.user.name ?? am.user.email,
eventAt: event.eventAt,
venue: event.venue,
changeDeadline: deadline,
pickUrl: `${process.env.NEXTAUTH_URL ?? ''}/applicant`,
})
sent++
} catch (e) {
console.error('[lunch-reminders] send failed for', am.user.email, e)
}
}
await prisma.lunchEvent.update({
where: { id: event.id },
data: { reminderSentAt: new Date() },
})
} catch (e) {
console.error('[lunch-reminders] event failed', event.id, e)
}
}
return NextResponse.json({ ok: true, sent, processedEvents: events.length })
}

View File

@@ -30,21 +30,21 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
// Authorization: must be admin or assigned jury/mentor for this project // Authorization: must be admin or assigned jury/mentor for this project
const isAdmin = userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN' const isAdmin = userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN'
// Per-round scope: jurors may only pull URLs for files in rounds with
// sortOrder <= their assigned round. Mirrors file.getDownloadUrl. Without
// this, a juror assigned to EVALUATION could bulk-download LIVE_FINAL
// confidential files via this endpoint.
let priorRoundIds: string[] | null = null
if (!isAdmin) { if (!isAdmin) {
// Check if user is assigned as jury
const juryAssignment = await prisma.assignment.findFirst({ const juryAssignment = await prisma.assignment.findFirst({
where: { where: { userId, projectId },
userId, select: { id: true, roundId: true },
projectId,
},
}) })
// Check if user is assigned as mentor
const mentorAssignment = await prisma.mentorAssignment.findFirst({ const mentorAssignment = await prisma.mentorAssignment.findFirst({
where: { where: { mentorId: userId, projectId },
mentorId: userId, select: { id: true },
projectId,
},
}) })
if (!juryAssignment && !mentorAssignment) { if (!juryAssignment && !mentorAssignment) {
@@ -53,14 +53,41 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
{ status: 403 } { status: 403 }
) )
} }
// Apply the per-round filter only when access is jury-only.
if (juryAssignment && !mentorAssignment) {
const assignedRound = await prisma.round.findUnique({
where: { id: juryAssignment.roundId },
select: { competitionId: true, sortOrder: true },
})
if (assignedRound) {
const priorOrCurrent = await prisma.round.findMany({
where: {
competitionId: assignedRound.competitionId,
sortOrder: { lte: assignedRound.sortOrder },
},
select: { id: true },
})
priorRoundIds = priorOrCurrent.map((r) => r.id)
}
}
} }
// Fetch file metadata from DB // Fetch file metadata from DB
const fileWhere: Record<string, unknown> = {
id: { in: fileIds },
projectId,
}
if (priorRoundIds !== null) {
fileWhere.OR = [
{ requirement: { roundId: { in: priorRoundIds } } },
{ requirementId: null, roundId: { in: priorRoundIds } },
{ requirementId: null, roundId: null },
]
}
const files = await prisma.projectFile.findMany({ const files = await prisma.projectFile.findMany({
where: { where: fileWhere,
id: { in: fileIds },
projectId,
},
select: { select: {
id: true, id: true,
fileName: true, fileName: true,

View File

@@ -218,35 +218,6 @@
--info: 194 25% 44%; --info: 194 25% 44%;
} }
.dark {
--background: 220 15% 8%;
--foreground: 0 0% 98%;
--card: 220 15% 10%;
--card-foreground: 0 0% 98%;
--popover: 220 15% 10%;
--popover-foreground: 0 0% 98%;
--primary: 354 90% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 220 15% 18%;
--secondary-foreground: 0 0% 98%;
--muted: 220 15% 18%;
--muted-foreground: 0 0% 64%;
--accent: 194 20% 18%;
--accent-foreground: 0 0% 98%;
--destructive: 0 84% 55%;
--destructive-foreground: 0 0% 100%;
--border: 220 15% 22%;
--input: 220 15% 22%;
--ring: 220 10% 50%;
}
} }
@layer base { @layer base {
@@ -345,13 +316,6 @@ div[class*="recharts-tooltip"] {
opacity: 1 !important; opacity: 1 !important;
} }
.dark div[class*="tremor"][class*="tooltip"],
.dark .recharts-tooltip-wrapper .recharts-default-tooltip,
.dark div[class*="recharts-tooltip"] {
background-color: hsl(var(--card)) !important;
border-color: hsl(var(--border)) !important;
}
/* Tremor/Recharts tooltip color indicator icons — fix rendering */ /* Tremor/Recharts tooltip color indicator icons — fix rendering */
.recharts-tooltip-wrapper svg.recharts-surface { .recharts-tooltip-wrapper svg.recharts-surface {
display: inline-block !important; display: inline-block !important;

View File

@@ -4,28 +4,26 @@ import Image from 'next/image'
import { auth } from '@/lib/auth' import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import type { Route } from 'next' import type { Route } from 'next'
import type { UserRole } from '@prisma/client'
export const metadata: Metadata = { title: 'Monaco Ocean Protection Challenge' } export const metadata: Metadata = { title: 'Monaco Ocean Protection Challenge' }
export default async function HomePage() { export default async function HomePage() {
const session = await auth() const session = await auth()
// Redirect authenticated users to their appropriate dashboard // Redirect authenticated users to their appropriate dashboard.
// Reads the multi-role array (roles[]) so a user who is e.g. JURY_MEMBER+MENTOR
// lands on /jury (their highest-priority role) rather than always falling
// through on the singular `role` field. The context-aware variant —
// user.getDefaultDashboard tRPC procedure — exists for surfaces that can call
// tRPC; page.tsx uses static priority for simplicity.
if (session?.user) { if (session?.user) {
if ( const roles = (session.user.roles as UserRole[] | undefined) ?? [session.user.role as UserRole]
session.user.role === 'SUPER_ADMIN' || if (roles.includes('SUPER_ADMIN') || roles.includes('PROGRAM_ADMIN')) redirect('/admin')
session.user.role === 'PROGRAM_ADMIN' if (roles.includes('JURY_MEMBER')) redirect('/jury')
) { if (roles.includes('MENTOR')) redirect('/mentor' as Route)
redirect('/admin') if (roles.includes('APPLICANT')) redirect('/applicant' as Route)
} else if (session.user.role === 'JURY_MEMBER') { if (roles.includes('OBSERVER')) redirect('/observer')
redirect('/jury')
} else if (session.user.role === 'MENTOR') {
redirect('/mentor' as Route)
} else if (session.user.role === 'OBSERVER') {
redirect('/observer')
} else if (session.user.role === 'APPLICANT') {
redirect('/applicant' as Route)
}
} }
return ( return (

View File

@@ -2,7 +2,6 @@
import { useState } from 'react' import { useState } from 'react'
import { SessionProvider } from 'next-auth/react' import { SessionProvider } from 'next-auth/react'
import { ThemeProvider } from 'next-themes'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client' import { httpBatchLink } from '@trpc/client'
import superjson from 'superjson' import superjson from 'superjson'
@@ -78,12 +77,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
) )
return ( return (
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}> <SessionProvider>
<SessionProvider> <trpc.Provider client={trpcClient} queryClient={queryClient}>
<trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider> </trpc.Provider>
</trpc.Provider> </SessionProvider>
</SessionProvider>
</ThemeProvider>
) )
} }

View File

@@ -437,7 +437,7 @@ export function AssignmentPreviewSheet({
{mode === 'ai' && !aiResult && !isAIGenerating && ( {mode === 'ai' && !aiResult && !isAIGenerating && (
<Card className="border-dashed"> <Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-8 gap-3"> <CardContent className="flex flex-col items-center justify-center py-8 gap-3">
<div className="h-12 w-12 rounded-full bg-violet-100 dark:bg-violet-950 flex items-center justify-center"> <div className="h-12 w-12 rounded-full bg-violet-100 flex items-center justify-center">
<Sparkles className="h-6 w-6 text-violet-600" /> <Sparkles className="h-6 w-6 text-violet-600" />
</div> </div>
<div className="text-center space-y-1"> <div className="text-center space-y-1">
@@ -463,7 +463,7 @@ export function AssignmentPreviewSheet({
{isLoading ? ( {isLoading ? (
<div className="space-y-3"> <div className="space-y-3">
{mode === 'ai' && ( {mode === 'ai' && (
<Card className="border-violet-200 bg-violet-50/50 dark:bg-violet-950/20"> <Card className="border-violet-200 bg-violet-50/50">
<CardContent className="flex items-center gap-3 py-4"> <CardContent className="flex items-center gap-3 py-4">
<div className="relative"> <div className="relative">
<div className="h-6 w-6 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" /> <div className="h-6 w-6 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
@@ -567,13 +567,13 @@ export function AssignmentPreviewSheet({
{/* ── Warnings ── */} {/* ── Warnings ── */}
{preview.warnings && preview.warnings.length > 0 && ( {preview.warnings && preview.warnings.length > 0 && (
<Card className="border-amber-300 bg-amber-50/50 dark:bg-amber-950/20"> <Card className="border-amber-300 bg-amber-50/50">
<CardContent className="p-3"> <CardContent className="p-3">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" /> <AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" />
<div className="space-y-1"> <div className="space-y-1">
{preview.warnings.map((w: string, idx: number) => ( {preview.warnings.map((w: string, idx: number) => (
<p key={idx} className="text-xs text-amber-800 dark:text-amber-200"> <p key={idx} className="text-xs text-amber-800">
{w} {w}
</p> </p>
))} ))}

View File

@@ -0,0 +1,162 @@
'use client'
import { useEffect, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Loader2, Save, Trophy } from 'lucide-react'
import { formatEnumLabel } from '@/lib/utils'
import type { CompetitionCategory } from '@prisma/client'
interface Props {
programId: string
}
const CATEGORIES: CompetitionCategory[] = ['STARTUP', 'BUSINESS_CONCEPT']
type Row = {
category: CompetitionCategory
quota: number
confirmed: number
pending: number
}
export function FinalistSlotsCard({ programId }: Props) {
const utils = trpc.useUtils()
const { data: quotas, isLoading: loadingQuotas } = trpc.finalist.listQuotas.useQuery({
programId,
})
const { data: counts, isLoading: loadingCounts } = trpc.finalist.listCategoryCounts.useQuery({
programId,
})
const [draft, setDraft] = useState<Record<CompetitionCategory, string>>({
STARTUP: '',
BUSINESS_CONCEPT: '',
})
// Sync draft from server response on first load / after save
useEffect(() => {
if (!quotas) return
const next: Record<CompetitionCategory, string> = { STARTUP: '', BUSINESS_CONCEPT: '' }
for (const cat of CATEGORIES) {
const found = quotas.find((q) => q.category === cat)
next[cat] = found ? String(found.quota) : ''
}
setDraft(next)
}, [quotas])
const setQuotaMutation = trpc.finalist.setQuota.useMutation({
onSuccess: (_, vars) => {
toast.success(`${formatEnumLabel(vars.category)} quota saved`)
utils.finalist.listQuotas.invalidate({ programId })
utils.finalist.listCategoryCounts.invalidate({ programId })
},
onError: (err) => toast.error(err.message),
})
if (loadingQuotas || loadingCounts) {
return <Skeleton className="h-44 w-full rounded-md" />
}
const rows: Row[] = CATEGORIES.map((cat) => {
const q = quotas?.find((x) => x.category === cat)
const c = counts?.find((x) => x.category === cat)
return {
category: cat,
quota: q?.quota ?? 0,
confirmed: c?.confirmed ?? 0,
pending: c?.pending ?? 0,
}
})
const handleSave = (category: CompetitionCategory) => {
const raw = draft[category]
const n = Number.parseInt(raw, 10)
if (Number.isNaN(n) || n < 0) {
toast.error('Quota must be a non-negative integer')
return
}
setQuotaMutation.mutate({ programId, category, quota: n })
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Trophy className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-base">Finalist slots</CardTitle>
</div>
<CardDescription>
Per-category quotas. Reductions blocked when {`> `}confirmed count un-confirm a team
first if you need to shrink a category.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{rows.map((r) => {
const isPending =
setQuotaMutation.isPending &&
setQuotaMutation.variables?.category === r.category
const dirty = String(r.quota) !== draft[r.category]
return (
<div
key={r.category}
className="flex items-center justify-between gap-3 rounded-md border p-3"
>
<div className="min-w-0 flex-1">
<div className="font-medium">{formatEnumLabel(r.category)}</div>
<div className="text-muted-foreground mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>
<Badge variant="default" className="text-xs">
{r.confirmed}
</Badge>{' '}
confirmed
</span>
<span>
<Badge variant="secondary" className="text-xs">
{r.pending}
</Badge>{' '}
pending
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Input
type="number"
inputMode="numeric"
min={0}
className="w-20 tabular-nums"
value={draft[r.category]}
onChange={(e) =>
setDraft((d) => ({ ...d, [r.category]: e.target.value }))
}
/>
<Button
size="sm"
variant={dirty ? 'default' : 'outline'}
disabled={!dirty || isPending}
onClick={() => handleSave(r.category)}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Save className="mr-1 h-3.5 w-3.5" />
Save
</>
)}
</Button>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,167 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { ListOrdered, Loader2 } from 'lucide-react'
import { formatEnumLabel } from '@/lib/utils'
import type { CompetitionCategory } from '@prisma/client'
interface Props {
programId: string
}
const STATUS_LABEL: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }> = {
WAITING: { label: 'Waiting', variant: 'outline' },
PROMOTED: { label: 'Promoted', variant: 'default' },
USED: { label: 'Used', variant: 'secondary' },
}
export function WaitlistCard({ programId }: Props) {
const utils = trpc.useUtils()
const { data, isLoading } = trpc.finalist.listWaitlist.useQuery({ programId })
const promoteMutation = trpc.finalist.manualPromote.useMutation({
onSuccess: () => {
toast.success('Waitlist entry promoted — confirmation email sent')
utils.finalist.listWaitlist.invalidate({ programId })
utils.finalist.listCategoryCounts.invalidate({ programId })
},
onError: (err) => toast.error(err.message),
})
if (isLoading) return <Skeleton className="h-44 w-full rounded-md" />
const byCategory = new Map<CompetitionCategory, typeof data>()
for (const entry of data ?? []) {
const list = byCategory.get(entry.category) ?? []
list.push(entry)
byCategory.set(entry.category, list)
}
if (!data || data.length === 0) {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<ListOrdered className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-base">Waitlist</CardTitle>
</div>
<CardDescription>
Per-category ranked waitlist. Auto-cascades when a finalist declines or expires.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground py-6 text-center text-sm">
No waitlist entries yet.
</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<ListOrdered className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-base">Waitlist</CardTitle>
</div>
<CardDescription>
Per-category ranked waitlist. Auto-cascades when a finalist declines or expires. You can
manually promote out of order overrides are audit-logged.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{Array.from(byCategory.entries()).map(([category, entries]) => (
<div key={category}>
<div className="text-muted-foreground mb-2 text-xs font-medium uppercase tracking-wide">
{formatEnumLabel(category)}
</div>
<div className="space-y-2">
{(entries ?? []).map((entry) => {
const badge = STATUS_LABEL[entry.status] ?? { label: entry.status, variant: 'outline' as const }
const canPromote = entry.status === 'WAITING'
const isPending =
promoteMutation.isPending && promoteMutation.variables?.waitlistEntryId === entry.id
return (
<div
key={entry.id}
className="flex items-center justify-between gap-3 rounded-md border p-3"
>
<div className="flex items-center gap-3">
<div className="bg-muted flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold tabular-nums">
{entry.rank}
</div>
<div className="min-w-0">
<div className="font-medium">{entry.project.title}</div>
<div className="text-muted-foreground text-xs">
{entry.project.country ?? '—'}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={badge.variant} className="text-xs">
{badge.label}
</Badge>
{canPromote && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline" disabled={isPending}>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Promote'
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Promote this team out of order?</AlertDialogTitle>
<AlertDialogDescription>
{entry.project.title} (rank #{entry.rank}) will be promoted into a
finalist slot. A confirmation email will be sent to the team lead
with a 24-hour window. This override is audit-logged.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
promoteMutation.mutate({
waitlistEntryId: entry.id,
windowHours: 24,
})
}
>
Promote
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
)
})}
</div>
</div>
))}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,235 @@
'use client'
import { useEffect, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Loader2 } from 'lucide-react'
import { toast } from 'sonner'
export type AttendanceMode = 'confirm' | 'decline'
export function AdminAttendanceDialog({
open,
mode,
confirmationId,
programId,
onOpenChange,
}: {
open: boolean
mode: AttendanceMode
confirmationId: string | null
programId: string
onOpenChange: (next: boolean) => void
}) {
const utils = trpc.useUtils()
const enabled = open && !!confirmationId
const { data: detail, isLoading } = trpc.finalist.getConfirmationDetail.useQuery(
{ confirmationId: confirmationId ?? '' },
{ enabled },
)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [visa, setVisa] = useState<Record<string, boolean>>({})
const [reason, setReason] = useState('')
const invalidate = () => {
utils.logistics.listConfirmations.invalidate({ programId })
}
const confirmMutation = trpc.finalist.adminConfirm.useMutation({
onSuccess: () => {
toast.success('Attendance confirmed')
invalidate()
onOpenChange(false)
},
onError: (e) => toast.error(e.message),
})
const declineMutation = trpc.finalist.adminDecline.useMutation({
onSuccess: () => {
toast.success('Marked as declined')
invalidate()
onOpenChange(false)
},
onError: (e) => toast.error(e.message),
})
// Reset form when the dialog opens for a new row
useEffect(() => {
if (!open) return
setReason('')
if (detail) {
// Default-pre-select the team lead + up to cap members
const cap = detail.project.program.defaultAttendeeCap
const initial = new Set(
detail.project.teamMembers.slice(0, cap).map((tm) => tm.userId),
)
setSelected(initial)
setVisa({})
}
}, [open, detail])
const isPending = confirmMutation.isPending || declineMutation.isPending
const handleConfirm = () => {
if (!confirmationId) return
const ids = Array.from(selected)
confirmMutation.mutate({
confirmationId,
attendingUserIds: ids,
visaFlags: Object.fromEntries(ids.map((id) => [id, !!visa[id]])),
})
}
const handleDecline = () => {
if (!confirmationId) return
declineMutation.mutate({
confirmationId,
reason: reason.trim() || undefined,
})
}
const cap = detail?.project.program.defaultAttendeeCap ?? 3
const overCap = selected.size > cap
const noneSelected = selected.size === 0
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!isPending) onOpenChange(next)
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{mode === 'confirm' ? 'Confirm attendance on team behalf' : 'Decline on team behalf'}
</DialogTitle>
<DialogDescription>
{mode === 'confirm'
? 'Use this when the team replied by email. The selected attendees will be locked in just like a public confirmation.'
: 'Use this when the team has told us they cannot attend. The slot will cascade to the next waitlist entry.'}
</DialogDescription>
</DialogHeader>
{isLoading || !detail ? (
<div className="space-y-2">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-20 w-full" />
</div>
) : mode === 'confirm' ? (
<>
<div className="text-sm">
<span className="text-muted-foreground">Project:</span>{' '}
<strong>{detail.project.title}</strong>
</div>
<ul className="space-y-2 max-h-[50vh] overflow-y-auto pr-1">
{detail.project.teamMembers.map((tm) => {
const checked = selected.has(tm.userId)
return (
<li
key={tm.userId}
className="flex items-start justify-between gap-4 rounded-md border px-3 py-2"
>
<label className="flex flex-1 items-start gap-3 cursor-pointer">
<Checkbox
checked={checked}
onCheckedChange={(c) => {
setSelected((prev) => {
const next = new Set(prev)
if (c === true) next.add(tm.userId)
else next.delete(tm.userId)
return next
})
}}
className="mt-0.5"
/>
<div>
<div className="text-sm font-medium">
{tm.user.name ?? tm.user.email}
</div>
<div className="text-muted-foreground text-xs">
{tm.user.email}
{tm.role && tm.role !== 'MEMBER' ? ` · ${tm.role.toLowerCase()}` : ''}
</div>
</div>
</label>
{checked && (
<label className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Visa?</span>
<Switch
checked={!!visa[tm.userId]}
onCheckedChange={(c) =>
setVisa((prev) => ({ ...prev, [tm.userId]: c }))
}
/>
</label>
)}
</li>
)
})}
</ul>
{overCap && (
<p className="text-destructive text-sm">
Please select no more than {cap} member{cap === 1 ? '' : 's'}.
</p>
)}
</>
) : (
<>
<div className="text-sm">
<span className="text-muted-foreground">Project:</span>{' '}
<strong>{detail.project.title}</strong>
</div>
<div className="space-y-2">
<label className="text-muted-foreground text-sm" htmlFor="admin-decline-reason">
Reason (optional)
</label>
<Textarea
id="admin-decline-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. logistics conflict, team disbanded, no longer interested"
rows={3}
/>
</div>
</>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Cancel
</Button>
{mode === 'confirm' ? (
<Button
onClick={handleConfirm}
disabled={!detail || overCap || noneSelected || isPending}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm attendance
</Button>
) : (
<Button
onClick={handleDecline}
disabled={!detail || isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Mark as declined
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,246 @@
'use client'
import { useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { formatEnumLabel } from '@/lib/utils'
import type { FinalistConfirmationStatus } from '@prisma/client'
import { AdminAttendanceDialog, type AttendanceMode } from './admin-attendance-dialog'
interface Props {
programId: string
}
type StatusFilter = 'all' | FinalistConfirmationStatus
const STATUS_BADGE: Record<
FinalistConfirmationStatus,
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
> = {
PENDING: { label: 'Pending', variant: 'secondary' },
CONFIRMED: { label: 'Confirmed', variant: 'default' },
DECLINED: { label: 'Declined', variant: 'destructive' },
EXPIRED: { label: 'Expired', variant: 'outline' },
SUPERSEDED: { label: 'Superseded', variant: 'outline' },
}
function formatDeadline(d: Date): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(d)
}
function relativeFromNow(d: Date): string {
const ms = d.getTime() - Date.now()
if (ms <= 0) return 'past deadline'
const hours = Math.floor(ms / 3_600_000)
const days = Math.floor(hours / 24)
if (days >= 1) return `in ${days}d`
return `in ${hours}h`
}
export function ConfirmationsTab({ programId }: Props) {
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [dialogState, setDialogState] = useState<{
open: boolean
mode: AttendanceMode
confirmationId: string | null
}>({ open: false, mode: 'confirm', confirmationId: null })
const { data, isLoading } = trpc.logistics.listConfirmations.useQuery(
{ programId },
{ refetchInterval: 60_000 },
)
const filtered = useMemo(() => {
if (!data) return []
return statusFilter === 'all' ? data : data.filter((r) => r.status === statusFilter)
}, [data, statusFilter])
const totals = useMemo(() => {
const counts: Record<FinalistConfirmationStatus, number> = {
PENDING: 0,
CONFIRMED: 0,
DECLINED: 0,
EXPIRED: 0,
SUPERSEDED: 0,
}
for (const r of data ?? []) counts[r.status]++
return counts
}, [data])
const StatusPill = ({ value, label, count }: { value: StatusFilter; label: string; count: number }) => (
<button
type="button"
onClick={() => setStatusFilter(value)}
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
statusFilter === value
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-muted'
}`}
>
{label} <span className="tabular-nums opacity-80">({count})</span>
</button>
)
return (
<div className="space-y-4">
<Card>
<CardHeader className="space-y-3">
<div className="flex items-center justify-between gap-4">
<CardTitle className="text-base">All confirmations</CardTitle>
<div className="flex flex-wrap gap-1.5">
<StatusPill
value="all"
label="All"
count={(data ?? []).length}
/>
<StatusPill value="PENDING" label="Pending" count={totals.PENDING} />
<StatusPill value="CONFIRMED" label="Confirmed" count={totals.CONFIRMED} />
<StatusPill value="DECLINED" label="Declined" count={totals.DECLINED} />
<StatusPill value="EXPIRED" label="Expired" count={totals.EXPIRED} />
<StatusPill value="SUPERSEDED" label="Superseded" count={totals.SUPERSEDED} />
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : filtered.length === 0 ? (
<p className="text-muted-foreground py-12 text-center text-sm">
{statusFilter === 'all'
? 'No finalists have been selected yet. Use the grand-finale round page to send confirmations.'
: 'No confirmations match this filter.'}
</p>
) : (
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Status</TableHead>
<TableHead>Deadline</TableHead>
<TableHead className="text-right">Attendees</TableHead>
<TableHead>Notes</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((r) => {
const badge = STATUS_BADGE[r.status]
const isPending = r.status === 'PENDING'
return (
<TableRow key={r.id}>
<TableCell>
<div className="font-medium">{r.project.title}</div>
<div className="text-muted-foreground text-xs">
{formatEnumLabel(r.category)}
{r.project.country && (
<>
{' · '}
{r.project.country}
</>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={badge.variant} className="text-xs">
{badge.label}
</Badge>
{r.promotedFromWaitlistEntryId && (
<Badge variant="outline" className="ml-1 text-xs">
Waitlist
</Badge>
)}
</TableCell>
<TableCell className="text-sm">
<div>{formatDeadline(new Date(r.deadline))}</div>
{r.status === 'PENDING' && (
<div className="text-muted-foreground text-xs">
{relativeFromNow(new Date(r.deadline))}
</div>
)}
</TableCell>
<TableCell className="text-right tabular-nums">
{r.attendeeCount}
</TableCell>
<TableCell className="text-muted-foreground max-w-[20rem] truncate text-xs">
{r.status === 'DECLINED' && r.declineReason
? `Reason: ${r.declineReason}`
: r.status === 'CONFIRMED' && r.confirmedAt
? `Confirmed ${formatDeadline(new Date(r.confirmedAt))}`
: r.status === 'EXPIRED' && r.expiredAt
? `Expired ${formatDeadline(new Date(r.expiredAt))}`
: '—'}
</TableCell>
<TableCell className="text-right">
{isPending ? (
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="default"
onClick={() =>
setDialogState({
open: true,
mode: 'confirm',
confirmationId: r.id,
})
}
>
Confirm
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
setDialogState({
open: true,
mode: 'decline',
confirmationId: r.id,
})
}
>
Decline
</Button>
</div>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<AdminAttendanceDialog
open={dialogState.open}
mode={dialogState.mode}
confirmationId={dialogState.confirmationId}
programId={programId}
onOpenChange={(next) =>
setDialogState((prev) => ({ ...prev, open: next }))
}
/>
</div>
)
}

View File

@@ -0,0 +1,175 @@
'use client'
import { useEffect, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Skeleton } from '@/components/ui/skeleton'
import { ExternalLink, Hotel as HotelIcon, Loader2, Save } from 'lucide-react'
interface Props {
programId: string
}
export function HotelsTab({ programId }: Props) {
const utils = trpc.useUtils()
const { data: hotel, isLoading } = trpc.logistics.getHotel.useQuery({ programId })
const [name, setName] = useState('')
const [address, setAddress] = useState('')
const [link, setLink] = useState('')
const [notes, setNotes] = useState('')
// Sync form state from server data on first load / after save.
useEffect(() => {
if (hotel) {
setName(hotel.name)
setAddress(hotel.address ?? '')
setLink(hotel.link ?? '')
setNotes(hotel.notes ?? '')
}
}, [hotel])
const upsertMutation = trpc.logistics.upsertHotel.useMutation({
onSuccess: () => {
toast.success('Hotel saved')
utils.logistics.getHotel.invalidate({ programId })
},
onError: (err) => toast.error(err.message),
})
const handleSave = () => {
if (!name.trim()) {
toast.error('Hotel name is required')
return
}
upsertMutation.mutate({
programId,
name: name.trim(),
address: address.trim() || undefined,
link: link.trim() || '',
notes: notes.trim() || undefined,
})
}
if (isLoading) return <Skeleton className="h-96 w-full" />
const dirty =
name !== (hotel?.name ?? '') ||
address !== (hotel?.address ?? '') ||
link !== (hotel?.link ?? '') ||
notes !== (hotel?.notes ?? '')
return (
<div className="grid gap-4 md:grid-cols-3">
<div className="md:col-span-2">
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<HotelIcon className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-base">Hotel for this edition</CardTitle>
</div>
<CardDescription>
One hotel per edition. Used in confirmation emails and finalist communications.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="hotel-name">Name *</Label>
<Input
id="hotel-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Hôtel de Paris"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="hotel-address">Address</Label>
<Textarea
id="hotel-address"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="Place du Casino, 98000 Monaco"
rows={2}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="hotel-link">Hotel website / booking link</Label>
<Input
id="hotel-link"
type="url"
value={link}
onChange={(e) => setLink(e.target.value)}
placeholder="https://hoteldeparismontecarlo.com"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="hotel-notes">Internal notes</Label>
<Textarea
id="hotel-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Check-in time, special arrangements, etc."
rows={3}
/>
</div>
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={!dirty || upsertMutation.isPending}
>
{upsertMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save
</Button>
</div>
</CardContent>
</Card>
</div>
<div className="md:col-span-1">
<Card>
<CardHeader>
<CardTitle className="text-base">Email preview</CardTitle>
<CardDescription>What teams will see in confirmation emails.</CardDescription>
</CardHeader>
<CardContent>
{!name.trim() ? (
<p className="text-muted-foreground text-sm">Save a hotel to see the preview.</p>
) : (
<div className="bg-muted/30 rounded-md border p-4 text-sm">
<div className="text-muted-foreground mb-1 text-xs uppercase tracking-wide">
Your accommodation
</div>
<div className="font-semibold">{name}</div>
{address.trim() && (
<div className="text-muted-foreground mt-1 whitespace-pre-line text-xs">
{address}
</div>
)}
{link.trim() && (
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
>
Visit hotel website <ExternalLink className="h-3 w-3" />
</a>
)}
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,160 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
const DIETARY_TAGS = ['VEGETARIAN', 'VEGAN', 'GLUTEN_FREE', 'PESCATARIAN'] as const
type DietaryTag = (typeof DIETARY_TAGS)[number]
function formatTag(tag: string): string {
return tag.replace('_', ' ').toLowerCase()
}
export function LunchDishes({
programId,
lunchEventId,
}: {
programId: string
lunchEventId: string
}) {
const utils = trpc.useUtils()
const { data: dishes } = trpc.lunch.listDishes.useQuery({ lunchEventId })
const invalidateAll = () => {
utils.lunch.listDishes.invalidate({ lunchEventId })
utils.lunch.getManifest.invalidate({ programId })
}
const create = trpc.lunch.createDish.useMutation({
onSuccess: invalidateAll,
onError: (e) => toast.error(e.message),
})
const update = trpc.lunch.updateDish.useMutation({
onSuccess: invalidateAll,
onError: (e) => toast.error(e.message),
})
const del = trpc.lunch.deleteDish.useMutation({
onSuccess: invalidateAll,
onError: (e) => toast.error(e.message),
})
const [newName, setNewName] = useState('')
const [newTags, setNewTags] = useState<DietaryTag[]>([])
return (
<Card>
<CardHeader>
<CardTitle>Dishes</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{dishes && dishes.length === 0 && (
<p className="text-muted-foreground text-sm">
Add at least one dish to open picks.
</p>
)}
<ul className="space-y-2">
{dishes?.map((d) => (
<li
key={d.id}
className="flex items-center gap-3 rounded-md border p-3"
>
<span className="font-medium">{d.name}</span>
<div className="flex gap-1">
{d.dietaryTags.map((t) => (
<Badge key={t} variant="outline">
{formatTag(t)}
</Badge>
))}
</div>
<div className="ml-auto flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => {
const name = prompt('Edit dish name', d.name)
if (name && name !== d.name) {
update.mutate({ dishId: d.id, name })
}
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
if (
confirm(
`Delete "${d.name}"? Existing picks will go back to "not picked".`,
)
) {
del.mutate({ dishId: d.id })
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
<div className="flex flex-wrap items-center gap-2 border-t pt-4">
<Input
placeholder="New dish name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="max-w-xs"
/>
<div className="flex flex-wrap gap-1">
{DIETARY_TAGS.map((t) => (
<Button
key={t}
size="sm"
variant={newTags.includes(t) ? 'default' : 'outline'}
type="button"
onClick={() =>
setNewTags(
newTags.includes(t)
? newTags.filter((x) => x !== t)
: [...newTags, t],
)
}
>
{formatTag(t)}
</Button>
))}
</div>
<Button
disabled={!newName.trim() || create.isPending}
onClick={() => {
if (!newName.trim()) return
create.mutate(
{
lunchEventId,
name: newName.trim(),
dietaryTags: newTags,
sortOrder: dishes?.length ?? 0,
},
{
onSuccess: () => {
setNewName('')
setNewTags([])
},
},
)
}}
>
<Plus className="mr-1 h-4 w-4" /> Add
</Button>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,246 @@
'use client'
import { useState } from 'react'
import type { LunchEvent } from '@prisma/client'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { X } from 'lucide-react'
import { toast } from 'sonner'
function toLocalDateTimeInputValue(d: Date | null | undefined): string {
if (!d) return ''
// datetime-local expects "YYYY-MM-DDTHH:mm" in local time.
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
d.getHours(),
)}:${pad(d.getMinutes())}`
}
export function LunchEventConfig({
programId,
event,
}: {
programId: string
event: LunchEvent
}) {
const utils = trpc.useUtils()
const update = trpc.lunch.updateEvent.useMutation({
onSuccess: () => {
utils.lunch.getEvent.invalidate({ programId })
utils.lunch.getEventForMember.invalidate({ programId })
},
onError: (e) => toast.error(e.message),
})
const [extraInput, setExtraInput] = useState('')
const eventAt = event.eventAt ? new Date(event.eventAt) : null
const endAt = event.endAt ? new Date(event.endAt) : null
return (
<Card>
<CardHeader>
<CardTitle>Event configuration</CardTitle>
<CardDescription>Per-edition lunch settings.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* enabled */}
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<div className="space-y-1">
<Label htmlFor="lunch-enabled">Enable lunch event</Label>
<p className="text-muted-foreground text-xs">
When off, attendees see no banner or picker; admins still see this tab.
</p>
</div>
<Switch
id="lunch-enabled"
checked={event.enabled}
onCheckedChange={(v) => update.mutate({ programId, enabled: v })}
disabled={update.isPending}
/>
</div>
{/* eventAt */}
<div className="space-y-1.5">
<Label htmlFor="event-at">Event start</Label>
<Input
id="event-at"
type="datetime-local"
defaultValue={toLocalDateTimeInputValue(eventAt)}
onBlur={(e) => {
const v = e.target.value
update.mutate({ programId, eventAt: v ? new Date(v) : null })
}}
disabled={update.isPending}
className="max-w-sm"
/>
</div>
{/* endAt */}
<div className="space-y-1.5">
<Label htmlFor="end-at">Event end (optional)</Label>
<Input
id="end-at"
type="datetime-local"
defaultValue={toLocalDateTimeInputValue(endAt)}
onBlur={(e) => {
const v = e.target.value
update.mutate({ programId, endAt: v ? new Date(v) : null })
}}
disabled={update.isPending}
className="max-w-sm"
/>
</div>
{/* venue */}
<div className="space-y-1.5">
<Label htmlFor="venue">Venue (optional)</Label>
<Input
id="venue"
defaultValue={event.venue ?? ''}
placeholder="e.g. Hôtel Hermitage, Salle Belle Époque"
onBlur={(e) =>
update.mutate({ programId, venue: e.target.value || null })
}
disabled={update.isPending}
/>
</div>
{/* notes */}
<div className="space-y-1.5">
<Label htmlFor="notes">Notes for attendees (optional)</Label>
<Textarea
id="notes"
defaultValue={event.notes ?? ''}
placeholder="Wine pairings included. Vegetarian options at table 4."
onBlur={(e) =>
update.mutate({ programId, notes: e.target.value || null })
}
disabled={update.isPending}
/>
</div>
{/* changeCutoffHours */}
<div className="space-y-1.5">
<Label htmlFor="cutoff">Change cutoff (hours before event)</Label>
<Input
id="cutoff"
type="number"
min={0}
max={720}
defaultValue={event.changeCutoffHours}
onBlur={(e) => {
const n = Number(e.target.value)
if (Number.isFinite(n) && n !== event.changeCutoffHours) {
update.mutate({ programId, changeCutoffHours: n })
}
}}
disabled={update.isPending}
className="max-w-[12rem]"
/>
<p className="text-muted-foreground text-xs">
After this many hours before the event, attendees and team leads can
no longer change their picks. Admins always can.
</p>
</div>
{/* reminderHoursBeforeDeadline */}
<div className="space-y-1.5">
<Label htmlFor="reminder">Reminder (hours before deadline)</Label>
<Input
id="reminder"
type="number"
min={0}
max={720}
defaultValue={event.reminderHoursBeforeDeadline ?? ''}
placeholder="Leave blank for no reminder"
onBlur={(e) => {
const v = e.target.value
update.mutate({
programId,
reminderHoursBeforeDeadline: v === '' ? null : Number(v),
})
}}
disabled={update.isPending}
className="max-w-[12rem]"
/>
</div>
{/* cronEnabled */}
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<div className="space-y-1">
<Label htmlFor="cron-enabled">Auto-send recap at deadline</Label>
<p className="text-muted-foreground text-xs">
When on, the platform automatically emails the manifest when the
change deadline passes.
</p>
</div>
<Switch
id="cron-enabled"
checked={event.cronEnabled}
onCheckedChange={(v) => update.mutate({ programId, cronEnabled: v })}
disabled={update.isPending}
/>
</div>
{/* extraRecipients */}
<div className="space-y-1.5">
<Label>Extra recap recipients (optional)</Label>
<p className="text-muted-foreground text-xs">
All edition admins receive the recap automatically. Add additional
email addresses here (e.g. caterer, event manager).
</p>
<div className="flex flex-wrap gap-2">
{event.extraRecipients.map((email) => (
<Badge key={email} variant="secondary" className="gap-1">
{email}
<button
type="button"
className="ml-1"
onClick={() =>
update.mutate({
programId,
extraRecipients: event.extraRecipients.filter(
(e) => e !== email,
),
})
}
aria-label={`Remove ${email}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<Input
placeholder="email@example.com — press Enter to add"
value={extraInput}
onChange={(e) => setExtraInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && extraInput.trim()) {
e.preventDefault()
const next = [
...event.extraRecipients,
extraInput.trim(),
]
update.mutate({ programId, extraRecipients: next })
setExtraInput('')
}
}}
className="max-w-sm"
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,341 @@
'use client'
import { useState, useImperativeHandle, forwardRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
const ALLERGENS = [
'GLUTEN',
'CRUSTACEANS',
'EGGS',
'FISH',
'PEANUTS',
'SOYBEANS',
'MILK',
'TREE_NUTS',
'CELERY',
'MUSTARD',
'SESAME',
'SULPHITES',
'LUPIN',
'MOLLUSCS',
] as const
type Allergen = (typeof ALLERGENS)[number]
const STANDALONE = '__standalone__'
const NO_DISH = '__no_dish__'
type Editing = { mode: 'new' } | { mode: 'edit'; id: string } | null
export type LunchExternalsHandle = {
openEditDialog: (id: string) => void
}
export const LunchExternals = forwardRef<
LunchExternalsHandle,
{ programId: string; lunchEventId: string }
>(function LunchExternals({ programId, lunchEventId }, ref) {
const utils = trpc.useUtils()
const { data: externals } = trpc.lunch.listExternals.useQuery({ lunchEventId })
const { data: dishes } = trpc.lunch.listDishes.useQuery({ lunchEventId })
const { data: projects } = trpc.program.listFinalistProjects.useQuery({
programId,
})
const [editing, setEditing] = useState<Editing>(null)
useImperativeHandle(
ref,
() => ({
openEditDialog: (id: string) => setEditing({ mode: 'edit', id }),
}),
[],
)
const invalidateAll = () => {
utils.lunch.listExternals.invalidate({ lunchEventId })
utils.lunch.getManifest.invalidate({ programId })
}
const create = trpc.lunch.createExternal.useMutation({
onSuccess: invalidateAll,
onError: (e) => toast.error(e.message),
})
const update = trpc.lunch.updateExternal.useMutation({
onSuccess: invalidateAll,
onError: (e) => toast.error(e.message),
})
const del = trpc.lunch.deleteExternal.useMutation({
onSuccess: invalidateAll,
onError: (e) => toast.error(e.message),
})
const editingRow =
editing?.mode === 'edit'
? (externals?.find((e) => e.id === editing.id) ?? null)
: null
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>External attendees</span>
<Button size="sm" onClick={() => setEditing({ mode: 'new' })}>
<Plus className="mr-1 h-4 w-4" /> Add external
</Button>
</CardTitle>
</CardHeader>
<CardContent>
{externals?.length === 0 && (
<p className="text-muted-foreground text-sm">
No external attendees yet. Add jurors, dignitaries, or per-team plus-ones.
</p>
)}
{externals && externals.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<tbody>
{externals.map((e) => (
<tr key={e.id} className="border-b last:border-b-0">
<td className="py-2 font-medium">{e.name}</td>
<td className="text-muted-foreground">
{e.project?.title ?? 'Standalone'}
</td>
<td className="text-muted-foreground">{e.roleNote ?? ''}</td>
<td className="text-right">
<Button
size="sm"
variant="ghost"
onClick={() => setEditing({ mode: 'edit', id: e.id })}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
if (confirm(`Delete external attendee "${e.name}"?`)) {
del.mutate({ externalId: e.id })
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
{editing && (
<ExternalDialog
mode={editing.mode}
initial={editingRow}
dishes={dishes ?? []}
projects={projects ?? []}
submitting={create.isPending || update.isPending}
onClose={() => setEditing(null)}
onSubmit={(values) => {
if (editing.mode === 'new') {
create.mutate(
{ lunchEventId, ...values },
{ onSuccess: () => setEditing(null) },
)
} else {
update.mutate(
{ externalId: editing.id, ...values },
{ onSuccess: () => setEditing(null) },
)
}
}}
/>
)}
</Card>
)
})
function ExternalDialog({
mode,
initial,
dishes,
projects,
submitting,
onClose,
onSubmit,
}: {
mode: 'new' | 'edit'
initial: {
name: string
email: string | null
projectId: string | null
roleNote: string | null
dishId: string | null
allergens: string[]
allergenOther: string | null
} | null
dishes: Array<{ id: string; name: string }>
projects: Array<{ id: string; title: string }>
submitting: boolean
onClose: () => void
onSubmit: (values: {
name: string
email?: string
projectId?: string | null
roleNote?: string
dishId?: string | null
allergens: Allergen[]
allergenOther?: string | null
}) => void
}) {
const [name, setName] = useState(initial?.name ?? '')
const [email, setEmail] = useState(initial?.email ?? '')
const [projectId, setProjectId] = useState(initial?.projectId ?? '')
const [roleNote, setRoleNote] = useState(initial?.roleNote ?? '')
const [dishId, setDishId] = useState(initial?.dishId ?? '')
const [allergens, setAllergens] = useState<Allergen[]>(
(initial?.allergens as Allergen[]) ?? [],
)
const [allergenOther, setAllergenOther] = useState(initial?.allergenOther ?? '')
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose() }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{mode === 'new' ? 'Add external attendee' : 'Edit external attendee'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Name *</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<Label>Email (optional)</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<Label>Project (optional)</Label>
<Select
value={projectId === '' ? STANDALONE : projectId}
onValueChange={(v) => setProjectId(v === STANDALONE ? '' : v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={STANDALONE}>Standalone</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Role / note (optional)</Label>
<Input
value={roleNote}
onChange={(e) => setRoleNote(e.target.value)}
placeholder="e.g. Foundation rep, Speaker, Sponsor"
/>
</div>
<div>
<Label>Dish</Label>
<Select
value={dishId === '' ? NO_DISH : dishId}
onValueChange={(v) => setDishId(v === NO_DISH ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder="Not picked" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_DISH}>Not picked</SelectItem>
{dishes.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Allergens</Label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{ALLERGENS.map((a) => (
<label key={a} className="flex items-center gap-2 text-sm">
<Checkbox
checked={allergens.includes(a)}
onCheckedChange={(v) =>
setAllergens(
v ? [...allergens, a] : allergens.filter((x) => x !== a),
)
}
/>
{a.replace('_', ' ').toLowerCase()}
</label>
))}
</div>
</div>
<div>
<Label>Other allergens / notes (optional)</Label>
<Textarea
value={allergenOther}
onChange={(e) => setAllergenOther(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
disabled={!name.trim() || submitting}
onClick={() =>
onSubmit({
name: name.trim(),
email: email.trim() || undefined,
projectId: projectId || null,
roleNote: roleNote.trim() || undefined,
dishId: dishId || null,
allergens,
allergenOther: allergenOther.trim() || null,
})
}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,254 @@
'use client'
import { useState, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet'
import { LunchPickForm } from '@/components/applicant/lunch-pick-form'
import { Pencil, Download } from 'lucide-react'
import { toast } from 'sonner'
function formatAllergens(allergens: string[], other: string | null): string {
return [...allergens.map((a) => a.replace('_', ' ').toLowerCase()), other]
.filter(Boolean)
.join(', ')
}
function DownloadCsvButton({ programId }: { programId: string }) {
const utils = trpc.useUtils()
const [pending, setPending] = useState(false)
return (
<Button
variant="outline"
size="sm"
disabled={pending}
onClick={async () => {
setPending(true)
try {
const csv = await utils.client.lunch.exportManifestCsv.query({ programId })
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'lunch-manifest.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (e) {
toast.error((e as Error).message)
} finally {
setPending(false)
}
}}
>
<Download className="mr-1 h-4 w-4" /> Download CSV
</Button>
)
}
export function LunchManifest({
programId,
onEditExternal,
}: {
programId: string
onEditExternal?: (externalId: string) => void
}) {
const { data } = trpc.lunch.getManifest.useQuery({ programId })
const [search, setSearch] = useState('')
const [missingOnly, setMissingOnly] = useState(false)
const [editingMemberId, setEditingMemberId] = useState<string | null>(null)
const editingMember = data?.members.find(
(m) => m.attendingMemberId === editingMemberId,
)
type Row =
| (NonNullable<typeof data>['members'][number] & { sortKey: string })
| (NonNullable<typeof data>['externals'][number] & { sortKey: string })
const rows: Row[] = useMemo(() => {
if (!data) return []
const all: Row[] = [
...data.members.map((m) => ({
...m,
sortKey: `0-${m.project?.name ?? ''}-${m.name}`,
})),
...data.externals.map((e) => ({
...e,
sortKey: `1-${e.project?.name ?? ''}-${e.name}`,
})),
]
return all
.filter(
(r) =>
!search ||
(r.project?.name ?? '').toLowerCase().includes(search.toLowerCase()) ||
r.name.toLowerCase().includes(search.toLowerCase()),
)
.filter((r) => !missingOnly || !r.dish)
.sort((a, b) => a.sortKey.localeCompare(b.sortKey))
}, [data, search, missingOnly])
if (!data) return null
// Aggregate dietary + allergen counts client-side for the summary chip
const dietaryCounts: Record<string, number> = {}
const allergenCounts: Record<string, number> = {}
const allRows = [...data.members, ...data.externals]
for (const r of allRows) {
if (r.dish) {
for (const t of r.dish.dietaryTags) {
dietaryCounts[t] = (dietaryCounts[t] ?? 0) + 1
}
}
for (const a of r.allergens) {
allergenCounts[a] = (allergenCounts[a] ?? 0) + 1
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex flex-wrap items-center gap-2">
<span>Manifest</span>
<Badge variant="outline">
{data.summary.picked}/{data.summary.total} picked
{data.summary.missing > 0 ? ` · ${data.summary.missing} missing` : ''}
</Badge>
{Object.entries(dietaryCounts).map(([tag, n]) => (
<Badge key={tag} variant="secondary">
{n} {tag.replace('_', ' ').toLowerCase()}
</Badge>
))}
{Object.entries(allergenCounts).map(([a, n]) => (
<Badge key={a} variant="destructive">
{n} {a.replace('_', ' ').toLowerCase()}
</Badge>
))}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-4">
<Input
placeholder="Filter by team or name"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-xs"
/>
<div className="flex items-center gap-2">
<Switch
id="missing-only"
checked={missingOnly}
onCheckedChange={setMissingOnly}
/>
<Label htmlFor="missing-only">Missing picks only</Label>
</div>
<div className="ml-auto">
<DownloadCsvButton programId={programId} />
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-muted-foreground border-b text-left">
<tr>
<th className="py-2 font-medium">Team</th>
<th className="font-medium">Attendee</th>
<th className="font-medium">Type</th>
<th className="font-medium">Dish</th>
<th className="font-medium">Allergens</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const id =
r.kind === 'MEMBER' ? r.attendingMemberId : r.externalId
return (
<tr key={id} className="border-b">
<td className="py-2">{r.project?.name ?? '—'}</td>
<td>{r.name}</td>
<td>
<Badge variant="outline">
{r.kind === 'MEMBER' ? 'Member' : 'External'}
</Badge>
</td>
<td>
{r.dish ? (
r.dish.name
) : (
<span className="text-muted-foreground">not picked</span>
)}
</td>
<td className="text-muted-foreground">
{formatAllergens(r.allergens, r.allergenOther)}
</td>
<td className="text-right">
<Button
size="sm"
variant="ghost"
onClick={() => {
if (r.kind === 'EXTERNAL') {
onEditExternal?.(r.externalId)
} else {
setEditingMemberId(r.attendingMemberId)
}
}}
>
<Pencil className="h-4 w-4" />
</Button>
</td>
</tr>
)
})}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="text-muted-foreground py-6 text-center">
No rows match the current filter.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
<Sheet
open={!!editingMemberId}
onOpenChange={(o) => { if (!o) setEditingMemberId(null) }}
>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle>Edit lunch pick</SheetTitle>
{editingMember && (
<SheetDescription>
{editingMember.name} · {editingMember.project?.name}
</SheetDescription>
)}
</SheetHeader>
{editingMemberId && data?.event && (
<div className="mt-6">
<LunchPickForm
attendingMemberId={editingMemberId}
programId={programId}
lunchEventId={data.event.id}
canEdit
/>
</div>
)}
</SheetContent>
</Sheet>
</Card>
)
}

View File

@@ -0,0 +1,147 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Send, Eye } from 'lucide-react'
import { toast } from 'sonner'
export function LunchRecapActions({
programId,
recapSentAt,
extraRecipientCount,
}: {
programId: string
recapSentAt: Date | null
extraRecipientCount: number
}) {
const utils = trpc.useUtils()
const [previewOpen, setPreviewOpen] = useState(false)
const send = trpc.lunch.sendRecap.useMutation({
onSuccess: () => {
utils.lunch.getEvent.invalidate({ programId })
toast.success('Recap sent')
},
onError: (e) => {
if (e.data?.code === 'PRECONDITION_FAILED') {
if (
confirm(
"You've already sent a recap. Send updated version to all recipients?",
)
) {
send.mutate({ programId, forceUpdate: true })
}
} else {
toast.error(e.message)
}
},
})
const { data: preview, isLoading: loadingPreview } =
trpc.lunch.getRecapPreview.useQuery(
{ programId },
{ enabled: previewOpen },
)
return (
<Card>
<CardHeader>
<CardTitle>Recap</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setPreviewOpen(true)}>
<Eye className="mr-2 h-4 w-4" /> Preview recap
</Button>
<Button
onClick={() => send.mutate({ programId })}
disabled={send.isPending}
>
<Send className="mr-2 h-4 w-4" /> Send recap now
</Button>
</div>
<p className="text-muted-foreground text-xs">
{recapSentAt
? `Last sent: ${new Date(recapSentAt).toLocaleString()}. Recipients: edition admins${extraRecipientCount > 0 ? ` + ${extraRecipientCount} extra` : ''}.`
: 'Recap has not been sent yet.'}
</p>
</CardContent>
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Recap preview</DialogTitle>
</DialogHeader>
{loadingPreview && (
<p className="text-muted-foreground text-sm">Loading</p>
)}
{preview && (
<div className="space-y-4 text-sm">
<p>
<strong>
{preview.summary.picked}/{preview.summary.total}
</strong>{' '}
picked
{preview.summary.missing > 0
? ` · ${preview.summary.missing} missing`
: ''}
</p>
{Object.keys(preview.dishCounts).length > 0 && (
<div>
<h4 className="font-medium">Dishes</h4>
<ul className="ml-4 list-disc">
{Object.entries(preview.dishCounts).map(([n, c]) => (
<li key={n}>
{c}× {n}
</li>
))}
</ul>
</div>
)}
{Object.keys(preview.dietaryCounts).length > 0 && (
<div>
<h4 className="font-medium">Dietary tags</h4>
<ul className="ml-4 list-disc">
{Object.entries(preview.dietaryCounts).map(([n, c]) => (
<li key={n}>
{c}× {n.replace('_', ' ').toLowerCase()}
</li>
))}
</ul>
</div>
)}
<div>
<h4 className="font-medium">Allergens</h4>
{Object.keys(preview.allergenCounts).length === 0 ? (
<p className="text-muted-foreground">None reported.</p>
) : (
<ul className="ml-4 list-disc">
{Object.entries(preview.allergenCounts).map(([n, c]) => (
<li key={n}>
{c}× {n.replace('_', ' ').toLowerCase()}
</li>
))}
</ul>
)}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setPreviewOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}

View File

@@ -0,0 +1,42 @@
'use client'
import { useRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Skeleton } from '@/components/ui/skeleton'
import { LunchEventConfig } from './lunch-event-config'
import { LunchDishes } from './lunch-dishes'
import { LunchManifest } from './lunch-manifest'
import { LunchExternals, type LunchExternalsHandle } from './lunch-externals'
import { LunchRecapActions } from './lunch-recap-actions'
export function LunchTab({ programId }: { programId: string }) {
const { data: event, isLoading } = trpc.lunch.getEvent.useQuery({ programId })
const externalsRef = useRef<LunchExternalsHandle>(null)
if (isLoading || !event) {
return <Skeleton className="h-48 w-full" />
}
return (
<div className="space-y-6">
<LunchEventConfig programId={programId} event={event} />
{event.enabled && (
<>
<LunchDishes programId={programId} lunchEventId={event.id} />
<LunchManifest
programId={programId}
onEditExternal={(id) => externalsRef.current?.openEditDialog(id)}
/>
<LunchExternals
ref={externalsRef}
programId={programId}
lunchEventId={event.id}
/>
<LunchRecapActions
programId={programId}
recapSentAt={event.recapSentAt}
extraRecipientCount={event.extraRecipients.length}
/>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,426 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Skeleton } from '@/components/ui/skeleton'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Loader2, Plane } from 'lucide-react'
import type { FlightDetailStatus } from '@prisma/client'
interface Props {
programId: string
}
type AttendeeRow = {
id: string
needsVisa: boolean
user: { id: string; name: string | null; email: string; country: string | null }
confirmation: {
project: {
id: string
title: string
country: string | null
competitionCategory: string | null
}
}
flightDetail: {
id: string
arrivalAt: Date | null
arrivalFlightNumber: string | null
arrivalAirport: string | null
departureAt: Date | null
departureFlightNumber: string | null
departureAirport: string | null
status: FlightDetailStatus
adminNotes: string | null
} | null
}
type StatusFilter = 'all' | 'PENDING' | 'CONFIRMED' | 'unfilled'
function formatDateTime(d: Date | null): string {
if (!d) return '—'
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(d))
}
function isoLocalForInput(d: Date | null): string {
if (!d) return ''
// Format as 'YYYY-MM-DDTHH:mm' for datetime-local input
const local = new Date(d.getTime() - new Date().getTimezoneOffset() * 60000)
return local.toISOString().slice(0, 16)
}
function FlightEditorSheet({
attendee,
programId,
open,
onClose,
}: {
attendee: AttendeeRow | null
programId: string
open: boolean
onClose: () => void
}) {
const utils = trpc.useUtils()
const [arrivalAt, setArrivalAt] = useState('')
const [arrivalFlightNumber, setArrivalFlightNumber] = useState('')
const [arrivalAirport, setArrivalAirport] = useState('')
const [departureAt, setDepartureAt] = useState('')
const [departureFlightNumber, setDepartureFlightNumber] = useState('')
const [departureAirport, setDepartureAirport] = useState('')
const [adminNotes, setAdminNotes] = useState('')
useEffect(() => {
if (!attendee) return
const fd = attendee.flightDetail
setArrivalAt(isoLocalForInput(fd?.arrivalAt ?? null))
setArrivalFlightNumber(fd?.arrivalFlightNumber ?? '')
setArrivalAirport(fd?.arrivalAirport ?? '')
setDepartureAt(isoLocalForInput(fd?.departureAt ?? null))
setDepartureFlightNumber(fd?.departureFlightNumber ?? '')
setDepartureAirport(fd?.departureAirport ?? '')
setAdminNotes(fd?.adminNotes ?? '')
}, [attendee])
const upsertMutation = trpc.logistics.upsertFlightDetail.useMutation({
onSuccess: () => {
toast.success('Flight details saved')
utils.logistics.listFlightDetails.invalidate({ programId })
onClose()
},
onError: (err) => toast.error(err.message),
})
if (!attendee) return null
const handleSave = () => {
upsertMutation.mutate({
attendingMemberId: attendee.id,
arrivalAt: arrivalAt ? new Date(arrivalAt) : null,
arrivalFlightNumber: arrivalFlightNumber.trim() || null,
arrivalAirport: arrivalAirport.trim().toUpperCase() || null,
departureAt: departureAt ? new Date(departureAt) : null,
departureFlightNumber: departureFlightNumber.trim() || null,
departureAirport: departureAirport.trim().toUpperCase() || null,
adminNotes: adminNotes.trim() || null,
})
}
return (
<Sheet open={open} onOpenChange={(o) => !o && onClose()}>
<SheetContent className="sm:max-w-md overflow-y-auto">
<SheetHeader>
<SheetTitle>{attendee.user.name ?? attendee.user.email}</SheetTitle>
<SheetDescription>
{attendee.confirmation.project.title}
</SheetDescription>
</SheetHeader>
<div className="space-y-5 py-6">
<div className="space-y-3 rounded-md border p-3">
<div className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Arrival
</div>
<div className="space-y-1.5">
<Label htmlFor="arr-at">Date &amp; time</Label>
<Input
id="arr-at"
type="datetime-local"
value={arrivalAt}
onChange={(e) => setArrivalAt(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<Label htmlFor="arr-flight">Flight number</Label>
<Input
id="arr-flight"
value={arrivalFlightNumber}
onChange={(e) => setArrivalFlightNumber(e.target.value)}
placeholder="AF7400"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="arr-airport">Airport (IATA)</Label>
<Input
id="arr-airport"
value={arrivalAirport}
onChange={(e) => setArrivalAirport(e.target.value)}
placeholder="NCE"
/>
</div>
</div>
</div>
<div className="space-y-3 rounded-md border p-3">
<div className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Departure
</div>
<div className="space-y-1.5">
<Label htmlFor="dep-at">Date &amp; time</Label>
<Input
id="dep-at"
type="datetime-local"
value={departureAt}
onChange={(e) => setDepartureAt(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<Label htmlFor="dep-flight">Flight number</Label>
<Input
id="dep-flight"
value={departureFlightNumber}
onChange={(e) => setDepartureFlightNumber(e.target.value)}
placeholder="AF7405"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="dep-airport">Airport (IATA)</Label>
<Input
id="dep-airport"
value={departureAirport}
onChange={(e) => setDepartureAirport(e.target.value)}
placeholder="NCE"
/>
</div>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="notes">Admin notes</Label>
<Textarea
id="notes"
value={adminNotes}
onChange={(e) => setAdminNotes(e.target.value)}
placeholder="e.g. paid by program, awaiting receipt"
rows={3}
/>
</div>
</div>
<SheetFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={upsertMutation.isPending}>
{upsertMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Save
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export function TravelTab({ programId }: Props) {
const utils = trpc.useUtils()
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [editing, setEditing] = useState<AttendeeRow | null>(null)
const { data, isLoading } = trpc.logistics.listFlightDetails.useQuery(
{ programId },
{ refetchInterval: 60_000 },
)
const setStatusMutation = trpc.logistics.setFlightStatus.useMutation({
onSuccess: () => {
toast.success('Status updated')
utils.logistics.listFlightDetails.invalidate({ programId })
},
onError: (err) => toast.error(err.message),
})
const filtered = useMemo(() => {
if (!data) return []
if (statusFilter === 'all') return data
if (statusFilter === 'unfilled') return data.filter((r) => !r.flightDetail)
return data.filter((r) => r.flightDetail?.status === statusFilter)
}, [data, statusFilter])
const totals = useMemo(() => {
const c = { all: 0, PENDING: 0, CONFIRMED: 0, unfilled: 0 }
for (const r of data ?? []) {
c.all++
if (!r.flightDetail) c.unfilled++
else c[r.flightDetail.status]++
}
return c
}, [data])
const StatusPill = ({
value,
label,
count,
}: {
value: StatusFilter
label: string
count: number
}) => (
<button
type="button"
onClick={() => setStatusFilter(value)}
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
statusFilter === value
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-muted'
}`}
>
{label} <span className="tabular-nums opacity-80">({count})</span>
</button>
)
return (
<div className="space-y-4">
<Card>
<CardHeader className="space-y-3">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Plane className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-base">Travel for confirmed finalists</CardTitle>
</div>
<div className="flex flex-wrap gap-1.5">
<StatusPill value="all" label="All" count={totals.all} />
<StatusPill value="unfilled" label="Unfilled" count={totals.unfilled} />
<StatusPill value="PENDING" label="Pending" count={totals.PENDING} />
<StatusPill value="CONFIRMED" label="Confirmed" count={totals.CONFIRMED} />
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : filtered.length === 0 ? (
<p className="text-muted-foreground py-12 text-center text-sm">
{data && data.length === 0
? 'No confirmed finalist attendees yet.'
: 'No attendees match this filter.'}
</p>
) : (
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Attendee</TableHead>
<TableHead>Arrival</TableHead>
<TableHead>Departure</TableHead>
<TableHead>Status</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((r) => {
const fd = r.flightDetail
return (
<TableRow key={r.id}>
<TableCell>
<div className="font-medium">
{r.user.name ?? r.user.email}
</div>
<div className="text-muted-foreground text-xs">
{r.confirmation.project.title}
{r.needsVisa ? ' · needs visa' : ''}
</div>
</TableCell>
<TableCell className="text-sm">
<div>{formatDateTime(fd?.arrivalAt ?? null)}</div>
{(fd?.arrivalFlightNumber || fd?.arrivalAirport) && (
<div className="text-muted-foreground text-xs">
{fd.arrivalFlightNumber ?? '—'}
{fd.arrivalAirport ? ` · ${fd.arrivalAirport}` : ''}
</div>
)}
</TableCell>
<TableCell className="text-sm">
<div>{formatDateTime(fd?.departureAt ?? null)}</div>
{(fd?.departureFlightNumber || fd?.departureAirport) && (
<div className="text-muted-foreground text-xs">
{fd.departureFlightNumber ?? '—'}
{fd.departureAirport ? ` · ${fd.departureAirport}` : ''}
</div>
)}
</TableCell>
<TableCell>
{fd ? (
<button
type="button"
onClick={() =>
setStatusMutation.mutate({
flightDetailId: fd.id,
status: fd.status === 'PENDING' ? 'CONFIRMED' : 'PENDING',
})
}
className="cursor-pointer"
title="Click to toggle"
>
<Badge
variant={fd.status === 'CONFIRMED' ? 'default' : 'secondary'}
className="text-xs"
>
{fd.status === 'CONFIRMED' ? 'Confirmed' : 'Pending'}
</Badge>
</button>
) : (
<Badge variant="outline" className="text-xs">
No info
</Badge>
)}
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
variant="outline"
onClick={() => setEditing(r as AttendeeRow)}
>
Edit
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<FlightEditorSheet
attendee={editing}
programId={programId}
open={!!editing}
onClose={() => setEditing(null)}
/>
</div>
)
}

View File

@@ -0,0 +1,217 @@
'use client'
import { useEffect, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import type { VisaStatus } from '@prisma/client'
const STATUS_OPTIONS: { value: VisaStatus; label: string }[] = [
{ value: 'NOT_NEEDED', label: 'Not needed' },
{ value: 'REQUESTED', label: 'Requested' },
{ value: 'INVITATION_SENT', label: 'Invitation sent' },
{ value: 'APPOINTMENT_BOOKED', label: 'Appointment booked' },
{ value: 'GRANTED', label: 'Granted' },
{ value: 'DENIED', label: 'Denied' },
]
function toDateInputValue(d: Date | null | undefined): string {
if (!d) return ''
const dt = new Date(d)
if (Number.isNaN(dt.getTime())) return ''
// YYYY-MM-DD for <input type="date">
return dt.toISOString().slice(0, 10)
}
function fromDateInputValue(s: string): Date | null {
if (!s) return null
const dt = new Date(s)
return Number.isNaN(dt.getTime()) ? null : dt
}
export type VisaEditTarget = {
id: string
status: VisaStatus
nationality: string | null
invitationSentAt: Date | null
appointmentAt: Date | null
decisionAt: Date | null
notes: string | null
attendeeName: string
projectTitle: string
}
export function VisaEditDialog({
open,
target,
programId,
onOpenChange,
}: {
open: boolean
target: VisaEditTarget | null
programId: string
onOpenChange: (next: boolean) => void
}) {
const utils = trpc.useUtils()
const [status, setStatus] = useState<VisaStatus>('REQUESTED')
const [nationality, setNationality] = useState('')
const [invitationSent, setInvitationSent] = useState('')
const [appointment, setAppointment] = useState('')
const [decision, setDecision] = useState('')
const [notes, setNotes] = useState('')
useEffect(() => {
if (target && open) {
setStatus(target.status)
setNationality(target.nationality ?? '')
setInvitationSent(toDateInputValue(target.invitationSentAt))
setAppointment(toDateInputValue(target.appointmentAt))
setDecision(toDateInputValue(target.decisionAt))
setNotes(target.notes ?? '')
}
}, [target, open])
const mutation = trpc.logistics.updateVisaApplication.useMutation({
onSuccess: () => {
toast.success('Visa application updated')
utils.logistics.listVisaApplications.invalidate({ programId })
onOpenChange(false)
},
onError: (e) => toast.error(e.message),
})
const handleSave = () => {
if (!target) return
mutation.mutate({
id: target.id,
status,
nationality: nationality.trim() || null,
invitationSentAt: fromDateInputValue(invitationSent),
appointmentAt: fromDateInputValue(appointment),
decisionAt: fromDateInputValue(decision),
notes: notes.trim() || null,
})
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!mutation.isPending) onOpenChange(next)
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Update visa application</DialogTitle>
<DialogDescription>
{target
? `${target.attendeeName} · ${target.projectTitle}`
: 'Loading…'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="visa-status">Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as VisaStatus)}>
<SelectTrigger id="visa-status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="visa-nationality">Nationality</Label>
<Input
id="visa-nationality"
value={nationality}
onChange={(e) => setNationality(e.target.value)}
placeholder="Self-declared, optional"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="visa-invitation">Invitation sent</Label>
<Input
id="visa-invitation"
type="date"
value={invitationSent}
onChange={(e) => setInvitationSent(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="visa-appointment">Appointment</Label>
<Input
id="visa-appointment"
type="date"
value={appointment}
onChange={(e) => setAppointment(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="visa-decision">Decision</Label>
<Input
id="visa-decision"
type="date"
value={decision}
onChange={(e) => setDecision(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="visa-notes">Notes</Label>
<Textarea
id="visa-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
placeholder="Free-text notes — embassy, contact, follow-ups, etc. No documents."
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={!target || mutation.isPending}>
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,249 @@
'use client'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Settings as SettingsIcon, ShieldOff } from 'lucide-react'
import { VisaEditDialog, type VisaEditTarget } from './visa-edit-dialog'
import type { VisaStatus } from '@prisma/client'
interface Props {
programId: string
}
const STATUS_BADGE: Record<
VisaStatus,
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
> = {
NOT_NEEDED: { label: 'Not needed', variant: 'outline' },
REQUESTED: { label: 'Requested', variant: 'secondary' },
INVITATION_SENT: { label: 'Invitation sent', variant: 'secondary' },
APPOINTMENT_BOOKED: { label: 'Appointment booked', variant: 'default' },
GRANTED: { label: 'Granted', variant: 'default' },
DENIED: { label: 'Denied', variant: 'destructive' },
}
type StatusFilter = 'all' | VisaStatus
function formatDateOnly(d: Date | null | undefined): string {
if (!d) return '—'
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(d))
}
function nextDate(row: {
invitationSentAt: Date | null
appointmentAt: Date | null
decisionAt: Date | null
status: VisaStatus
}): { label: string; date: Date | null } {
if (row.status === 'GRANTED' || row.status === 'DENIED') {
return { label: 'Decision', date: row.decisionAt }
}
if (row.appointmentAt) return { label: 'Appointment', date: row.appointmentAt }
if (row.invitationSentAt) return { label: 'Invitation sent', date: row.invitationSentAt }
return { label: '—', date: null }
}
export function VisasTab({ programId }: Props) {
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [editTarget, setEditTarget] = useState<VisaEditTarget | null>(null)
const [editOpen, setEditOpen] = useState(false)
const { data, isLoading } = trpc.logistics.listVisaApplications.useQuery({ programId })
const filtered = useMemo(() => {
if (!data) return []
return statusFilter === 'all'
? data
: data.filter((r) => r.status === statusFilter)
}, [data, statusFilter])
const totals = useMemo(() => {
const counts: Record<VisaStatus, number> = {
NOT_NEEDED: 0,
REQUESTED: 0,
INVITATION_SENT: 0,
APPOINTMENT_BOOKED: 0,
GRANTED: 0,
DENIED: 0,
}
for (const r of data ?? []) counts[r.status]++
return counts
}, [data])
const StatusPill = ({
value,
label,
count,
}: {
value: StatusFilter
label: string
count: number
}) => (
<button
type="button"
onClick={() => setStatusFilter(value)}
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
statusFilter === value
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-muted'
}`}
>
{label} <span className="tabular-nums opacity-80">({count})</span>
</button>
)
return (
<div className="space-y-4">
<Card>
<CardHeader className="space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-base">Visa applications</CardTitle>
<p className="text-muted-foreground mt-1 max-w-2xl text-xs">
Process metadata only invitation letters, passport copies, and visa decisions
continue to flow over email and are never stored on this platform.
</p>
</div>
<Button variant="outline" size="sm" asChild>
<Link href="/admin/settings?tab=edition">
<SettingsIcon className="mr-2 h-3.5 w-3.5" />
Edition settings
</Link>
</Button>
</div>
<div className="flex flex-wrap gap-1.5">
<StatusPill value="all" label="All" count={(data ?? []).length} />
<StatusPill value="REQUESTED" label="Requested" count={totals.REQUESTED} />
<StatusPill
value="INVITATION_SENT"
label="Invitation sent"
count={totals.INVITATION_SENT}
/>
<StatusPill
value="APPOINTMENT_BOOKED"
label="Appointment booked"
count={totals.APPOINTMENT_BOOKED}
/>
<StatusPill value="GRANTED" label="Granted" count={totals.GRANTED} />
<StatusPill value="DENIED" label="Denied" count={totals.DENIED} />
<StatusPill value="NOT_NEEDED" label="Not needed" count={totals.NOT_NEEDED} />
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : filtered.length === 0 ? (
<div className="text-muted-foreground py-12 text-center text-sm">
<ShieldOff className="mx-auto mb-2 h-6 w-6 opacity-60" />
{statusFilter === 'all'
? 'No visa applications yet. They are auto-created when a team confirms with needsVisa=true.'
: 'No applications match this filter.'}
</div>
) : (
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Member</TableHead>
<TableHead>Nationality</TableHead>
<TableHead>Status</TableHead>
<TableHead>Next date</TableHead>
<TableHead>Notes</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((r) => {
const badge = STATUS_BADGE[r.status]
const next = nextDate(r)
return (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.project.title}</TableCell>
<TableCell>
<div className="text-sm">
{r.attendee.user.name ?? r.attendee.user.email}
</div>
<div className="text-muted-foreground text-xs">
{r.attendee.user.email}
</div>
</TableCell>
<TableCell className="text-sm">
{r.nationality ?? <span className="text-muted-foreground"></span>}
</TableCell>
<TableCell>
<Badge variant={badge.variant} className="text-xs">
{badge.label}
</Badge>
</TableCell>
<TableCell className="text-sm">
{next.date ? (
<>
<div>{formatDateOnly(next.date)}</div>
<div className="text-muted-foreground text-xs">{next.label}</div>
</>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-muted-foreground max-w-[18rem] truncate text-xs">
{r.notes ?? '—'}
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
variant="outline"
onClick={() => {
setEditTarget({
id: r.id,
status: r.status,
nationality: r.nationality,
invitationSentAt: r.invitationSentAt,
appointmentAt: r.appointmentAt,
decisionAt: r.decisionAt,
notes: r.notes,
attendeeName: r.attendee.user.name ?? r.attendee.user.email,
projectTitle: r.project.title,
})
setEditOpen(true)
}}
>
Edit
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<VisaEditDialog
open={editOpen}
target={editTarget}
programId={programId}
onOpenChange={setEditOpen}
/>
</div>
)
}

View File

@@ -191,6 +191,20 @@ export function MembersContent() {
}, },
}) })
const bulkUpdateRoles = trpc.user.bulkUpdateRoles.useMutation({
onSuccess: (r) => {
const parts: string[] = []
if (r.updated > 0) parts.push(`Updated ${r.updated} user${r.updated === 1 ? '' : 's'}`)
if (r.alreadyHadRole > 0) parts.push(`${r.alreadyHadRole} already had role`)
toast.success(parts.join(' · ') || 'No changes')
setSelectedIds(new Set())
utils.user.list.invalidate()
},
onError: (error) => {
toast.error(error.message || 'Failed to update roles')
},
})
const selectableUsers = useMemo( const selectableUsers = useMemo(
() => data?.users ?? [], () => data?.users ?? [],
[data?.users] [data?.users]
@@ -321,9 +335,29 @@ export function MembersContent() {
<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">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Selection persists across pages and filters. {selectedIds.size > 0
? `${selectedIds.size} selected. Selection persists across pages and filters.`
: 'Selection persists across pages and filters.'}
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{selectedIds.size > 0 && (
<Button
variant="outline"
size="sm"
onClick={() =>
bulkUpdateRoles.mutate({
userIds: Array.from(selectedIds),
addRole: 'MENTOR',
})
}
disabled={bulkUpdateRoles.isPending}
>
{bulkUpdateRoles.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : null}
Add MENTOR role
</Button>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View File

@@ -0,0 +1,257 @@
'use client'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Separator } from '@/components/ui/separator'
import {
Mail,
MapPin,
GraduationCap,
CheckCircle2,
XCircle,
MessageSquare,
FileText,
Target,
Calendar,
} from 'lucide-react'
import { formatEnumLabel } from '@/lib/utils'
function formatDateOnly(d: Date | string | null | undefined): string {
if (!d) return '—'
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(d))
}
function formatRelativePast(d: Date | string | null): string {
if (!d) return '—'
const dt = typeof d === 'string' ? new Date(d) : d
const ms = Date.now() - dt.getTime()
const days = Math.floor(ms / 86_400_000)
const hours = Math.floor(ms / 3_600_000)
if (days >= 1) return `${days}d ago`
if (hours >= 1) return `${hours}h ago`
const minutes = Math.floor(ms / 60_000)
return `${Math.max(0, minutes)}m ago`
}
export function MentorDetailSheet({
mentorId,
open,
onOpenChange,
}: {
mentorId: string | null
open: boolean
onOpenChange: (next: boolean) => void
}) {
const { data, isLoading } = trpc.mentor.getMentorDetail.useQuery(
{ mentorId: mentorId ?? '' },
{ enabled: open && !!mentorId },
)
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle>
{isLoading || !data ? (
<Skeleton className="h-6 w-48" />
) : (
data.mentor.name ?? 'Unnamed mentor'
)}
</SheetTitle>
<SheetDescription>
{isLoading || !data ? (
<Skeleton className="h-4 w-64" />
) : (
<span className="flex flex-wrap items-center gap-2 text-sm">
<span className="inline-flex items-center gap-1">
<Mail className="h-3 w-3" /> {data.mentor.email}
</span>
{data.mentor.country && (
<span className="inline-flex items-center gap-1">
<MapPin className="h-3 w-3" /> {data.mentor.country}
</span>
)}
</span>
)}
</SheetDescription>
</SheetHeader>
{isLoading || !data ? (
<div className="mt-6 space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
) : (
<div className="mt-6 space-y-6">
{/* Profile summary */}
<section className="space-y-3">
<h3 className="text-muted-foreground text-xs font-semibold uppercase tracking-wide">
Profile
</h3>
<div className="space-y-2 rounded-md border p-4">
<div className="flex items-start gap-2 text-sm">
<GraduationCap className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="text-muted-foreground text-xs">Expertise</div>
{data.mentor.expertiseTags.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-1">
{data.mentor.expertiseTags.map((t) => (
<Badge key={t} variant="secondary" className="text-xs">
{t}
</Badge>
))}
</div>
) : (
<div className="text-muted-foreground text-sm italic">
None declared
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="text-muted-foreground h-4 w-4 shrink-0" />
<span className="text-muted-foreground text-xs">Joined</span>
<span>{formatDateOnly(data.mentor.createdAt)}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground text-xs">Max assignments</span>
<span className="tabular-nums">
{data.mentor.maxAssignments ?? '∞'}
</span>
</div>
</div>
</section>
{/* Assignments */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-muted-foreground text-xs font-semibold uppercase tracking-wide">
Teams ({data.assignments.length})
</h3>
</div>
{data.assignments.length === 0 ? (
<div className="text-muted-foreground rounded-md border py-8 text-center text-sm">
This mentor has no assignments yet.
</div>
) : (
<div className="space-y-3">
{data.assignments.map((a) => {
const isCompleted = a.completionStatus === 'completed'
const isDropped = !!a.droppedAt
return (
<div
key={a.id}
className={`rounded-md border p-4 ${isDropped ? 'opacity-70' : ''}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<Link
href={`/admin/projects/${a.project.id}`}
className="text-sm font-medium hover:underline"
>
{a.project.title}
</Link>
<div className="text-muted-foreground mt-0.5 flex flex-wrap items-center gap-2 text-xs">
{a.project.competitionCategory && (
<span>
{formatEnumLabel(a.project.competitionCategory)}
</span>
)}
{a.project.country && (
<>
<span>·</span>
<span>{a.project.country}</span>
</>
)}
<span>·</span>
<span>Assigned {formatDateOnly(a.assignedAt)}</span>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
{isCompleted ? (
<Badge variant="default" className="gap-1">
<CheckCircle2 className="h-3 w-3" /> Completed
</Badge>
) : isDropped ? (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" /> Dropped
</Badge>
) : (
<Badge variant="secondary">Active</Badge>
)}
</div>
</div>
{isDropped && a.droppedReason && (
<div className="text-muted-foreground bg-muted/50 mt-3 rounded-md p-2 text-xs">
<strong>Drop reason</strong>
{a.droppedBy ? ` (by ${a.droppedBy})` : ''}: {a.droppedReason}
</div>
)}
<Separator className="my-3" />
{/* Activity counts */}
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="flex items-start gap-2 rounded-md border p-2">
<MessageSquare className="text-muted-foreground mt-0.5 h-3.5 w-3.5 shrink-0" />
<div>
<div className="font-semibold tabular-nums">
{a.messageCount}
</div>
<div className="text-muted-foreground">
{a.lastMessageAt
? formatRelativePast(a.lastMessageAt)
: 'no messages'}
</div>
</div>
</div>
<div className="flex items-start gap-2 rounded-md border p-2">
<FileText className="text-muted-foreground mt-0.5 h-3.5 w-3.5 shrink-0" />
<div>
<div className="font-semibold tabular-nums">
{a.fileCount}
</div>
<div className="text-muted-foreground">
{a.lastFileAt
? formatRelativePast(a.lastFileAt)
: 'no files'}
</div>
</div>
</div>
<div className="flex items-start gap-2 rounded-md border p-2">
<Target className="text-muted-foreground mt-0.5 h-3.5 w-3.5 shrink-0" />
<div>
<div className="font-semibold tabular-nums">
{a.milestoneCompletionCount}
</div>
<div className="text-muted-foreground">
{a.lastMilestoneAt
? formatRelativePast(a.lastMilestoneAt)
: 'no milestones'}
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
)}
</section>
</div>
)}
</SheetContent>
</Sheet>
)
}

View File

@@ -0,0 +1,177 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Mail, Send, Eye } from 'lucide-react'
interface Props {
open: boolean
onClose: () => void
projectId: string
projectTitle: string
}
function useDebounced<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delayMs)
return () => clearTimeout(t)
}, [value, delayMs])
return debounced
}
export function ProjectEmailDialog({ open, onClose, projectId, projectTitle }: Props) {
const initialBody = useMemo(() => `Hello ${projectTitle} team,\n\n`, [projectTitle])
const [subject, setSubject] = useState('')
const [body, setBody] = useState(initialBody)
const [showPreview, setShowPreview] = useState(false)
// Reset state whenever the dialog opens for a new project
useEffect(() => {
if (open) {
setSubject('')
setBody(initialBody)
setShowPreview(false)
}
}, [open, initialBody])
const debouncedSubject = useDebounced(subject, 300)
const debouncedBody = useDebounced(body, 300)
const recipientPreview = trpc.message.previewRecipients.useQuery(
{ recipientType: 'PROJECT_TEAM', recipientFilter: { projectId } },
{ enabled: open }
)
const emailPreview = trpc.message.previewEmail.useQuery(
{ subject: debouncedSubject, body: debouncedBody },
{ enabled: showPreview && debouncedSubject.length > 0 && debouncedBody.length > 0 }
)
const sendTestMutation = trpc.message.sendTest.useMutation({
onSuccess: ({ to }) => toast.success(`Test email sent to ${to}`),
onError: (e) => toast.error(e.message),
})
const sendMutation = trpc.message.send.useMutation({
onSuccess: () => {
toast.success(`Email sent to ${recipientPreview.data?.totalApplicants ?? 0} team members`)
onClose()
},
onError: (e) => toast.error(e.message),
})
const recipientCount = recipientPreview.data?.totalApplicants ?? 0
const canSend = subject.length > 0 && body.length > 0 && recipientCount > 0
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Email Team {projectTitle}
</DialogTitle>
<DialogDescription>
Compose a custom email to all members of this project&apos;s team.
{recipientPreview.isLoading
? ' Loading recipients…'
: ` Will be sent to ${recipientCount} team member${recipientCount === 1 ? '' : 's'}.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email-subject">Subject</Label>
<Input
id="email-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Subject of your email"
maxLength={500}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email-body">Body</Label>
<Textarea
id="email-body"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={10}
className="font-mono text-sm"
placeholder={initialBody}
/>
<p className="text-xs text-muted-foreground">
The greeting is pre-filled edit freely. The full email is wrapped in the standard
MOPC styled template when sent. Click &quot;Show preview&quot; to see exactly what recipients will see.
</p>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowPreview((s) => !s)}
disabled={subject.length === 0 || body.length === 0}
>
<Eye className="mr-2 h-4 w-4" />
{showPreview ? 'Hide preview' : 'Show preview'}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => sendTestMutation.mutate({ subject, body })}
disabled={!canSend || sendTestMutation.isPending}
>
<Send className="mr-2 h-4 w-4" />
{sendTestMutation.isPending ? 'Sending…' : 'Send test to me'}
</Button>
</div>
{showPreview && emailPreview.data && (
<div className="border rounded-md overflow-hidden">
<div className="bg-muted text-xs px-3 py-2 border-b">Email preview</div>
<iframe
title="Email preview"
srcDoc={emailPreview.data.html}
className="w-full h-96 bg-white"
sandbox=""
/>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button
onClick={() =>
sendMutation.mutate({
recipientType: 'PROJECT_TEAM',
recipientFilter: { projectId },
subject,
body,
deliveryChannels: ['EMAIL'],
linkType: 'NONE',
})
}
disabled={!canSend || sendMutation.isPending}
>
<Send className="mr-2 h-4 w-4" />
{sendMutation.isPending ? 'Sending…' : `Send to ${recipientCount}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -259,7 +259,7 @@ export function AwardShortlist({
} }
</p> </p>
{eligibilityMode === 'SEPARATE_POOL' && !hasAwardRounds && ( {eligibilityMode === 'SEPARATE_POOL' && !hasAwardRounds && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300"> <div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" /> <AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm"> <p className="text-sm">
No award rounds have been created yet. Projects will be confirmed but <strong>not routed</strong> to an evaluation track. Create rounds on the award page first. No award rounds have been created yet. Projects will be confirmed but <strong>not routed</strong> to an evaluation track. Create rounds on the award page first.

View File

@@ -328,13 +328,13 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
<div className="space-y-6"> <div className="space-y-6">
{/* Grace Period Banner */} {/* Grace Period Banner */}
{summary.isGracePeriodActive && ( {summary.isGracePeriodActive && (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20"> <Card className="border-amber-200 bg-amber-50">
<CardContent className="flex items-center justify-between py-4"> <CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-amber-600" /> <Clock className="h-5 w-5 text-amber-600" />
<div> <div>
<p className="font-medium text-amber-800 dark:text-amber-200">Grace Period Active</p> <p className="font-medium text-amber-800">Grace Period Active</p>
<p className="text-sm text-amber-600 dark:text-amber-400"> <p className="text-sm text-amber-600">
Applicants can still submit until{' '} Applicants can still submit until{' '}
{summary.gracePeriodEndsAt {summary.gracePeriodEndsAt
? new Date(summary.gracePeriodEndsAt).toLocaleString() ? new Date(summary.gracePeriodEndsAt).toLocaleString()
@@ -358,12 +358,12 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
{/* Finalized Banner */} {/* Finalized Banner */}
{summary.isFinalized && ( {summary.isFinalized && (
<Card className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20"> <Card className="border-green-200 bg-green-50">
<CardContent className="flex items-center gap-3 py-4"> <CardContent className="flex items-center gap-3 py-4">
<CheckCircle2 className="h-5 w-5 text-green-600" /> <CheckCircle2 className="h-5 w-5 text-green-600" />
<div> <div>
<p className="font-medium text-green-800 dark:text-green-200">Round Finalized</p> <p className="font-medium text-green-800">Round Finalized</p>
<p className="text-sm text-green-600 dark:text-green-400"> <p className="text-sm text-green-600">
Finalized on{' '} Finalized on{' '}
{summary.finalizedAt {summary.finalizedAt
? new Date(summary.finalizedAt).toLocaleString() ? new Date(summary.finalizedAt).toLocaleString()
@@ -376,13 +376,13 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
{/* Needs Processing Banner */} {/* Needs Processing Banner */}
{!summary.isFinalized && !summary.isGracePeriodActive && summary.projects.length > 0 && summary.projects.every((p) => !p.proposedOutcome) && ( {!summary.isFinalized && !summary.isGracePeriodActive && summary.projects.length > 0 && summary.projects.every((p) => !p.proposedOutcome) && (
<Card className="border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20"> <Card className="border-blue-200 bg-blue-50">
<CardContent className="flex items-center justify-between py-4"> <CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-blue-600" /> <AlertTriangle className="h-5 w-5 text-blue-600" />
<div> <div>
<p className="font-medium text-blue-800 dark:text-blue-200">Projects Need Processing</p> <p className="font-medium text-blue-800">Projects Need Processing</p>
<p className="text-sm text-blue-600 dark:text-blue-400"> <p className="text-sm text-blue-600">
{summary.projects.length} project{summary.projects.length !== 1 ? 's' : ''} in this round have no proposed outcome. {summary.projects.length} project{summary.projects.length !== 1 ? 's' : ''} in this round have no proposed outcome.
Click &quot;Process&quot; to auto-assign outcomes based on round type and project activity. Click &quot;Process&quot; to auto-assign outcomes based on round type and project activity.
</p> </p>
@@ -666,7 +666,9 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
)} )}
<div> <div>
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-medium">Advancement Message</label> <label className="text-sm font-medium">
{summary.winnerContext ? 'Winner Message' : 'Advancement Message'}
</label>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -681,7 +683,11 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
</Button> </Button>
</div> </div>
<Textarea <Textarea
placeholder="Custom message for projects that are advancing (added to the standard email template)..." placeholder={
summary.winnerContext
? 'Custom message for winners (added to the standard winner email template)...'
: 'Custom message for projects that are advancing (added to the standard email template)...'
}
value={advancementMessage} value={advancementMessage}
onChange={(e) => setAdvancementMessage(e.target.value)} onChange={(e) => setAdvancementMessage(e.target.value)}
rows={3} rows={3}
@@ -715,7 +721,13 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
<CardContent className="pt-0"> <CardContent className="pt-0">
<div className="flex items-center justify-between border-t pt-4"> <div className="flex items-center justify-between border-t pt-4">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{summary.nextRound ? ( {summary.winnerContext ? (
<span>
<strong>{passedCount}</strong>{' '}
{passedCount !== 1 ? 'winners' : 'winner'} will be notified for{' '}
<strong>{summary.winnerContext.label}</strong>
</span>
) : summary.nextRound ? (
<span> <span>
<strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} will advance to{' '} <strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} will advance to{' '}
<strong>{summary.nextRound.name}</strong> <strong>{summary.nextRound.name}</strong>
@@ -751,9 +763,11 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
<ul className="list-disc pl-5 space-y-1"> <ul className="list-disc pl-5 space-y-1">
<li>Mark <strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} as <strong>PASSED</strong></li> <li>Mark <strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} as <strong>PASSED</strong></li>
<li>Mark <strong>{rejectedCount}</strong> project{rejectedCount !== 1 ? 's' : ''} as <strong>REJECTED</strong></li> <li>Mark <strong>{rejectedCount}</strong> project{rejectedCount !== 1 ? 's' : ''} as <strong>REJECTED</strong></li>
{summary.nextRound && ( {summary.winnerContext ? (
<li>Notify <strong>{passedCount}</strong> {passedCount !== 1 ? 'winners' : 'winner'} for <strong>{summary.winnerContext.label}</strong> (no further round)</li>
) : summary.nextRound ? (
<li>Advance passed projects to <strong>{summary.nextRound.name}</strong></li> <li>Advance passed projects to <strong>{summary.nextRound.name}</strong></li>
)} ) : null}
<li>Send email notifications to all affected teams</li> <li>Send email notifications to all affected teams</li>
</ul> </ul>
{undecidedCount > 0 && ( {undecidedCount > 0 && (

View File

@@ -0,0 +1,738 @@
'use client'
import { useMemo, useState } from 'react'
import Link from 'next/link'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Search,
UserPlus,
ArrowRight,
Sparkles,
Loader2,
Download,
X,
Plus,
} from 'lucide-react'
import { CountryDisplay } from '@/components/shared/country-display'
import { AddProjectDialog } from '@/components/admin/round/project-states-table'
type Filter = 'all' | 'unassigned' | 'assigned' | 'wants_only'
type CompetitionRound = {
id: string
name: string
sortOrder: number
_count: { projectRoundStates: number }
}
export function MentoringProjectsTable({
roundId,
competitionId,
competitionRounds,
currentSortOrder,
}: {
roundId: string
competitionId: string
competitionRounds?: CompetitionRound[]
currentSortOrder?: number
}) {
const [addProjectOpen, setAddProjectOpen] = useState(false)
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<Filter>('all')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [bulkOpen, setBulkOpen] = useState(false)
const [chosenMentorIds, setChosenMentorIds] = useState<Set<string>>(new Set())
const [mentorSearch, setMentorSearch] = useState('')
const utils = trpc.useUtils()
const { data, isLoading } = trpc.round.listMentoringProjects.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const { data: importCandidates } =
trpc.round.getMentoringImportCandidates.useQuery({ roundId })
const { data: mentorPool } = trpc.mentor.getMentorPool.useQuery(
{},
{ enabled: bulkOpen },
)
const bulkAssignMutation = trpc.mentor.bulkAssign.useMutation({
onSuccess: (result) => {
if (result.totalAssigned === 0 && result.totalSkipped > 0) {
toast.info(
`No new assignments — every selected mentor is already on every selected project (${result.totalSkipped} pair${result.totalSkipped === 1 ? '' : 's'} skipped).`,
)
} else if (result.totalAssigned === 0 && result.ineligibleProjectCount > 0) {
toast.warning(
`${result.ineligibleProjectCount} project${result.ineligibleProjectCount === 1 ? '' : 's'} aren't in a mentoring round and were skipped.`,
)
} else {
const mentorCount = result.perMentor.filter((m) => m.assigned > 0).length
toast.success(
`Created ${result.totalAssigned} assignment${
result.totalAssigned === 1 ? '' : 's'
} across ${result.touchedProjectCount} project${
result.touchedProjectCount === 1 ? '' : 's'
}${result.totalSkipped > 0 ? ` (${result.totalSkipped} pair${result.totalSkipped === 1 ? '' : 's'} already existed)` : ''}${
result.emailsSent > 0
? ` · ${result.emailsSent} mentor email${result.emailsSent === 1 ? '' : 's'} sent`
: ''
}`,
{
description:
mentorCount > 1
? `Each of ${mentorCount} mentors gets a single combined email listing only their new projects.`
: undefined,
},
)
}
utils.round.listMentoringProjects.invalidate({ roundId })
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
utils.round.getMentoringImportCandidates.invalidate({ roundId })
utils.mentor.getMentorPool.invalidate()
utils.mentor.getRoundStats.invalidate({ roundId })
utils.project.list.invalidate()
setSelected(new Set())
setChosenMentorIds(new Set())
setMentorSearch('')
setBulkOpen(false)
},
onError: (err) => toast.error(err.message),
})
const advanceMutation = trpc.round.advanceProjects.useMutation({
onSuccess: (result) => {
toast.success(
`Imported ${result.advancedCount} project${
result.advancedCount === 1 ? '' : 's'
} from ${result.targetRoundName ? '' : ''}${
importCandidates?.priorRound?.name ?? 'the prior round'
}`,
)
utils.round.listMentoringProjects.invalidate({ roundId })
utils.round.getMentoringImportCandidates.invalidate({ roundId })
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
},
onError: (err) => toast.error(err.message),
})
const importBanner = importCandidates?.priorRound &&
importCandidates.pendingCount > 0 && (
<div className="flex flex-col gap-2 rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between">
<div className="text-amber-900">
<span className="font-medium">
{importCandidates.pendingCount} PASSED project
{importCandidates.pendingCount === 1 ? '' : 's'}
</span>{' '}
from{' '}
<span className="font-medium">
{importCandidates.priorRound.name}
</span>{' '}
{importCandidates.pendingCount === 1 ? "isn't" : "aren't"} in this
mentoring round yet.
</div>
<Button
size="sm"
onClick={() =>
advanceMutation.mutate({
roundId: importCandidates.priorRound!.id,
targetRoundId: roundId,
})
}
disabled={advanceMutation.isPending}
>
{advanceMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Download className="mr-1.5 h-4 w-4" />
)}
Import {importCandidates.pendingCount}
</Button>
</div>
)
const filtered = useMemo(() => {
if (!data) return []
const q = search.trim().toLowerCase()
return data.projects.filter((p) => {
if (filter === 'unassigned' && p.mentors.length > 0) return false
if (filter === 'assigned' && p.mentors.length === 0) return false
if (filter === 'wants_only' && !p.wantsMentorship) return false
if (!q) return true
const hay = [
p.title,
p.teamName ?? '',
p.country ?? '',
...p.mentors.map((m) => m.name ?? m.email),
]
.join(' ')
.toLowerCase()
return hay.includes(q)
})
}, [data, search, filter])
const totals = useMemo(() => {
if (!data)
return { total: 0, unassigned: 0, assigned: 0, wants: 0 }
return {
total: data.projects.length,
unassigned: data.projects.filter((p) => p.mentors.length === 0).length,
assigned: data.projects.filter((p) => p.mentors.length > 0).length,
wants: data.projects.filter((p) => p.wantsMentorship).length,
}
}, [data])
if (isLoading) {
return (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)
}
if (!data || data.projects.length === 0) {
return (
<div className="space-y-3">
{importBanner}
<div className="flex items-center justify-end">
<Button size="sm" onClick={() => setAddProjectOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
Add Project to Round
</Button>
</div>
<div className="rounded-md border bg-muted/30 px-4 py-12 text-center text-sm text-muted-foreground">
No projects in this mentoring round yet. Click{' '}
<span className="font-medium text-foreground">Add Project to Round</span>{' '}
above to populate it.
</div>
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
competitionRounds={competitionRounds}
currentSortOrder={currentSortOrder}
onAssigned={() => {
utils.round.listMentoringProjects.invalidate({ roundId })
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
utils.round.getMentoringImportCandidates.invalidate({ roundId })
utils.mentor.getRoundStats.invalidate({ roundId })
}}
/>
</div>
)
}
const Pill = ({
value,
label,
count,
}: {
value: Filter
label: string
count: number
}) => (
<button
type="button"
onClick={() => setFilter(value)}
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
filter === value
? 'border-primary bg-primary text-primary-foreground'
: 'bg-background hover:bg-muted'
}`}
>
{label}{' '}
<span className="tabular-nums opacity-80">({count})</span>
</button>
)
return (
<div className="space-y-3">
{importBanner}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-1.5">
<Pill value="all" label="All" count={totals.total} />
<Pill value="unassigned" label="No mentor" count={totals.unassigned} />
<Pill value="assigned" label="Has mentor" count={totals.assigned} />
<Pill value="wants_only" label="Wants mentorship" count={totals.wants} />
</div>
<div className="flex items-center gap-2">
<div className="relative w-full sm:w-72">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search projects, teams, or mentors…"
className="pl-8"
/>
</div>
<Button
size="sm"
onClick={() => setAddProjectOpen(true)}
className="shrink-0"
>
<Plus className="mr-1 h-4 w-4" />
Add
</Button>
</div>
</div>
{selected.size > 0 ? (
<div className="flex flex-col gap-2 rounded-md border border-primary/30 bg-primary/5 px-4 py-2.5 text-sm sm:flex-row sm:items-center sm:justify-between">
<div>
<span className="font-medium">{selected.size}</span>{' '}
<span className="text-muted-foreground">
project{selected.size === 1 ? '' : 's'} selected
</span>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => setBulkOpen(true)}>
<UserPlus className="mr-1.5 h-4 w-4" />
Assign mentor
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setSelected(new Set())}
>
<X className="mr-1 h-4 w-4" />
Clear
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-between rounded-md border border-dashed bg-muted/20 px-4 py-2 text-xs text-muted-foreground">
<span>
Tip: tick checkboxes to bulk-assign one mentor to multiple
projects in a single click (mentor gets one combined email).
</span>
{totals.unassigned > 0 && (
<button
type="button"
className="text-xs font-medium text-foreground hover:underline"
onClick={() => {
setFilter('unassigned')
setSelected(
new Set(
data.projects
.filter((p) => p.mentors.length === 0)
.map((p) => p.id),
),
)
}}
>
Select all {totals.unassigned} without a mentor
</button>
)}
</div>
)}
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={
filtered.length > 0 &&
filtered.every((p) => selected.has(p.id))
}
onCheckedChange={(checked) => {
setSelected((prev) => {
const next = new Set(prev)
if (checked) {
filtered.forEach((p) => next.add(p.id))
} else {
filtered.forEach((p) => next.delete(p.id))
}
return next
})
}}
aria-label="Select all visible"
/>
</TableHead>
<TableHead>Project</TableHead>
<TableHead>Wants?</TableHead>
<TableHead>Mentors</TableHead>
<TableHead className="w-32 text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="py-8 text-center text-sm text-muted-foreground"
>
No projects match the current filter.
</TableCell>
</TableRow>
) : (
filtered.map((p) => (
<TableRow
key={p.id}
data-state={selected.has(p.id) ? 'selected' : undefined}
>
<TableCell>
<Checkbox
checked={selected.has(p.id)}
onCheckedChange={(checked) =>
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(p.id)
else next.delete(p.id)
return next
})
}
aria-label={`Select ${p.title}`}
/>
</TableCell>
<TableCell>
<div className="font-medium">{p.title}</div>
<div className="text-xs text-muted-foreground">
{p.teamName ?? '—'}
{p.country && (
<>
{' · '}
<CountryDisplay country={p.country} />
</>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
{p.wantsMentorship ? (
<Badge variant="secondary" className="w-fit text-xs">
Requested
</Badge>
) : (
<span className="text-xs text-muted-foreground">No</span>
)}
{p.finalistConfirmationStatus !== 'CONFIRMED' && (
<span
className="text-[10px] uppercase tracking-wide text-amber-700"
title="Auto-fill skips projects whose team has not confirmed attendance."
>
{p.finalistConfirmationStatus
? p.finalistConfirmationStatus.toLowerCase()
: 'no confirmation'}
</span>
)}
</div>
</TableCell>
<TableCell>
{p.mentors.length === 0 ? (
<span className="text-xs italic text-muted-foreground">
Unassigned
</span>
) : (
<div className="flex flex-wrap gap-1">
{p.mentors.map((m) => (
<Badge
key={m.assignmentId}
variant="outline"
className="gap-1 text-xs"
title={m.email}
>
{(m.method === 'AI_AUTO' ||
m.method === 'AI_SUGGESTED') && (
<Sparkles className="h-3 w-3 text-amber-500" />
)}
{m.name ?? m.email}
</Badge>
))}
</div>
)}
</TableCell>
<TableCell className="text-right">
<Button asChild size="sm" variant="outline">
<Link href={`/admin/projects/${p.id}/mentor`}>
{p.mentors.length === 0 ? (
<>
<UserPlus className="mr-1 h-3.5 w-3.5" />
Assign
</>
) : (
<>
Open
<ArrowRight className="ml-1 h-3.5 w-3.5" />
</>
)}
</Link>
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<Dialog
open={bulkOpen}
onOpenChange={(next) => {
if (!next) {
setBulkOpen(false)
setChosenMentorIds(new Set())
setMentorSearch('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Assign mentors to {selected.size} project
{selected.size === 1 ? '' : 's'}
</DialogTitle>
<DialogDescription>
Tick any number of mentors. Each chosen mentor will be added to
every selected project they aren&apos;t already on. Each mentor
receives one combined email; each team receives one intro email
listing all of their mentors.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{(() => {
const allMentors = mentorPool?.mentors ?? []
const chosenMentors = allMentors.filter((m) =>
chosenMentorIds.has(m.id),
)
const upperBound = chosenMentorIds.size * selected.size
return (
<>
{chosenMentors.length > 0 && (
<div className="flex flex-wrap gap-1 rounded-md border bg-muted/30 p-2">
{chosenMentors.map((m) => (
<Badge
key={m.id}
variant="secondary"
className="gap-1 pl-2 pr-1"
>
{m.name ?? m.email}
<button
type="button"
aria-label={`Remove ${m.name ?? m.email}`}
className="rounded-full p-0.5 hover:bg-foreground/10"
onClick={() =>
setChosenMentorIds((prev) => {
const next = new Set(prev)
next.delete(m.id)
return next
})
}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={mentorSearch}
onChange={(e) => setMentorSearch(e.target.value)}
placeholder="Search mentor by name, email, country, or expertise…"
className="pl-8"
/>
</div>
<div className="max-h-72 overflow-y-auto rounded-md border">
{(() => {
const q = mentorSearch.trim().toLowerCase()
const filteredMentors = q
? allMentors.filter((m) =>
[
m.name ?? '',
m.email,
m.country ?? '',
...(m.expertiseTags ?? []),
]
.join(' ')
.toLowerCase()
.includes(q),
)
: allMentors
if (allMentors.length === 0) {
return (
<p className="p-4 text-center text-sm text-muted-foreground">
No mentors in the pool yet.{' '}
<Link
href="/admin/members?tab=mentors"
className="underline-offset-2 hover:underline"
>
Add mentors
</Link>
.
</p>
)
}
if (filteredMentors.length === 0) {
return (
<p className="p-4 text-center text-sm text-muted-foreground">
No mentors match &ldquo;{mentorSearch}&rdquo;.
</p>
)
}
return filteredMentors.map((m) => {
const isChosen = chosenMentorIds.has(m.id)
return (
<label
key={m.id}
className={`flex cursor-pointer items-start gap-3 border-b px-3 py-2 text-sm last:border-b-0 ${
isChosen ? 'bg-accent' : 'hover:bg-muted/50'
}`}
>
<Checkbox
className="mt-1"
checked={isChosen}
onCheckedChange={(checked) =>
setChosenMentorIds((prev) => {
const next = new Set(prev)
if (checked) next.add(m.id)
else next.delete(m.id)
return next
})
}
aria-label={`Toggle ${m.name ?? m.email}`}
/>
<div className="min-w-0 flex-1">
<div className="font-medium">
{m.name ?? 'Unnamed'}
</div>
<div className="truncate text-xs text-muted-foreground">
{m.email}
{m.country && <> · {m.country}</>}
</div>
{m.expertiseTags && m.expertiseTags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{m.expertiseTags.slice(0, 4).map((t) => (
<Badge
key={t}
variant="secondary"
className="text-[10px]"
>
{t}
</Badge>
))}
{m.expertiseTags.length > 4 && (
<Badge
variant="outline"
className="text-[10px]"
>
+{m.expertiseTags.length - 4}
</Badge>
)}
</div>
)}
</div>
<div className="shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{m.currentAssignments}
{m.maxAssignments != null && `/${m.maxAssignments}`}{' '}
load
</div>
</label>
)
})
})()}
</div>
{chosenMentorIds.size > 0 && (
<p className="text-xs text-muted-foreground">
Will create up to{' '}
<span className="font-medium tabular-nums text-foreground">
{upperBound}
</span>{' '}
assignment{upperBound === 1 ? '' : 's'} (
{chosenMentorIds.size} mentor
{chosenMentorIds.size === 1 ? '' : 's'} × {selected.size}{' '}
project{selected.size === 1 ? '' : 's'}). Pairs that
already exist are skipped.
</p>
)}
</>
)
})()}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setBulkOpen(false)
setChosenMentorIds(new Set())
setMentorSearch('')
}}
>
Cancel
</Button>
<Button
onClick={() =>
bulkAssignMutation.mutate({
mentorIds: Array.from(chosenMentorIds),
projectIds: Array.from(selected),
})
}
disabled={
chosenMentorIds.size === 0 || bulkAssignMutation.isPending
}
>
{bulkAssignMutation.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
Assign {chosenMentorIds.size} mentor
{chosenMentorIds.size === 1 ? '' : 's'} to {selected.size} project
{selected.size === 1 ? '' : 's'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
competitionRounds={competitionRounds}
currentSortOrder={currentSortOrder}
onAssigned={() => {
utils.round.listMentoringProjects.invalidate({ roundId })
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
utils.round.getMentoringImportCandidates.invalidate({ roundId })
utils.mentor.getRoundStats.invalidate({ roundId })
}}
/>
</div>
)
}

View File

@@ -0,0 +1,267 @@
'use client'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowRight,
Clock,
FileText,
Inbox,
MessageCircle,
Target,
UserCheck,
Users,
} from 'lucide-react'
interface Props {
roundId: string
}
function formatRelativeFuture(date: Date | null): { label: string; tone: 'normal' | 'amber' | 'red' } {
if (!date) return { label: '—', tone: 'normal' }
const ms = date.getTime() - Date.now()
if (ms <= 0) return { label: 'Closed', tone: 'red' }
const hours = Math.floor(ms / 3_600_000)
const days = Math.floor(hours / 24)
const tone: 'normal' | 'amber' | 'red' =
hours <= 12 ? 'red' : hours <= 48 ? 'amber' : 'normal'
const label = days > 0 ? `Closes in ${days}d` : `Closes in ${hours}h`
return { label, tone }
}
function formatRelativePast(date: Date | null): string {
if (!date) return '—'
const ms = Date.now() - date.getTime()
const minutes = Math.floor(ms / 60_000)
const hours = Math.floor(ms / 3_600_000)
const days = Math.floor(ms / 86_400_000)
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
return `${Math.max(0, minutes)}m ago`
}
export function MentoringRoundOverview({ roundId }: Props) {
const { data: stats, isLoading: statsLoading } = trpc.mentor.getRoundStats.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const { data: pool, isLoading: poolLoading } = trpc.mentor.getMentorPool.useQuery({})
const { data: pendingChangeRequests } = trpc.mentor.listChangeRequests.useQuery(
{ status: 'PENDING' },
{ refetchInterval: 30_000 },
)
if (statsLoading || poolLoading) {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-32 w-full rounded-md" />
))}
</div>
)
}
if (!stats || !pool) return null
const pendingCount = pendingChangeRequests?.length ?? 0
// If there's at least one pending request, deep-link directly into the
// first one's project (admins can resolve / view siblings from there).
// Otherwise the card stays static.
const firstPendingProjectId = pendingChangeRequests?.[0]?.project.id ?? null
const changeRequestsHref = firstPendingProjectId
? `/admin/projects/${firstPendingProjectId}/mentor`
: null
const requestedPct = stats.totalProjects
? Math.round((stats.requestedCount / stats.totalProjects) * 100)
: 0
const assignedPct = stats.totalProjects
? Math.round((stats.assignedCount / stats.totalProjects) * 100)
: 0
const window = formatRelativeFuture(
stats.requestWindow.deadline ? new Date(stats.requestWindow.deadline) : null,
)
const avgLoad =
pool.poolSize > 0 ? (pool.totalCurrentAssignments / pool.poolSize).toFixed(1) : '—'
const lastActivity = stats.workspaceActivity.lastActivityAt
? new Date(stats.workspaceActivity.lastActivityAt)
: null
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Requested mentoring
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold tabular-nums">
{stats.requestedCount}
<span className="text-muted-foreground ml-2 text-sm font-normal">
/ {stats.totalProjects}
</span>
</div>
<p className="text-muted-foreground mt-1 text-xs">{requestedPct}% of round</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Mentor assigned
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-2">
<div className="text-3xl font-bold tabular-nums">{stats.assignedCount}</div>
<UserCheck className="text-muted-foreground h-5 w-5" />
</div>
<p className="text-muted-foreground mt-1 text-xs">
{assignedPct}% of round{' '}
{stats.awaitingAssignment > 0 && (
<span className="text-amber-700">
· {stats.awaitingAssignment} awaiting
</span>
)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Request window
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-2">
<Clock className="text-muted-foreground h-5 w-5" />
<Badge
variant={
window.tone === 'red'
? 'destructive'
: window.tone === 'amber'
? 'secondary'
: 'outline'
}
className="text-sm"
>
{window.label}
</Badge>
</div>
<p className="text-muted-foreground mt-2 text-xs">
{stats.requestWindow.deadline
? `Closes ${new Date(stats.requestWindow.deadline).toLocaleDateString()} · ${stats.requestWindow.deadlineDays}-day window`
: 'No window deadline set'}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Mentor pool
</CardTitle>
<Link
href="/admin/mentors"
className="text-muted-foreground hover:text-foreground inline-flex items-center text-xs"
>
View all
<ArrowRight className="ml-0.5 h-3 w-3" />
</Link>
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-2">
<div className="text-3xl font-bold tabular-nums">{pool.poolSize}</div>
<Users className="text-muted-foreground h-5 w-5" />
</div>
<p className="text-muted-foreground mt-1 text-xs">
Avg load <span className="text-foreground font-medium">{avgLoad}</span> ·{' '}
{pool.totalCurrentAssignments} active
</p>
</CardContent>
</Card>
<Card
className={`md:col-span-2 xl:col-span-4 ${
pendingCount > 0 ? 'border-amber-300' : ''
}`}
>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3">
<Inbox
className={`h-5 w-5 ${
pendingCount > 0 ? 'text-amber-600' : 'text-muted-foreground'
}`}
/>
<div>
<div className="text-sm font-medium">Pending change requests</div>
<div className="text-muted-foreground text-xs">
Team members asking admin to swap a mentor
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-2xl font-bold tabular-nums">{pendingCount}</div>
{changeRequestsHref ? (
<Link
href={changeRequestsHref}
className="text-muted-foreground hover:text-foreground inline-flex items-center text-xs"
>
Review
<ArrowRight className="ml-0.5 h-3 w-3" />
</Link>
) : (
<span className="text-muted-foreground text-xs">All clear</span>
)}
</div>
</CardContent>
</Card>
<Card className="md:col-span-2 xl:col-span-4">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Workspace activity</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm md:grid-cols-4">
<div className="flex items-center gap-2">
<MessageCircle className="text-muted-foreground h-4 w-4" />
<div>
<div className="font-bold tabular-nums">{stats.workspaceActivity.messageCount}</div>
<div className="text-muted-foreground text-xs">messages</div>
</div>
</div>
<div className="flex items-center gap-2">
<FileText className="text-muted-foreground h-4 w-4" />
<div>
<div className="font-bold tabular-nums">{stats.workspaceActivity.fileCount}</div>
<div className="text-muted-foreground text-xs">files</div>
</div>
</div>
<div className="flex items-center gap-2">
<Target className="text-muted-foreground h-4 w-4" />
<div>
<div className="font-bold tabular-nums">
{stats.workspaceActivity.milestoneCount}
</div>
<div className="text-muted-foreground text-xs">milestones</div>
</div>
</div>
<div className="flex items-center gap-2">
<Clock className="text-muted-foreground h-4 w-4" />
<div>
<div className="font-medium">{formatRelativePast(lastActivity)}</div>
<div className="text-muted-foreground text-xs">last activity</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -290,8 +290,8 @@ export function ProjectStatesTable({ competitionId, roundId, roundStatus, compet
<div className="space-y-4"> <div className="space-y-4">
{/* Finalization hint for closed rounds */} {/* Finalization hint for closed rounds */}
{(roundStatus === 'ROUND_CLOSED' || roundStatus === 'ROUND_ARCHIVED') && ( {(roundStatus === 'ROUND_CLOSED' || roundStatus === 'ROUND_ARCHIVED') && (
<div className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20 px-4 py-3 text-sm"> <div className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm">
<span className="text-blue-700 dark:text-blue-300"> <span className="text-blue-700">
This round is closed. Use the <strong>Finalization</strong> tab to review proposed outcomes and confirm advancement. This round is closed. Use the <strong>Finalization</strong> tab to review proposed outcomes and confirm advancement.
</span> </span>
</div> </div>
@@ -785,7 +785,7 @@ function QuickAddDialog({
* Create New: form to create a project and assign it directly to the round. * Create New: form to create a project and assign it directly to the round.
* From Pool: search existing projects not yet in this round and assign them. * From Pool: search existing projects not yet in this round and assign them.
*/ */
function AddProjectDialog({ export function AddProjectDialog({
open, open,
onOpenChange, onOpenChange,
roundId, roundId,

View File

@@ -39,6 +39,7 @@ import { Textarea } from '@/components/ui/textarea'
import { Slider } from '@/components/ui/slider' import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { ScoreExplainerDialog } from '@/components/shared/score-explainer-dialog' import { ScoreExplainerDialog } from '@/components/shared/score-explainer-dialog'
import { csvCell } from '@/lib/csv'
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
@@ -652,16 +653,9 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
} }
const headers = result.columns const headers = result.columns
const csvRows = [ const csvRows = [
headers.join(','), headers.map((h: string) => csvCell(h)).join(','),
...result.data.map((row: Record<string, unknown>) => ...result.data.map((row: Record<string, unknown>) =>
headers.map((h: string) => { headers.map((h: string) => csvCell(row[h])).join(','),
const val = row[h]
if (val == null) return ''
const str = String(val)
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"`
: str
}).join(','),
), ),
] ]
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' }) const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' })
@@ -705,7 +699,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
This may take a minute. You can continue working results will appear automatically. This may take a minute. You can continue working results will appear automatically.
</p> </p>
</div> </div>
<div className="h-2 w-48 rounded-full bg-blue-100 dark:bg-blue-900 overflow-hidden"> <div className="h-2 w-48 rounded-full bg-blue-100 overflow-hidden">
<div className="h-full w-full rounded-full bg-blue-500 animate-pulse" /> <div className="h-full w-full rounded-full bg-blue-500 animate-pulse" />
</div> </div>
</> </>
@@ -968,18 +962,18 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
{/* Ranking in-progress banner */} {/* Ranking in-progress banner */}
{rankingInProgress && ( {rankingInProgress && (
<Card className="border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30"> <Card className="border-blue-200 bg-blue-50">
<CardContent className="flex items-center gap-3 py-4"> <CardContent className="flex items-center gap-3 py-4">
<Loader2 className="h-5 w-5 animate-spin text-blue-600 dark:text-blue-400 flex-shrink-0" /> <Loader2 className="h-5 w-5 animate-spin text-blue-600 flex-shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-blue-900 dark:text-blue-200"> <p className="text-sm font-medium text-blue-900">
Ranking in progress&hellip; Ranking in progress&hellip;
</p> </p>
<p className="text-xs text-blue-700 dark:text-blue-400"> <p className="text-xs text-blue-700">
This may take a minute. You can continue working results will appear automatically. This may take a minute. You can continue working results will appear automatically.
</p> </p>
</div> </div>
<div className="h-1.5 w-32 rounded-full bg-blue-200 dark:bg-blue-800 overflow-hidden flex-shrink-0"> <div className="h-1.5 w-32 rounded-full bg-blue-200 overflow-hidden flex-shrink-0">
<div className="h-full w-full rounded-full bg-blue-500 animate-pulse" /> <div className="h-full w-full rounded-full bg-blue-500 animate-pulse" />
</div> </div>
</CardContent> </CardContent>
@@ -1103,7 +1097,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }} exit={{ opacity: 0, y: -20 }}
className={isAdvancing className={isAdvancing
? 'rounded-lg bg-emerald-50 border-l-4 border-emerald-400 dark:bg-emerald-950/20 dark:border-emerald-600' ? 'rounded-lg bg-emerald-50 border-l-4 border-emerald-400'
: ''} : ''}
> >
<SortableProjectRow <SortableProjectRow
@@ -1126,7 +1120,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
{isCutoffRow && ( {isCutoffRow && (
<div className="flex items-center gap-2 py-1"> <div className="flex items-center gap-2 py-1">
<div className="flex-1 border-t-2 border-dashed border-emerald-400/60" /> <div className="flex-1 border-t-2 border-dashed border-emerald-400/60" />
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400 whitespace-nowrap"> <span className="text-xs font-medium text-emerald-600 whitespace-nowrap">
Advancement cutoff {isThresholdMode ? `Score ≥ ${threshold}` : `Top ${advanceCount}`} Advancement cutoff {isThresholdMode ? `Score ≥ ${threshold}` : `Top ${advanceCount}`}
</span> </span>
<div className="flex-1 border-t-2 border-dashed border-emerald-400/60" /> <div className="flex-1 border-t-2 border-dashed border-emerald-400/60" />

View File

@@ -40,6 +40,11 @@ export function MentoringConfig({ config, onChange }: MentoringConfigProps) {
<SelectItem value="admin_selected">Admin Selected</SelectItem> <SelectItem value="admin_selected">Admin Selected</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<ul className="text-xs text-muted-foreground space-y-1 mt-2">
<li><strong>All Advancing Projects</strong> every project that enters this round is paired with a mentor.</li>
<li><strong>Requested Only</strong> only projects that explicitly request mentoring participate (default).</li>
<li><strong>Admin Selected</strong> admin manually picks which projects get a mentor.</li>
</ul>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -56,6 +61,46 @@ export function MentoringConfig({ config, onChange }: MentoringConfigProps) {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Mentoring Request Window</CardTitle>
<CardDescription>How long teams have to request a mentor, and what happens to non-requesters</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="mentoringRequestDeadlineDays">Request deadline (days from round opening)</Label>
<p className="text-xs text-muted-foreground">After this many days, teams can no longer submit a mentoring request. Default: 14.</p>
<Input
id="mentoringRequestDeadlineDays"
type="number"
min={1}
max={90}
className="w-32"
value={(config.mentoringRequestDeadlineDays as number) ?? 14}
onChange={(e) => {
const v = parseInt(e.target.value, 10)
if (!Number.isNaN(v) && v >= 1 && v <= 90) update('mentoringRequestDeadlineDays', v)
}}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="passThroughIfNoRequest">Auto-pass non-requesters</Label>
<p className="text-xs text-muted-foreground">
When ON, projects that don&apos;t request mentoring auto-PASS to the next round (default).
When OFF, all projects are held in PENDING until the admin decides useful when mentoring is mandatory.
</p>
</div>
<Switch
id="passThroughIfNoRequest"
checked={(config.passThroughIfNoRequest as boolean | undefined) ?? true}
onCheckedChange={(v) => update('passThroughIfNoRequest', v)}
/>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Communication & Files</CardTitle> <CardTitle className="text-base">Communication & Files</CardTitle>

View File

@@ -0,0 +1,226 @@
'use client'
import { useEffect, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { useEdition } from '@/contexts/edition-context'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { Loader2, ScrollText, Stamp, Users } from 'lucide-react'
import { toast } from 'sonner'
function NumberField({
id,
label,
hint,
value,
onCommit,
disabled,
min,
max,
}: {
id: string
label: string
hint?: string
value: number | null
onCommit: (next: number) => void
disabled?: boolean
min?: number
max?: number
}) {
const [draft, setDraft] = useState<string>(value != null ? String(value) : '')
useEffect(() => {
setDraft(value != null ? String(value) : '')
}, [value])
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
type="number"
min={min}
max={max}
value={draft}
disabled={disabled}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
const parsed = Number(draft)
if (!Number.isFinite(parsed) || parsed === value) return
if (min !== undefined && parsed < min) return
if (max !== undefined && parsed > max) return
onCommit(parsed)
}}
className="max-w-[12rem]"
/>
{hint && <p className="text-muted-foreground text-xs">{hint}</p>}
</div>
)
}
export function EditionSettingsTab() {
const { currentEdition } = useEdition()
const programId = currentEdition?.id ?? null
const utils = trpc.useUtils()
const { data, isLoading } = trpc.program.getEditionSettings.useQuery(
{ programId: programId ?? '' },
{ enabled: !!programId },
)
const update = trpc.program.updateEditionSettings.useMutation({
onSuccess: () => {
if (programId) utils.program.getEditionSettings.invalidate({ programId })
toast.success('Edition settings updated')
},
onError: (e) => toast.error(e.message),
})
if (!programId) {
return (
<p className="text-muted-foreground text-sm">
Select an edition from the sidebar dropdown to manage settings.
</p>
)
}
if (isLoading || !data) {
return (
<div className="space-y-6">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-32 w-full" />
</div>
)
}
const noLiveFinalRound = data.liveFinalRoundId == null
return (
<div className="space-y-6">
{/* Grand-finale logistics */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-base">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<Users className="h-4 w-4 text-emerald-500" />
</div>
Grand-finale logistics
</CardTitle>
<CardDescription>
Per-edition limits and deadlines that drive finalist confirmation, attendee
editing, and visa visibility.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<NumberField
id="default-attendee-cap"
label="Default attendee cap"
hint="Maximum number of team members allowed at the grand finale per finalist team."
min={1}
max={20}
value={data.defaultAttendeeCap}
disabled={update.isPending}
onCommit={(next) =>
update.mutate({ programId, defaultAttendeeCap: next })
}
/>
<NumberField
id="confirmation-window-hours"
label="Confirmation window (hours)"
hint={
noLiveFinalRound
? 'Will be editable once the grand-finale (LIVE_FINAL) round is created.'
: 'How long teams have to click the confirm/decline link after we send it.'
}
min={1}
max={720}
value={data.confirmationWindowHours}
disabled={update.isPending || noLiveFinalRound}
onCommit={(next) =>
update.mutate({ programId, confirmationWindowHours: next })
}
/>
<NumberField
id="attendee-edit-cutoff-hours"
label="Attendee edit cutoff (hours before grand finale)"
hint={
noLiveFinalRound
? 'Will be editable once the grand-finale (LIVE_FINAL) round is created.'
: 'After this many hours before the grand finale opens, the team lead can no longer change attendees.'
}
min={0}
max={720}
value={data.attendeeEditCutoffHours}
disabled={update.isPending || noLiveFinalRound}
onCommit={(next) =>
update.mutate({ programId, attendeeEditCutoffHours: next })
}
/>
</CardContent>
</Card>
{/* Visa */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-base">
<div className="rounded-lg bg-sky-500/10 p-1.5">
<Stamp className="h-4 w-4 text-sky-500" />
</div>
Visa
</CardTitle>
<CardDescription>
Visa documents are exchanged over email and never stored on the platform
we track only process metadata. Choose whether teams see their own status.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<div className="space-y-1">
<Label htmlFor="visa-visibility-edition">Visible to teams</Label>
<p className="text-muted-foreground text-xs">
When on, attendees with needsVisa=true see their status on the
applicant dashboard. When off, only admins see the workflow.
</p>
</div>
<Switch
id="visa-visibility-edition"
checked={data.visaStatusVisibleToMembers}
disabled={update.isPending}
onCheckedChange={(v) =>
update.mutate({ programId, visaStatusVisibleToMembers: v })
}
/>
</div>
</CardContent>
</Card>
{/* Coming soon */}
<Card className="border-dashed">
<CardHeader>
<CardTitle className="text-muted-foreground text-base">Coming soon</CardTitle>
<CardDescription>
Editable email templates land in an upcoming update and will surface here.
</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground space-y-2 text-sm">
<div className="flex items-center gap-2">
<ScrollText className="h-4 w-4" /> Email templates editable subject + body
for confirmation, decline-cascade, mentor onboarding, etc.
</div>
</CardContent>
</Card>
{update.isPending && (
<div className="text-muted-foreground inline-flex items-center gap-2 text-xs">
<Loader2 className="h-3 w-3 animate-spin" /> Saving
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,210 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { PlaneTakeoff, ShieldCheck, AlertTriangle } from 'lucide-react'
import { EditAttendeesDialog } from './edit-attendees-dialog'
import { LunchPickForm } from './lunch-pick-form'
import type { VisaStatus } from '@prisma/client'
import { useSession } from 'next-auth/react'
const VISA_BADGE: Record<
VisaStatus,
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
> = {
NOT_NEEDED: { label: 'Visa not needed', variant: 'outline' },
REQUESTED: { label: 'Visa requested', variant: 'secondary' },
INVITATION_SENT: { label: 'Invitation sent', variant: 'secondary' },
APPOINTMENT_BOOKED: { label: 'Appointment booked', variant: 'default' },
GRANTED: { label: 'Visa granted', variant: 'default' },
DENIED: { label: 'Visa denied', variant: 'destructive' },
}
function formatDateOnly(d: Date | string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(d))
}
function nextVisaDate(v: {
invitationSentAt: Date | string | null
appointmentAt: Date | string | null
decisionAt: Date | string | null
status: VisaStatus
}): { label: string; date: Date | string } | null {
if (v.status === 'GRANTED' || v.status === 'DENIED') {
if (v.decisionAt) return { label: 'Decision', date: v.decisionAt }
return null
}
if (v.appointmentAt) return { label: 'Appointment', date: v.appointmentAt }
if (v.invitationSentAt) return { label: 'Invitation sent', date: v.invitationSentAt }
return null
}
export function AttendingMembersCard() {
const { data: session } = useSession()
const { data, isLoading } = trpc.applicant.getMyFinalistConfirmation.useQuery()
const { data: myVisas } = trpc.applicant.getMyVisaApplications.useQuery()
const programId = data?.project.programId
const { data: lunchEvent } = trpc.lunch.getEventForMember.useQuery(
{ programId: programId ?? '' },
{ enabled: !!programId },
)
if (isLoading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
)
}
if (!data || data.confirmation.status !== 'CONFIRMED') return null
const cutoffAt = data.cutoffAt ? new Date(data.cutoffAt) : null
const userById = new Map(data.project.teamMembers.map((tm) => [tm.userId, tm.user]))
const attendees = data.confirmation.attendingMembers
const visaByUser = new Map(
(myVisas ?? []).map((v) => [v.userId, v] as const),
)
const editDisabled = !data.editableNow
const editDisabledReason = !data.editableNow
? 'Attendee changes are closed for this edition.'
: undefined
return (
<Card>
<CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
<div>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-sky-500/10 p-1.5">
<PlaneTakeoff className="h-4 w-4 text-sky-500" />
</div>
Grand Finale Attendees
</CardTitle>
<CardDescription className="mt-1">
Team members confirmed to travel to Monaco
{cutoffAt && data.editableNow && (
<>
{' '}
· editable until{' '}
<strong>
{new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(cutoffAt)}
</strong>
</>
)}
{cutoffAt && !data.editableNow && (
<span className="text-muted-foreground inline-flex items-center gap-1">
{' '}
· <AlertTriangle className="h-3 w-3" /> editing closed
</span>
)}
</CardDescription>
</div>
{data.isLead && (
<EditAttendeesDialog
confirmationId={data.confirmation.id}
cap={data.project.program.defaultAttendeeCap}
teamMembers={data.project.teamMembers}
attendingMembers={attendees}
cutoffAt={cutoffAt}
disabled={editDisabled}
disabledReason={editDisabledReason}
/>
)}
</CardHeader>
<CardContent>
{attendees.length === 0 ? (
<p className="text-muted-foreground text-sm">No attendees selected yet.</p>
) : (
<ul className="space-y-2">
{attendees.map((a) => {
const user = userById.get(a.userId)
if (!user) return null
const visa = visaByUser.get(a.userId)
const visaBadge = visa ? VISA_BADGE[visa.status] : null
const next = visa ? nextVisaDate(visa) : null
const sessionUserId = session?.user?.id
const sessionRole = session?.user?.role
const isAdmin =
sessionRole === 'SUPER_ADMIN' || sessionRole === 'PROGRAM_ADMIN'
const isSelf = sessionUserId === a.userId
const isLeadActing = data.isLead && !isSelf
const lunchDeadline = lunchEvent?.changeDeadline
? new Date(lunchEvent.changeDeadline)
: null
const lunchPastDeadline =
!!lunchDeadline && new Date() > lunchDeadline
const canEditLunch =
!!lunchEvent &&
((isSelf && !lunchPastDeadline) ||
(data.isLead && !lunchPastDeadline) ||
isAdmin)
return (
<li
key={a.userId}
className="space-y-3 rounded-md border px-3 py-2"
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{user.name ?? user.email}</div>
<div className="text-muted-foreground text-xs">{user.email}</div>
</div>
<div className="flex flex-col items-end gap-1">
{visa && visaBadge ? (
<>
<Badge variant={visaBadge.variant} className="gap-1">
<ShieldCheck className="h-3 w-3" />
{visaBadge.label}
</Badge>
{next && (
<span className="text-muted-foreground text-xs">
{next.label}: {formatDateOnly(next.date)}
</span>
)}
</>
) : (
a.needsVisa && (
<Badge variant="outline" className="gap-1">
<ShieldCheck className="h-3 w-3" />
Visa support
</Badge>
)
)}
</div>
</div>
{lunchEvent && programId && (
<LunchPickForm
attendingMemberId={a.id}
programId={programId}
lunchEventId={lunchEvent.id}
canEdit={canEditLunch}
editingOnBehalfOf={
isLeadActing ? (user.name ?? user.email) : null
}
/>
)}
</li>
)
})}
</ul>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,182 @@
'use client'
import { useEffect, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Loader2, Pencil } from 'lucide-react'
import { toast } from 'sonner'
type TeamMember = {
userId: string
role: string
user: { id: string; name: string | null; email: string }
}
type AttendingMember = { userId: string; needsVisa: boolean }
export function EditAttendeesDialog({
confirmationId,
cap,
teamMembers,
attendingMembers,
cutoffAt,
disabled,
disabledReason,
}: {
confirmationId: string
cap: number
teamMembers: TeamMember[]
attendingMembers: AttendingMember[]
cutoffAt: Date | null
disabled?: boolean
disabledReason?: string
}) {
const [open, setOpen] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [visa, setVisa] = useState<Record<string, boolean>>({})
const utils = trpc.useUtils()
const edit = trpc.finalist.editAttendees.useMutation({
onSuccess: () => {
toast.success('Attendees updated')
utils.applicant.getMyFinalistConfirmation.invalidate()
setOpen(false)
},
onError: (e) => toast.error(e.message),
})
// Reset form to current roster when dialog opens
useEffect(() => {
if (open) {
setSelected(new Set(attendingMembers.map((m) => m.userId)))
setVisa(
Object.fromEntries(attendingMembers.map((m) => [m.userId, m.needsVisa])),
)
}
}, [open, attendingMembers])
const toggle = (userId: string, checked: boolean) => {
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(userId)
else next.delete(userId)
return next
})
}
const overCap = selected.size > cap
const noneSelected = selected.size === 0
const handleSubmit = () => {
const ids = Array.from(selected)
edit.mutate({
confirmationId,
attendingUserIds: ids,
visaFlags: Object.fromEntries(ids.map((id) => [id, !!visa[id]])),
})
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!edit.isPending) setOpen(next)
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm" disabled={disabled} title={disabledReason}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit attendees
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit attendees</DialogTitle>
<DialogDescription>
Update who from your team will travel to the grand finale. You can select up to{' '}
<strong>{cap}</strong> team members. Mark anyone who needs visa support so we can prepare
documents in time.
{cutoffAt && (
<>
{' '}
Editable until{' '}
<strong>
{new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(cutoffAt)}
</strong>
.
</>
)}
</DialogDescription>
</DialogHeader>
<ul className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
{teamMembers.map((tm) => {
const checked = selected.has(tm.userId)
return (
<li key={tm.userId} className="flex items-start justify-between gap-4">
<label className="flex flex-1 items-start gap-3 cursor-pointer">
<Checkbox
checked={checked}
onCheckedChange={(c) => toggle(tm.userId, c === true)}
className="mt-0.5"
/>
<div>
<div className="font-medium">{tm.user.name ?? tm.user.email}</div>
<div className="text-muted-foreground text-xs">
{tm.user.email}
{tm.role && tm.role !== 'MEMBER' ? ` · ${tm.role.toLowerCase()}` : ''}
</div>
</div>
</label>
{checked && (
<label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Needs visa?</span>
<Switch
checked={!!visa[tm.userId]}
onCheckedChange={(c) =>
setVisa((prev) => ({ ...prev, [tm.userId]: c }))
}
/>
</label>
)}
</li>
)
})}
</ul>
{overCap && (
<p className="text-destructive text-sm">
Please select no more than {cap} member{cap === 1 ? '' : 's'}.
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={edit.isPending}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={overCap || noneSelected || edit.isPending}
>
{edit.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save attendees
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

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