Compare commits
135 Commits
6e36704bb1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2945a92193 | ||
|
|
9b56eb27fb | ||
|
|
160333c2f9 | ||
|
|
f7fdfdec9b | ||
|
|
a2c6baf718 | ||
|
|
c9dc1bfabd | ||
|
|
4e6904fa12 | ||
|
|
45b007334e | ||
|
|
6d2fa3369f | ||
|
|
6eccfc694e | ||
|
|
97ef3e59ac | ||
|
|
a66bd728cd | ||
|
|
64f88890f5 | ||
|
|
dcd85c9b13 | ||
|
|
3be1fcd24a | ||
|
|
d38fe7887a | ||
|
|
28ca7bb0a6 | ||
|
|
d89f67ba57 | ||
|
|
2c311bc65a | ||
|
|
85937ec942 | ||
|
|
81352d7bd2 | ||
|
|
8a4184d20f | ||
|
|
f8f2d77e3b | ||
|
|
696d7e9041 | ||
|
|
f61dcfa89a | ||
|
|
146691be00 | ||
|
|
e4f13aaed4 | ||
|
|
6e1dcc8cbf | ||
|
|
24c7c4bc6c | ||
|
|
8c6a59bad9 | ||
|
|
b66e2071f9 | ||
|
|
df0be6bb48 | ||
|
|
e9e072dda7 | ||
|
|
b0a0a71cfe | ||
|
|
61bf5a4032 | ||
|
|
26709e2c9b | ||
|
|
f3d3a21156 | ||
|
|
9e058e6ad7 | ||
|
|
16e0a08f16 | ||
|
|
c53ec23109 | ||
|
|
b1923cf0e1 | ||
|
|
b757aae551 | ||
|
|
c2afa58606 | ||
|
|
537de07245 | ||
|
|
a6fc697e4d | ||
|
|
8d4f0bac1e | ||
|
|
f2c8cc1e80 | ||
|
|
89ab5cc8e6 | ||
|
|
3bbc80332c | ||
|
|
9313eb96f0 | ||
|
|
4cd2651f9c | ||
|
|
75e63eb47f | ||
|
|
200b5e0cb9 | ||
|
|
42e6b5f8c0 | ||
|
|
97951deb68 | ||
|
|
53b623fb20 | ||
|
|
74cd111e3a | ||
|
|
d03c705642 | ||
|
|
ed426a6fb4 | ||
|
|
ee8d65a300 | ||
|
|
0058b2b73b | ||
|
|
e5788b3e9d | ||
|
|
27bdf8cdef | ||
|
|
3f25ba112b | ||
|
|
884c96c710 | ||
|
|
1b4ab6be18 | ||
|
|
d501624c56 | ||
|
|
b2826d595f | ||
|
|
f529e79cd7 | ||
|
|
0ea949309a | ||
|
|
8afef1ecba | ||
|
|
34bb2bad57 | ||
|
|
8ee517f6ca | ||
|
|
2a98f0cacf | ||
|
|
e80710487c | ||
|
|
375aeb08af | ||
|
|
f1e62fdd3b | ||
|
|
dde8ea9345 | ||
|
|
ca9edcd038 | ||
|
|
647e7f09a7 | ||
|
|
6b37e9bb9e | ||
|
|
eb891403f1 | ||
|
|
60f1a53d70 | ||
|
|
501b4ffdb5 | ||
|
|
828c09df6d | ||
|
|
fe7f133879 | ||
|
|
d4a77f63d3 | ||
|
|
040e5ff9a9 | ||
|
|
652c3ed4f2 | ||
|
|
ed4948cc3d | ||
|
|
bd05aaa87d | ||
|
|
0d6f71b9e1 | ||
|
|
829b082912 | ||
|
|
32116dac75 | ||
|
|
0e221c3916 | ||
|
|
9d3ed1cc64 | ||
|
|
a973b1316c | ||
|
|
5a9821807a | ||
|
|
d57495be15 | ||
|
|
03526fca97 | ||
|
|
61dfc608cd | ||
|
|
c4f7216bc1 | ||
|
|
cb2a864b7f | ||
|
|
195fc787a9 | ||
|
|
921019aaa4 | ||
|
|
5b99d6a530 | ||
|
|
6969b9c2bc | ||
|
|
3bc9c11a51 | ||
|
|
8d4b62a602 | ||
|
|
f64e68e751 | ||
|
|
48e48f058d | ||
|
|
ec92b03006 | ||
|
|
349671f37c | ||
|
|
4f444a1baa | ||
|
|
d47db17027 | ||
|
|
83e950bb67 | ||
|
|
ba115f71a0 | ||
|
|
d440b5f274 | ||
|
|
ee47c0305f | ||
|
|
3a1eb149b6 | ||
|
|
a5ad11a1b5 | ||
|
|
66110598a0 | ||
|
|
9152ebb399 | ||
|
|
a26e486ab5 | ||
|
|
e89dca24c3 | ||
|
|
3bcbf72ad6 | ||
|
|
47746d79dd | ||
|
|
44c7accf62 | ||
|
|
9a9a73dde2 | ||
|
|
cad5b3fc28 | ||
|
|
7bc2b84d1d | ||
|
|
a9116b5833 | ||
|
|
b7a4eac2b1 | ||
|
|
55e6abc161 | ||
|
|
e8d0bb050f |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -63,3 +63,8 @@ build-output.txt
|
||||
private/
|
||||
public/build-id.json
|
||||
.remember/
|
||||
|
||||
# Local tooling + session screenshots
|
||||
.claude/
|
||||
.serena/
|
||||
/*.png
|
||||
|
||||
@@ -6,15 +6,38 @@ MIGRATION_RETRY_DELAY_SECONDS="${MIGRATION_RETRY_DELAY_SECONDS:-2}"
|
||||
ATTEMPT=1
|
||||
|
||||
# Auto-resolve any previously failed migrations so deploy can proceed.
|
||||
# This handles the case where a migration partially applied and was fixed
|
||||
# in a subsequent deploy — without this, Prisma refuses to run anything.
|
||||
# This handles the case where a migration failed mid-flight and was then
|
||||
# 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..."
|
||||
MIGRATE_STATUS=$(npx prisma migrate status 2>&1 || true)
|
||||
FAILED=$(echo "$MIGRATE_STATUS" | sed -n 's/.*The `\([^`]*\)` migration.*failed.*/\1/p' | head -1)
|
||||
if [ -n "$FAILED" ]; then
|
||||
echo "==> Found failed migration: $FAILED — marking as rolled back..."
|
||||
npx prisma migrate resolve --rolled-back "$FAILED"
|
||||
RESOLVE_ATTEMPTS=0
|
||||
while [ "$RESOLVE_ATTEMPTS" -lt 5 ]; do
|
||||
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..."
|
||||
npx prisma migrate resolve --rolled-back "$FAILED" || {
|
||||
echo "WARNING: prisma migrate resolve failed for $FAILED"
|
||||
break
|
||||
}
|
||||
RESOLVE_ATTEMPTS=$((RESOLVE_ATTEMPTS + 1))
|
||||
done
|
||||
|
||||
echo "==> Running database migrations (with retry)..."
|
||||
until npx prisma migrate deploy; do
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
76
docs/superpowers/plans/2026-06-04-multi-hotel-rooming.md
Normal file
76
docs/superpowers/plans/2026-06-04-multi-hotel-rooming.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Multiple Hotels + Room Assignments — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||
|
||||
**Goal:** Replace the one-hotel-per-edition model with many hotels + per-attendee room assignments (hotel, room number, check-in/out), assignable per-member or per-team, surfaced to teams and in the travel email.
|
||||
|
||||
**Architecture:** New `HotelStay` 1:1 detail record per `AttendingMember` (mirrors `FlightDetail`); `Hotel.programId` becomes non-unique. Logistics router gains list/CRUD + rooming/assignment procedures; the Hotels tab is reworked into Hotels + Rooming sections. Spec: `docs/superpowers/specs/2026-06-04-multi-hotel-rooming-design.md`.
|
||||
|
||||
**Tech Stack:** Next.js 15, tRPC 11, Prisma 6, shadcn/ui, Vitest 4. One schema migration (additive + drop one unique constraint).
|
||||
|
||||
**Verified facts:** `Hotel` (`schema.prisma`) is `programId @unique`; `FlightDetail` is the 1:1-detail pattern to mirror. 1:1 hotel callers to update: `logistics.ts:13` (`getHotel`), `:17/33` (`upsertHotel`), `:231` (travel email program-hotel lookup), `applicant.ts:2899` (`getMyLogistics`), `hotels-tab.tsx:20/37/40`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Schema — many hotels + `HotelStay`
|
||||
|
||||
**Files:** `prisma/schema.prisma`, migration.
|
||||
|
||||
- [ ] **Step 1:** Edit `Hotel`: remove `@unique` from `programId`; add `stays HotelStay[]` and `@@index([programId])`.
|
||||
- [ ] **Step 2:** Add `HotelStay` model exactly as in the spec (1:1 `attendingMemberId @unique`, required `hotelId`, `roomNumber?`, `checkInAt?`, `checkOutAt?`, `notes?`, timestamps; `attendingMember` relation `onDelete: Cascade`; `hotel` relation `onDelete: Restrict`; `@@index([hotelId])`). Add `hotelStay HotelStay?` to `AttendingMember`.
|
||||
- [ ] **Step 3:** `npx prisma migrate dev --name multi_hotel_and_hotel_stay` → migration created + applied + client regenerated. Confirm the generated SQL drops `Hotel_programId_key`, adds the index, and creates `HotelStay` with both FKs.
|
||||
- [ ] **Step 4:** `npm run typecheck` (existing `getHotel`/`upsertHotel` still compile until Task 2). Commit: `git add prisma/ && git commit -m "feat(hotel): many hotels per edition + HotelStay (room assignment)"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Logistics router — hotels CRUD + rooming + assignment + travel email
|
||||
|
||||
**Files:** `src/server/routers/logistics.ts`; test `tests/unit/logistics-hotels.test.ts`.
|
||||
|
||||
Replace `getHotel`/`upsertHotel` with the full set (read the spec table for exact inputs):
|
||||
- `listHotels({ programId })` — hotels + `_count: { stays }`.
|
||||
- `createHotel` / `updateHotel` / `deleteHotel` (deleteHotel: pre-count stays; if >0 throw `BAD_REQUEST` "Reassign N occupant(s) first"). All audited.
|
||||
- `listRooming({ programId })` — one row per CONFIRMED `AttendingMember` in the program: `{ attendingMemberId, projectId, projectTitle, user{id,name,email}, stay: { hotelId, roomNumber, checkInAt, checkOutAt } | null }`, sorted by project title then user name.
|
||||
- `assignStay({ attendingMemberId, hotelId, roomNumber?, checkInAt?, checkOutAt?, notes? })` — upsert `HotelStay` (validate the hotel belongs to the same program as the attendee). Audit `HOTEL_STAY_ASSIGN`.
|
||||
- `assignTeamToHotel({ confirmationId, hotelId, checkInAt?, checkOutAt? })` — for each `AttendingMember` of the confirmation, upsert `HotelStay` with `hotelId` (+ optional dates), preserving existing `roomNumber`. Audit `HOTEL_TEAM_ASSIGN`.
|
||||
- `unassignStay({ attendingMemberId })` — `deleteMany` (no-op safe). Audit `HOTEL_STAY_UNASSIGN`.
|
||||
|
||||
Then update **`setFlightStatus`** (the `TRAVEL_CONFIRMED` path, ~line 231): instead of the program 1:1 hotel, load the attendee's `hotelStay` (with `hotel`) and pass hotel + room/dates into the notification `metadata` (keys: `hotel: { name, address, link }`, `roomNumber`, `checkInAt`, `checkOutAt`). Update `getTravelConfirmedTemplate` (`src/lib/email.ts`) + its `NOTIFICATION_EMAIL_TEMPLATES` entry to render room/dates if present (additive — keep existing fields working).
|
||||
|
||||
- [ ] **Step 1: Tests** (`tests/unit/logistics-hotels.test.ts`, mirror `logistics-hotel.test.ts`): create 2 hotels for one program; `deleteHotel` on an occupied hotel rejects, on an empty one succeeds; `assignStay` upsert (create→update room); `assignTeamToHotel` assigns all of a 2-attendee team; `unassignStay` removes; `listRooming` returns the confirmed attendees with/without stays.
|
||||
- [ ] **Step 2:** Run → fail → implement → pass (`npx vitest run tests/unit/logistics-hotels.test.ts`). Re-run `tests/unit/logistics-flight.test.ts tests/unit/logistics-comms.test.ts` (travel-email change).
|
||||
- [ ] **Step 3:** `npm run typecheck`. Commit: `feat(logistics): hotels CRUD + rooming + assignment procedures + travel email`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Applicant `getMyLogistics` + My Logistics card → assigned hotel/room
|
||||
|
||||
**Files:** `src/server/routers/applicant.ts`, `src/components/applicant/my-logistics-card.tsx`; test `tests/unit/applicant-my-logistics.test.ts` (extend).
|
||||
|
||||
- [ ] **Step 1:** In `getMyLogistics` (~line 2899), replace the `prisma.hotel.findUnique({ where: { programId } })` with the caller's `AttendingMember.hotelStay` (include `hotel`). Return `hotel: { name, address, link, notes } | null` and `room: { roomNumber, checkInAt, checkOutAt } | null`. Extend the test to seed a `HotelStay` and assert `hotel.name` + `room.roomNumber`.
|
||||
- [ ] **Step 2:** Update `MyLogisticsCard` Hotel section to also show **Room** (number) + check-in/out (Monaco-time labels) when `room` is present.
|
||||
- [ ] **Step 3:** Run the applicant test → pass; `npm run typecheck`. Commit: `feat(applicant): My Logistics shows assigned hotel + room`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Hotels tab rework — Hotels + Rooming UI
|
||||
|
||||
**Files:** `src/components/admin/logistics/hotels-tab.tsx` (rework); optionally split a `rooming-section.tsx`.
|
||||
|
||||
- [ ] **Step 1: Hotels section** — `trpc.logistics.listHotels`; list cards with name/address/link/notes + occupancy badge (`_count.stays`); Add / Edit (dialog) / Delete (AlertDialog; show the "reassign first" error toast on rejection) wired to create/update/deleteHotel. Invalidate on success.
|
||||
- [ ] **Step 2: Rooming section** — `trpc.logistics.listRooming`; group rows by project (team). Per team header: an **"Assign whole team to…"** Select → `assignTeamToHotel`. Per attendee row: `Hotel` Select (→ `assignStay`, or `unassignStay` when cleared), `Room #` input (blur → `assignStay`), `Check-in` / `Check-out` date inputs (blur → `assignStay`). Hotel options from `listHotels`. Skeleton while loading; empty state "No confirmed attendees yet." **Download CSV** button (Team, Member, Email, Hotel, Room, Check-in, Check-out) mirroring the travel/visa export. Visible affordances only.
|
||||
- [ ] **Step 3:** `npm run typecheck`. Commit: `feat(logistics): Hotels tab — multi-hotel management + rooming assignment`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Verify + deploy
|
||||
|
||||
- [ ] **Step 1:** `npx vitest run` — full suite green; `npm run typecheck` clean.
|
||||
- [ ] **Step 2:** Stop dev, `rm -rf .next`, `npm run build` — clean.
|
||||
- [ ] **Step 3:** Restart dev on :3001. Dev smoke (admin): Logistics → Hotels tab → add 2 hotels; enroll a team (ADMIN_CONFIRM) so attendees exist; in Rooming, "assign whole team" to hotel A, override one member to hotel B + a room number; verify occupancy counts; check the team-member dashboard shows their assigned hotel + room. Clean up.
|
||||
- [ ] **Step 4:** Merge to `main` (fast-forward if possible), push, watch Gitea build #N succeed, then redeploy on prod (`ssh stefan@89.58.5.223:22022`, `/opt/letsbe/stacks/mopc-portal`, `docker compose pull && docker compose down && docker compose up -d` — **NO -v**), verify migration applied + app healthy + `GET /login` 200.
|
||||
- [ ] **Step 5:** Summarize.
|
||||
|
||||
## Notes
|
||||
- All comms/assignment writes best-effort where they sit inside other mutations (travel email try/catch already in place).
|
||||
- Prod migration is additive + one dropped unique constraint (safe; no `HotelStay` data exists yet).
|
||||
@@ -0,0 +1,467 @@
|
||||
# Wave 1 — Make Logistics Operable: Finalist Enrollment + BLOCKER fixes
|
||||
|
||||
> **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:** Give the grand-finale logistics flow its missing entry points so it is operable end-to-end: a unified "Enroll finalists" action that advances mentoring-round teams into the Grand Final round *and* creates their attendance confirmation in one step, a way to populate the waitlist, safe re-invitation, un-enroll, and the lunch-picker permission fix.
|
||||
|
||||
**Architecture:** Three new `finalist` tRPC procedures (`listEnrollmentCandidates`, `enrollFinalists`, `unenroll`) plus one shared service helper. The enroll mutation composes three existing mechanisms that are currently disconnected: (a) round membership (`ProjectRoundState` in the LIVE_FINAL round — same pattern as `round.advanceProjects`), (b) finalist confirmation (`createPendingConfirmation` service), and (c) optional immediate admin confirmation (same transaction as `finalist.adminConfirm`). A new admin card on the LIVE_FINAL round Overview drives it. One one-line permission fix unblocks the applicant lunch picker.
|
||||
|
||||
**Tech Stack:** Next.js 15 App Router, tRPC 11 (Zod), Prisma 6, shadcn/ui, Vitest 4. No schema migration required (all models already exist).
|
||||
|
||||
**Decisions locked (with Matt, 2026-06-04):**
|
||||
- Enroll is **one unified step**: adds the team to the R7 LIVE_FINAL round (so the Finals Jury sees them) AND creates the finalist confirmation. Un-enroll reverses both.
|
||||
- **Offer both attendee modes per team** at enroll time: `EMAIL` (send the self-confirm link; team lead picks ≤cap attendees) or `ADMIN_CONFIRM` (admin picks attendees now; status goes straight to CONFIRMED, no email).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
**Create:**
|
||||
- `src/server/services/finalist-enrollment.ts` — shared helpers: `resetOrCreatePendingConfirmation()` (re-invite-safe) and `confirmAttendanceInTx()` (extracted from adminConfirm, reused by enroll's ADMIN_CONFIRM mode).
|
||||
- `src/components/admin/grand-finale/finalist-enrollment-card.tsx` — the enrollment UI card on the LIVE_FINAL round Overview.
|
||||
- `src/components/admin/grand-finale/enroll-attendees-dialog.tsx` — per-team attendee picker for ADMIN_CONFIRM mode.
|
||||
- `tests/unit/finalist-enrollment.test.ts` — enrollFinalists (both modes), re-invite safety, unified PRS creation.
|
||||
- `tests/unit/finalist-unenroll.test.ts` — unenroll reverses membership + confirmation.
|
||||
- `tests/unit/lunch-list-dishes-perm.test.ts` — non-admin can read dishes.
|
||||
|
||||
**Modify:**
|
||||
- `src/server/routers/finalist.ts` — add `listEnrollmentCandidates`, `enrollFinalists`, `unenroll`; refactor `adminConfirm`/`selectFinalists` to reuse the new helpers.
|
||||
- `src/server/services/finalist-confirmation.ts` — export the reset-safe helper or re-export from the new module (keep `createPendingConfirmation` intact for waitlist promotion).
|
||||
- `src/server/routers/lunch.ts:42` — `listDishes`: `adminProcedure` → `protectedProcedure`.
|
||||
- `src/app/(admin)/admin/rounds/[roundId]/page.tsx` — render `FinalistEnrollmentCard` in the LIVE_FINAL grand-finale block (currently lines ~1528–1531, above `FinalistSlotsCard`).
|
||||
- `src/components/admin/grand-finale/waitlist-card.tsx` — add an "Add to waitlist" control (wires the existing `finalist.addToWaitlist`, which has no UI today).
|
||||
- `src/components/admin/logistics/confirmations-tab.tsx` — fix the dead-end empty-state copy; add **Un-confirm** and **Re-invite** row actions (surface existing `unconfirm` + new re-invite via `enrollFinalists`).
|
||||
|
||||
---
|
||||
|
||||
## Background facts the executor needs (verified 2026-06-04)
|
||||
|
||||
- **Two disconnected systems.** A project is "in" the Grand Final round iff it has a `ProjectRoundState{ roundId: <LIVE_FINAL>, ... }`. A project is a "confirmed finalist (logistics)" iff it has a `FinalistConfirmation`. `selectFinalists` only ever created the latter and **had zero UI callers**; `addToWaitlist` also had zero UI callers. This wave joins them.
|
||||
- **Rounds for the live edition:** R5 EVALUATION → **R6 MENTORING (active)** → **R7 LIVE_FINAL** → R8 DELIBERATION. Candidates to enroll = projects with a `ProjectRoundState` in the MENTORING round.
|
||||
- **`FinalistConfirmation.projectId` is `@unique`** (`prisma/schema.prisma:2755`). `createPendingConfirmation` does a naive `.create()` → a second invite for a previously DECLINED/EXPIRED project throws Prisma P2002. The new `resetOrCreatePendingConfirmation()` fixes this.
|
||||
- **Cap:** `Program.defaultAttendeeCap` (live value: 3) bounds attendees per team.
|
||||
- **Reference code to mirror:**
|
||||
- PRS creation in target round: `src/server/routers/round.ts:545-551` (`createMany … skipDuplicates`).
|
||||
- Admin confirm transaction (attendees + visa rows + lunch picks): `src/server/routers/finalist.ts:492-523`.
|
||||
- Pending-confirmation + token + email: `src/server/services/finalist-confirmation.ts:12-42` and `src/server/routers/finalist.ts:191-219`.
|
||||
- Candidate data source: `src/server/routers/round.ts:323` (`listMentoringProjects`).
|
||||
- Test harness: `tests/unit/finalist-admin-confirm.test.ts` (factories, `createCaller`, cleanup pattern).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Unblock the applicant lunch picker (BLOCKER)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/lunch.ts:42`
|
||||
- Test: `tests/unit/lunch-list-dishes-perm.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** — a non-admin (APPLICANT) can call `listDishes`.
|
||||
|
||||
```ts
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { prisma, createCaller } from '../setup'
|
||||
import { createTestUser, createTestProgram, cleanupTestData, uid } from '../helpers'
|
||||
import { lunchRouter } from '../../src/server/routers/lunch'
|
||||
|
||||
describe('lunch.listDishes permission', () => {
|
||||
const programIds: string[] = []
|
||||
const userIds: string[] = []
|
||||
afterAll(async () => {
|
||||
for (const id of programIds) {
|
||||
await prisma.dish.deleteMany({ where: { lunchEvent: { programId: id } } })
|
||||
await prisma.lunchEvent.deleteMany({ where: { programId: id } })
|
||||
await cleanupTestData(id, [])
|
||||
}
|
||||
if (userIds.length) await prisma.user.deleteMany({ where: { id: { in: userIds } } })
|
||||
})
|
||||
|
||||
it('lets a non-admin (APPLICANT) read the dish list', async () => {
|
||||
const program = await createTestProgram({ name: `dish-perm-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const event = await prisma.lunchEvent.create({
|
||||
data: { programId: program.id, enabled: true },
|
||||
})
|
||||
await prisma.dish.create({ data: { lunchEventId: event.id, name: 'Sea bass', sortOrder: 0 } })
|
||||
|
||||
const applicant = await createTestUser('APPLICANT')
|
||||
userIds.push(applicant.id)
|
||||
const caller = createCaller(lunchRouter, {
|
||||
id: applicant.id, email: applicant.email, role: 'APPLICANT',
|
||||
})
|
||||
const dishes = await caller.listDishes({ lunchEventId: event.id })
|
||||
expect(dishes).toHaveLength(1)
|
||||
expect(dishes[0].name).toBe('Sea bass')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
> Note: confirm the exact `Dish` field names (`lunchEventId`, `name`, `sortOrder`) against `prisma/schema.prisma` before running; adjust the factory data if they differ.
|
||||
|
||||
- [ ] **Step 2: Run it, verify it fails** — `npx vitest run tests/unit/lunch-list-dishes-perm.test.ts`. Expected: FAIL with an `UNAUTHORIZED` TRPCError (APPLICANT blocked by `adminProcedure`).
|
||||
|
||||
- [ ] **Step 3: Change the procedure** — in `src/server/routers/lunch.ts:42`, change `listDishes: adminProcedure` to `listDishes: protectedProcedure`. Ensure `protectedProcedure` is imported in that file (it is used elsewhere in the router; if not, add it to the import from `../trpc`).
|
||||
|
||||
- [ ] **Step 4: Run it, verify it passes** — same command. Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit** — `git add -A && git commit -m "fix(lunch): allow non-admins to read dish list (unblocks applicant picker)"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Re-invite-safe confirmation helper (fixes the P2002 dead-end)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/services/finalist-enrollment.ts`
|
||||
- Test: covered indirectly in Task 3; add a focused unit here.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (add to `tests/unit/finalist-enrollment.test.ts`, created here):
|
||||
|
||||
```ts
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { prisma } from '../setup'
|
||||
import { createTestProgram, createTestProject, cleanupTestData, uid } from '../helpers'
|
||||
import { resetOrCreatePendingConfirmation } from '../../src/server/services/finalist-enrollment'
|
||||
|
||||
describe('resetOrCreatePendingConfirmation', () => {
|
||||
const programIds: string[] = []
|
||||
afterAll(async () => {
|
||||
for (const id of programIds) {
|
||||
await prisma.attendingMember.deleteMany({ where: { confirmation: { project: { programId: id } } } })
|
||||
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId: id } } })
|
||||
await cleanupTestData(id, [])
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a fresh PENDING row when none exists', async () => {
|
||||
const program = await createTestProgram({ name: `reinvite-new-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
||||
const res = await resetOrCreatePendingConfirmation(prisma, {
|
||||
projectId: project.id, category: 'STARTUP', windowHours: 24,
|
||||
})
|
||||
const row = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { id: res.id } })
|
||||
expect(row.status).toBe('PENDING')
|
||||
expect(res.alreadyConfirmed).toBe(false)
|
||||
})
|
||||
|
||||
it('resets a DECLINED row to a fresh PENDING (no unique-constraint crash)', async () => {
|
||||
const program = await createTestProgram({ name: `reinvite-declined-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
||||
await prisma.finalistConfirmation.create({
|
||||
data: { projectId: project.id, category: 'STARTUP', status: 'DECLINED',
|
||||
deadline: new Date(Date.now() - 1000), token: `tok_${uid()}`, declinedAt: new Date(),
|
||||
declineReason: 'busy' },
|
||||
})
|
||||
const res = await resetOrCreatePendingConfirmation(prisma, {
|
||||
projectId: project.id, category: 'STARTUP', windowHours: 24,
|
||||
})
|
||||
const row = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { id: res.id } })
|
||||
expect(row.status).toBe('PENDING')
|
||||
expect(row.declinedAt).toBeNull()
|
||||
expect(row.declineReason).toBeNull()
|
||||
expect(res.alreadyConfirmed).toBe(false)
|
||||
})
|
||||
|
||||
it('is a no-op flagged alreadyConfirmed when row is CONFIRMED', async () => {
|
||||
const program = await createTestProgram({ name: `reinvite-confirmed-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
||||
await prisma.finalistConfirmation.create({
|
||||
data: { projectId: project.id, category: 'STARTUP', status: 'CONFIRMED',
|
||||
deadline: new Date(Date.now() + 1000), token: `tok_${uid()}`, confirmedAt: new Date() },
|
||||
})
|
||||
const res = await resetOrCreatePendingConfirmation(prisma, {
|
||||
projectId: project.id, category: 'STARTUP', windowHours: 24,
|
||||
})
|
||||
expect(res.alreadyConfirmed).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, verify it fails** — `npx vitest run tests/unit/finalist-enrollment.test.ts`. Expected: FAIL (module/function not found).
|
||||
|
||||
- [ ] **Step 3: Implement the helper** — create `src/server/services/finalist-enrollment.ts`:
|
||||
|
||||
```ts
|
||||
import type { CompetitionCategory, Prisma, PrismaClient } from '@prisma/client'
|
||||
import { signFinalistToken } from '@/lib/finalist-token'
|
||||
|
||||
type TxClient = PrismaClient | Prisma.TransactionClient
|
||||
|
||||
/**
|
||||
* Re-invite-safe variant of createPendingConfirmation. If a confirmation row
|
||||
* already exists for the project (projectId is @unique), reset any
|
||||
* non-CONFIRMED row back to a fresh PENDING with a new token/deadline and
|
||||
* clear stale attendee rows; report CONFIRMED rows as a no-op so callers can
|
||||
* skip them. Returns the row id + token + deadline for the email step.
|
||||
*/
|
||||
export async function resetOrCreatePendingConfirmation(
|
||||
prisma: TxClient,
|
||||
args: { projectId: string; category: CompetitionCategory; windowHours: number },
|
||||
): Promise<{ id: string; token: string; deadline: Date; alreadyConfirmed: boolean }> {
|
||||
const deadline = new Date(Date.now() + args.windowHours * 3_600_000)
|
||||
const existing = await prisma.finalistConfirmation.findUnique({
|
||||
where: { projectId: args.projectId },
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
|
||||
if (existing?.status === 'CONFIRMED') {
|
||||
return { id: existing.id, token: '', deadline, alreadyConfirmed: true }
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const token = signFinalistToken({
|
||||
confirmationId: existing.id,
|
||||
exp: Math.floor(deadline.getTime() / 1000),
|
||||
})
|
||||
// Clear any attendee rows from a prior cycle (cascade-deletes flight/visa/lunch).
|
||||
await prisma.attendingMember.deleteMany({ where: { confirmationId: existing.id } })
|
||||
await prisma.finalistConfirmation.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
category: args.category,
|
||||
status: 'PENDING',
|
||||
deadline,
|
||||
token,
|
||||
confirmedAt: null,
|
||||
declinedAt: null,
|
||||
declineReason: null,
|
||||
expiredAt: null,
|
||||
},
|
||||
})
|
||||
return { id: existing.id, token, deadline, alreadyConfirmed: false }
|
||||
}
|
||||
|
||||
const id = `cmfc_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
|
||||
const token = signFinalistToken({
|
||||
confirmationId: id,
|
||||
exp: Math.floor(deadline.getTime() / 1000),
|
||||
})
|
||||
await prisma.finalistConfirmation.create({
|
||||
data: {
|
||||
id,
|
||||
projectId: args.projectId,
|
||||
category: args.category,
|
||||
status: 'PENDING',
|
||||
deadline,
|
||||
token,
|
||||
},
|
||||
})
|
||||
return { id, token, deadline, alreadyConfirmed: false }
|
||||
}
|
||||
```
|
||||
|
||||
> Confirm `AttendingMember`→`FlightDetail`/`VisaApplication`/`MemberLunchPick` are `onDelete: Cascade` (they are, per schema 2789+); the `deleteMany` then cleans dependents. If `signFinalistToken`'s import path differs, match `src/server/services/finalist-confirmation.ts:2`.
|
||||
|
||||
- [ ] **Step 4: Run it, verify it passes** — same command. Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat(finalist): re-invite-safe confirmation reset helper"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `finalist.enrollFinalists` — the unified entry point
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/finalist.ts` (add procedure; import the helper)
|
||||
- Test: `tests/unit/finalist-enrollment.test.ts` (extend)
|
||||
|
||||
**Procedure contract:**
|
||||
|
||||
```ts
|
||||
enrollFinalists: adminProcedure
|
||||
.input(z.object({
|
||||
programId: z.string(),
|
||||
roundId: z.string(), // the LIVE_FINAL round
|
||||
enrollments: z.array(z.object({
|
||||
projectId: z.string(),
|
||||
mode: z.enum(['EMAIL', 'ADMIN_CONFIRM']),
|
||||
attendingUserIds: z.array(z.string()).optional(), // required for ADMIN_CONFIRM
|
||||
visaFlags: z.record(z.string(), z.boolean()).optional(),
|
||||
})).min(1),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => { /* see steps */ })
|
||||
```
|
||||
|
||||
Behavior per enrollment:
|
||||
1. Validate the project is in `programId` and read its `competitionCategory`, `defaultAttendeeCap`, team members + LEAD email.
|
||||
2. **Round membership:** `projectRoundState.createMany({ data: [{ projectId, roundId }], skipDuplicates: true })` (mirror `round.ts:545`).
|
||||
3. **Confirmation:** `resetOrCreatePendingConfirmation()`. If `alreadyConfirmed`, record `skipped: 'ALREADY_CONFIRMED'` and continue.
|
||||
4. If `mode === 'EMAIL'`: send `sendFinalistConfirmationEmail(lead.email, lead.name, project.title, deadline, confirmUrl)` inside a try/catch (never throw in the loop — mirror `finalist.ts:213`).
|
||||
5. If `mode === 'ADMIN_CONFIRM'`: validate `attendingUserIds` (non-empty, ≤ cap, all team members) then run the confirm transaction (attendees + visa rows + lunch picks) exactly as `finalist.ts:492-523`. No email.
|
||||
6. Audit `FINALIST_ENROLL` with `{ projectId, mode }`.
|
||||
7. Return `{ enrolled, emailed, adminConfirmed, skipped: [...] }`.
|
||||
|
||||
- [ ] **Step 1: Write failing tests** (extend `finalist-enrollment.test.ts`). Build an admin caller via `createCaller(finalistRouter, {…SUPER_ADMIN})`, a program (`defaultAttendeeCap: 3`), a competition with a `LIVE_FINAL` round (`createTestRound(comp.id, { roundType: 'LIVE_FINAL', sortOrder: 99, configJson: { confirmationWindowHours: 24 } })`) and a MENTORING round with a `ProjectRoundState` for the project. Assert:
|
||||
|
||||
```ts
|
||||
// EMAIL mode
|
||||
it('EMAIL mode: creates PRS in LIVE_FINAL + PENDING confirmation, no attendees', async () => {
|
||||
// ... call enrollFinalists with one { projectId, mode: 'EMAIL' }
|
||||
const prs = await prisma.projectRoundState.findFirst({ where: { projectId, roundId: liveFinalId } })
|
||||
expect(prs).not.toBeNull()
|
||||
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { projectId } })
|
||||
expect(conf.status).toBe('PENDING')
|
||||
expect(await prisma.attendingMember.count({ where: { confirmationId: conf.id } })).toBe(0)
|
||||
})
|
||||
|
||||
// ADMIN_CONFIRM mode
|
||||
it('ADMIN_CONFIRM mode: CONFIRMED with attendee + visa + lunch rows', async () => {
|
||||
// ... call with { projectId, mode: 'ADMIN_CONFIRM', attendingUserIds: [lead.id, member.id], visaFlags: { [member.id]: true } }
|
||||
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { projectId } })
|
||||
expect(conf.status).toBe('CONFIRMED')
|
||||
expect(await prisma.attendingMember.count({ where: { confirmationId: conf.id } })).toBe(2)
|
||||
expect(await prisma.visaApplication.count({ where: { attendingMember: { confirmationId: conf.id } } })).toBe(1)
|
||||
})
|
||||
|
||||
// idempotent membership + re-invite
|
||||
it('re-enrolling a DECLINED project resets it without crashing and keeps one PRS row', async () => {
|
||||
// pre-create DECLINED confirmation + a PRS; enroll EMAIL again
|
||||
// expect status PENDING and exactly one PRS row for (projectId, liveFinalId)
|
||||
})
|
||||
|
||||
// ADMIN_CONFIRM over cap rejects
|
||||
it('ADMIN_CONFIRM rejects when attendees exceed cap', async () => {
|
||||
await expect(/* 4 attendees with cap 3 */).rejects.toThrow(/cap/i)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run, verify fail** — `npx vitest run tests/unit/finalist-enrollment.test.ts`. Expected: FAIL (`enrollFinalists` not a function).
|
||||
|
||||
- [ ] **Step 3: Implement** the procedure in `finalist.ts`. Reuse: import `resetOrCreatePendingConfirmation` from `../services/finalist-enrollment`; reuse `sendFinalistConfirmationEmail`, `ensureLunchPickForAttendingMember`, `logAudit`, `signFinalistToken` already imported. For the ADMIN_CONFIRM transaction body, copy the exact `$transaction` block from `adminConfirm` (`finalist.ts:492-523`). Resolve `windowHours` from the LIVE_FINAL round's `configJson.confirmationWindowHours ?? 24` (mirror `finalist.ts:145`). Build `confirmUrl` from `NEXTAUTH_URL` (mirror `finalist.ts:191-204`).
|
||||
|
||||
- [ ] **Step 4: Run, verify pass** — same command. Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Refactor for DRY (optional within budget)** — extract the shared confirm transaction into `confirmAttendanceInTx(tx, { confirmationId, attendingUserIds, visaFlags })` in `finalist-enrollment.ts` and call it from both `adminConfirm` and `enrollFinalists`. Re-run `npx vitest run tests/unit/finalist-admin-confirm.test.ts tests/unit/finalist-enrollment.test.ts`. Expected: PASS both.
|
||||
|
||||
- [ ] **Step 6: Commit** — `git commit -am "feat(finalist): unified enrollFinalists (round membership + confirmation + email/admin-confirm)"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `finalist.unenroll` — reverse membership + confirmation
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/finalist.ts`
|
||||
- Test: `tests/unit/finalist-unenroll.test.ts`
|
||||
|
||||
**Contract:** `unenroll({ projectId, roundId })` →
|
||||
1. Delete the `FinalistConfirmation` for the project (cascade removes AttendingMember/FlightDetail/VisaApplication/lunch picks).
|
||||
2. Delete the LIVE_FINAL `ProjectRoundState` (`deleteMany({ where: { projectId, roundId } })`).
|
||||
3. Audit `FINALIST_UNENROLL`.
|
||||
4. Return `{ ok: true }`. (Mentor assignments are tied to the MENTORING round and are intentionally left untouched.)
|
||||
|
||||
- [ ] **Step 1: Failing test** — enroll (ADMIN_CONFIRM) a project, then `unenroll`; assert no confirmation, no attendees, no LIVE_FINAL PRS, and an audit row exists.
|
||||
- [ ] **Step 2: Run, verify fail.**
|
||||
- [ ] **Step 3: Implement** the procedure.
|
||||
- [ ] **Step 4: Run, verify pass** — `npx vitest run tests/unit/finalist-unenroll.test.ts`.
|
||||
- [ ] **Step 5: Commit** — `git commit -am "feat(finalist): unenroll reverses round membership + confirmation"`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `finalist.listEnrollmentCandidates` — data for the UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/finalist.ts`
|
||||
- Test: `tests/unit/finalist-enrollment.test.ts` (extend)
|
||||
|
||||
**Contract:** `listEnrollmentCandidates({ programId })` resolves the program's competition, finds the MENTORING round and the LIVE_FINAL round, and returns:
|
||||
|
||||
```ts
|
||||
{
|
||||
liveFinalRoundId: string | null,
|
||||
attendeeCap: number,
|
||||
categories: Array<{
|
||||
category: CompetitionCategory,
|
||||
quota: number | null,
|
||||
confirmedCount: number,
|
||||
pendingCount: number,
|
||||
candidates: Array<{
|
||||
projectId: string,
|
||||
title: string,
|
||||
teamName: string | null,
|
||||
country: string | null,
|
||||
inLiveFinal: boolean, // has a LIVE_FINAL ProjectRoundState
|
||||
confirmationStatus: FinalistConfirmationStatus | null,
|
||||
teamMembers: Array<{ userId: string, name: string | null, role: 'LEAD' | 'MEMBER', email: string }>,
|
||||
}>,
|
||||
}>,
|
||||
}
|
||||
```
|
||||
|
||||
Source candidates from `ProjectRoundState` in the MENTORING round (mirror `round.ts:335`), join `project.finalistConfirmation.status`, a `ProjectRoundState` existence check against the LIVE_FINAL round for `inLiveFinal`, and `project.teamMembers` (for the ADMIN_CONFIRM picker). Group by `competitionCategory`; merge per-category `FinalistSlotQuota` + confirmed/pending counts (reuse the count logic from `finalist.listCategoryCounts`).
|
||||
|
||||
- [ ] **Step 1: Failing test** — set up a MENTORING round with one STARTUP project (PRS), assert the project appears under the STARTUP category with `inLiveFinal: false`, `confirmationStatus: null`, and its team members listed.
|
||||
- [ ] **Step 2: Run, verify fail.**
|
||||
- [ ] **Step 3: Implement.**
|
||||
- [ ] **Step 4: Run, verify pass.**
|
||||
- [ ] **Step 5: Commit** — `git commit -am "feat(finalist): listEnrollmentCandidates query for enrollment UI"`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Enrollment UI card on the LIVE_FINAL round page
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/admin/grand-finale/finalist-enrollment-card.tsx`
|
||||
- Create: `src/components/admin/grand-finale/enroll-attendees-dialog.tsx`
|
||||
- Modify: `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (render the card in the grand-finale block, ~line 1528, above `FinalistSlotsCard`)
|
||||
|
||||
**Card UX (follow the visual pattern of `finalist-slots-card.tsx` / `waitlist-card.tsx`):**
|
||||
- `trpc.finalist.listEnrollmentCandidates.useQuery({ programId })`. Loading → Skeleton; empty → "No mentoring-round teams to enroll yet."
|
||||
- Group candidates by category with a header showing `confirmed/quota` (e.g. "Startup — 2/8 confirmed, 1 pending").
|
||||
- Each candidate row: checkbox, title + teamName + country, and a status badge (`Not enrolled` / `In round` / `Pending` / `Confirmed` / `Declined`). Confirmed/declined rows show an **Un-enroll** button instead of a checkbox.
|
||||
- Per selected row, a small mode toggle: **Email team** (default) or **Set attendees now**. Choosing "Set attendees now" opens `EnrollAttendeesDialog` (a ≤cap member multi-select with per-member "needs visa" checkbox) and stashes the chosen `attendingUserIds`/`visaFlags` on that row.
|
||||
- Footer: **Enroll selected** and **Enroll all eligible** (eligible = not already CONFIRMED). Calls `trpc.finalist.enrollFinalists.useMutation`, then `utils.finalist.listEnrollmentCandidates.invalidate()` + `utils.logistics.listConfirmations.invalidate()`. Toast the `{ enrolled, emailed, adminConfirmed, skipped }` summary.
|
||||
- Un-enroll buttons call `trpc.finalist.unenroll` behind an `AlertDialog` confirm ("This removes them from the Grand Final round and deletes their attendance record. Continue?").
|
||||
|
||||
> Use shadcn `AlertDialog` for confirms (no native `confirm()`), per house style and the no-keyboard-shortcuts preference (visible affordances only).
|
||||
|
||||
- [ ] **Step 1:** Build `enroll-attendees-dialog.tsx` (props: `members`, `cap`, `onConfirm(attendingUserIds, visaFlags)`).
|
||||
- [ ] **Step 2:** Build `finalist-enrollment-card.tsx` per the UX above.
|
||||
- [ ] **Step 3:** Render `<FinalistEnrollmentCard programId={programId} roundId={round.id} />` in the LIVE_FINAL grand-finale block in the round page.
|
||||
- [ ] **Step 4: Typecheck** — `npm run typecheck`. Expected: clean.
|
||||
- [ ] **Step 5: Commit** — `git commit -am "feat(grand-finale): finalist enrollment card on LIVE_FINAL round page"`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Waitlist populate UI + confirmations-tab dead-end fixes
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/admin/grand-finale/waitlist-card.tsx`
|
||||
- Modify: `src/components/admin/logistics/confirmations-tab.tsx`
|
||||
|
||||
- [ ] **Step 1: Add-to-waitlist control** in `waitlist-card.tsx` — a category + project picker (project options = enrollment candidates not already confirmed/waitlisted) that calls the existing `trpc.finalist.addToWaitlist` mutation (which had no UI). Invalidate `listWaitlist` on success. Confirm `addToWaitlist`'s exact input shape at `finalist.ts:629` before wiring.
|
||||
|
||||
- [ ] **Step 2: Fix the dead-end copy** in `confirmations-tab.tsx:127` — replace "Use the grand-finale round page to send confirmations." with "Enroll finalists from the Grand Final round's Overview tab to start confirmations." (Or, if scope allows, render an inline `<Link>` to the round page.)
|
||||
|
||||
- [ ] **Step 3: Add row actions** to `confirmations-tab.tsx` (currently only PENDING rows show Confirm/Decline; all others show "—"):
|
||||
- CONFIRMED rows → **Un-confirm** button calling existing `trpc.finalist.unconfirm` (behind `AlertDialog`).
|
||||
- DECLINED / EXPIRED rows → **Re-invite** button calling `trpc.finalist.enrollFinalists` with `{ projectId, mode: 'EMAIL', roundId: liveFinalRoundId }` (re-invite-safe via Task 2). Needs the LIVE_FINAL roundId — fetch via `listEnrollmentCandidates` or add it to `listConfirmations`' payload.
|
||||
|
||||
- [ ] **Step 4: Typecheck** — `npm run typecheck`. Expected: clean.
|
||||
- [ ] **Step 5: Commit** — `git commit -am "feat(logistics): waitlist populate UI + confirmations-tab un-confirm/re-invite actions"`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Full verification
|
||||
|
||||
- [ ] **Step 1: Run the whole finalist/lunch/logistics suite** — `npx vitest run tests/unit/finalist-enrollment.test.ts tests/unit/finalist-unenroll.test.ts tests/unit/lunch-list-dishes-perm.test.ts tests/unit/finalist-admin-confirm.test.ts tests/unit/finalist-quotas.test.ts tests/unit/finalist-confirmation.test.ts`. Expected: all PASS (regression check on the refactor).
|
||||
- [ ] **Step 2: Typecheck + build** — `npm run typecheck && npm run build`. Expected: clean (build is required before any push, per CLAUDE.md).
|
||||
- [ ] **Step 3: Dev smoke (Playwright, server already on :3001 as super-admin)** — verify the end-to-end the audit proved was impossible:
|
||||
1. Mentoring round (R6) → ensure a project has a `ProjectRoundState` (advance one in if needed).
|
||||
2. Grand Final round (R7) Overview → the new Enrollment card lists it; enroll it in EMAIL mode.
|
||||
3. `/admin/logistics` → Confirmations tab now shows a PENDING row (no longer the dead-end empty state).
|
||||
4. Enroll a second team in ADMIN_CONFIRM mode with 2 attendees → Confirmations shows CONFIRMED with attendee count 2; Travel/Visas tabs now list those attendees.
|
||||
5. Confirm the LIVE_FINAL round's Projects tab now shows the enrolled teams (jury can see them).
|
||||
- [ ] **Step 4: Final commit / branch** — ensure work is on a feature branch (not `main`); summarize for review.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (coverage vs. the 4 BLOCKERs)
|
||||
|
||||
- BLOCKER 1 (no select-finalists UI) → Tasks 3 + 6 (`enrollFinalists` + enrollment card).
|
||||
- BLOCKER 2 (no waitlist populate UI) → Task 7 step 1.
|
||||
- BLOCKER 3 (lunch picker broken) → Task 1.
|
||||
- BLOCKER 4 (re-invite crash) → Task 2 (`resetOrCreatePendingConfirmation`), surfaced via Task 7 step 3 (Re-invite action).
|
||||
- Unified enroll + both attendee modes (locked decisions) → Task 3.
|
||||
- Un-confirm dead-end (HIGH) → Task 7 step 3.
|
||||
|
||||
**Out of scope for Wave 1 (deferred to later waves):** all the new transactional emails/notifications and reminders (Wave 2), the Email Templates tab (Wave 3), team-facing "My Logistics" + timezone/validation/export UX fixes (Wave 4). The only email this wave sends is the existing `sendFinalistConfirmationEmail` (now reachable via EMAIL-mode enroll and Re-invite).
|
||||
186
docs/superpowers/plans/2026-06-04-wave2-logistics-comms.md
Normal file
186
docs/superpowers/plans/2026-06-04-wave2-logistics-comms.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Wave 2 — Close the Logistics Email/Notification Void
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Make logistics actually *communicate*. Today logistics fires exactly one automatic email (`sendFinalistConfirmationEmail`) and creates zero in-app notifications. This wave adds confirmation reminders, admin alerts, withdrawal emails, attendee travel/visa emails, and fixes the lunch reminder/recap comms — all routed through the existing notification pipeline so admins keep on/off control.
|
||||
|
||||
**Architecture:** Reuse the established comms pipeline (decision: reuse, not a new system). `createNotification({ userId, type, title, message, linkUrl, metadata })` writes an in-app row AND conditionally sends a branded email when a `NotificationEmailSetting` row exists for that type with `sendEmail=true` and the user's `notificationPreference` allows email. New notification types get registered in `NotificationTypes`, optionally given a custom branded template in `NOTIFICATION_EMAIL_TEMPLATES` (team/attendee-facing) or left to fall back to the generic branded template (admin alerts), and seeded in `prisma/seed-notification-settings.ts` (which runs on every deploy + dev).
|
||||
|
||||
**Tech Stack:** Next.js 15, tRPC 11, Prisma 6, Vitest 4. One small schema migration (`reminderSentAt`).
|
||||
|
||||
**Key infra facts (verified 2026-06-04):**
|
||||
- `createNotification(params)` — `src/server/services/in-app-notification.ts:185`. Email leg gated by `NotificationEmailSetting` (no row OR `sendEmail=false` → no email) + user `notificationPreference ∈ {EMAIL, BOTH}`.
|
||||
- Helpers: `notifyAdmins({type,title,message,linkUrl,metadata})` (`:324`), `notifyProjectTeam({projectId,...})` (`:374`), `createBulkNotifications` (`:263`).
|
||||
- Email body for a type comes from `NOTIFICATION_EMAIL_TEMPLATES[type]` (`src/lib/email.ts:2196`); missing entry → generic branded fallback `getNotificationEmailTemplate` (`:2635`). `sendStyledNotificationEmail` (`:2400`) is the sender.
|
||||
- Settings seeded idempotently in `prisma/seed-notification-settings.ts` (row shape `{ notificationType, category, label, description, sendEmail }`), run on every container start via `docker/docker-entrypoint.sh:72`.
|
||||
- Dead stub types already in registry (do NOT reuse; add explicit new ones): `MENTEE_FINALIST`, `EVENT_INVITATION`, `FINALISTS_ANNOUNCED`.
|
||||
- Recipients: admins via `roles: { has: 'SUPER_ADMIN' }` / `'PROGRAM_ADMIN'` + `status:'ACTIVE'`; team lead via `teamMembers where role:'LEAD'`; attendee email via `AttendingMember.user`.
|
||||
- `FinalistConfirmation` has NO `reminderSentAt` yet (Task 1 adds it). Lunch cron attendee-filter bug confirmed (Task 7).
|
||||
|
||||
**New notification type constants (add all to `NotificationTypes`):**
|
||||
| Constant | Audience | Email template | Seed `sendEmail` |
|
||||
|---|---|---|---|
|
||||
| `FINALIST_CONFIRMED` | admins | fallback | true |
|
||||
| `FINALIST_DECLINED` | admins | fallback | true |
|
||||
| `FINALIST_EXPIRED` | admins | fallback | true |
|
||||
| `FINALIST_WAITLIST_PROMOTED` | admins | fallback | true |
|
||||
| `FINALIST_REMINDER` | team lead | custom | true |
|
||||
| `FINALIST_WITHDRAWN` | team | custom | true |
|
||||
| `TRAVEL_CONFIRMED` | attendee | custom | true |
|
||||
| `VISA_STATUS_UPDATE` | attendee | custom | true |
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
**Create:**
|
||||
- `prisma/migrations/<ts>_add_finalist_reminder_sent_at/migration.sql`
|
||||
- `tests/unit/finalist-comms.test.ts` — admin alerts + withdrawal notifications fire
|
||||
- `tests/unit/finalist-reminders.test.ts` — reminder cron sends + stamps + idempotent
|
||||
- `tests/unit/logistics-comms.test.ts` — flight-confirmed + visa-status emails fire
|
||||
- `tests/unit/lunch-reminder-filter.test.ts` — cron picks up attendees with no pick row
|
||||
|
||||
**Modify:**
|
||||
- `prisma/schema.prisma` — `FinalistConfirmation.reminderSentAt DateTime?`
|
||||
- `src/server/services/in-app-notification.ts` — add 8 `NotificationTypes` constants (+ icons/priorities optional)
|
||||
- `src/lib/email.ts` — 4 custom templates + register in `NOTIFICATION_EMAIL_TEMPLATES`
|
||||
- `prisma/seed-notification-settings.ts` — 8 new setting rows (category `logistics`)
|
||||
- `src/server/routers/finalist.ts` — admin alerts in `confirm`/`decline`/`adminDecline`/`manualPromote`; withdrawal in `adminDecline`/`unconfirm`/`unenroll`
|
||||
- `src/server/services/finalist-confirmation.ts` — admin alert in `expirePendingPastDeadline` + `promoteNextWaitlistEntry`; new `sendDueConfirmationReminders`
|
||||
- `src/app/api/cron/finalist-confirmations/route.ts` — call `sendDueConfirmationReminders`
|
||||
- `src/server/routers/logistics.ts` — emails in `setFlightStatus`(→CONFIRMED) + `updateVisaApplication`(status transitions)
|
||||
- `src/app/api/cron/lunch-reminders/route.ts` — fix attendee OR-filter
|
||||
- `src/server/routers/lunch.ts` — surface recap send failure; add `sendReminders` mutation
|
||||
- `src/components/admin/logistics/lunch-recap-actions.tsx` — "Send reminders now" button
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Schema — `reminderSentAt`
|
||||
|
||||
**Files:** `prisma/schema.prisma`, migration.
|
||||
|
||||
- [ ] **Step 1:** Add to `FinalistConfirmation`: `reminderSentAt DateTime?` (near `confirmedAt`/`expiredAt`).
|
||||
- [ ] **Step 2:** `npx prisma migrate dev --name add_finalist_reminder_sent_at`. Expected: migration created + applied, client regenerated.
|
||||
- [ ] **Step 3:** `npm run typecheck` — clean.
|
||||
- [ ] **Step 4: Commit** — `git add prisma/ && git commit -m "feat(finalist): add reminderSentAt for confirmation reminders"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Notification types, templates, and settings
|
||||
|
||||
**Files:** `src/server/services/in-app-notification.ts`, `src/lib/email.ts`, `prisma/seed-notification-settings.ts`.
|
||||
|
||||
- [ ] **Step 1:** Add the 8 constants to the `NotificationTypes` object (`in-app-notification.ts:15`), grouped under a `// Logistics` comment, e.g. `FINALIST_CONFIRMED: 'FINALIST_CONFIRMED',` … through `VISA_STATUS_UPDATE: 'VISA_STATUS_UPDATE',`. Optionally add icons/priorities (e.g. `FINALIST_EXPIRED: 'urgent'`, `VISA_STATUS_UPDATE: 'high'`).
|
||||
|
||||
- [ ] **Step 2:** Add 4 custom branded templates in `src/lib/email.ts` (mirror an existing private template that uses `getEmailWrapper` + `sectionTitle`/`paragraph`/`ctaButton`/`infoBox`). Each returns `{ subject, html, text }`:
|
||||
- `getFinalistReminderTemplate(name, projectTitle, deadline, confirmUrl)` — "Reminder: confirm your grand-finale attendance by <formatted deadline>". Format the deadline human-readably (`toLocaleString('en-GB', { timeZone: 'Europe/Paris', dateStyle:'full', timeStyle:'short' })`).
|
||||
- `getFinalistWithdrawnTemplate(name, projectTitle, reason?)` — "Your grand-finale slot has been withdrawn".
|
||||
- `getTravelConfirmedTemplate(name, projectTitle, flight, hotel?)` — itinerary (arrival/departure flight no + airport + formatted times) and, if `hotel` provided, hotel name/address/link.
|
||||
- `getVisaStatusTemplate(name, projectTitle, status, note?)` — status-specific copy for `INVITATION_SENT` / `APPOINTMENT_BOOKED` / `GRANTED` / `DENIED`.
|
||||
Then register them in `NOTIFICATION_EMAIL_TEMPLATES` (`:2196`) keyed by the type string, reading fields from `ctx.metadata` (e.g. `FINALIST_REMINDER: (ctx) => getFinalistReminderTemplate(ctx.name||'', ctx.metadata?.projectTitle as string, new Date(ctx.metadata?.deadline as string), ctx.linkUrl||'')`). Admin-alert types are intentionally NOT registered (they use the generic fallback).
|
||||
|
||||
- [ ] **Step 3:** Add 8 rows to `NOTIFICATION_EMAIL_SETTINGS` in `prisma/seed-notification-settings.ts` (category `'logistics'`), all `sendEmail: true`, with clear `label`/`description`.
|
||||
|
||||
- [ ] **Step 4:** Apply to the dev DB: `npx tsx prisma/seed-notification-settings.ts`. Expected: upserts succeed (idempotent).
|
||||
|
||||
- [ ] **Step 5:** `npm run typecheck` — clean.
|
||||
- [ ] **Step 6: Commit** — `git commit -am "feat(comms): logistics notification types, templates, and email settings"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Admin alerts on confirmation lifecycle
|
||||
|
||||
**Files:** `src/server/routers/finalist.ts`, `src/server/services/finalist-confirmation.ts`; test `tests/unit/finalist-comms.test.ts`.
|
||||
|
||||
For each event, call `notifyAdmins({ type, title, message, linkUrl: '/admin/logistics', metadata: { projectId, projectTitle, category } })`. Wrap in try/catch — comms must never throw inside the mutation (mirror the round-notification rule in CLAUDE.md).
|
||||
|
||||
- Team confirms (`finalist.confirm`, after the transaction) → `FINALIST_CONFIRMED`.
|
||||
- Team declines (`finalist.decline`) and admin declines (`finalist.adminDecline`) → `FINALIST_DECLINED`.
|
||||
- Cron expiry (`expirePendingPastDeadline`, per expired row) → `FINALIST_EXPIRED`.
|
||||
- Waitlist promotion (`promoteNextWaitlistEntry` and `manualPromote`) → `FINALIST_WAITLIST_PROMOTED`.
|
||||
|
||||
- [ ] **Step 1: Failing tests** — for `confirm`, `decline`, and `expirePendingPastDeadline`, assert an `InAppNotification` row with the right `type` is created for an admin user after the action (set up a `SUPER_ADMIN` with `status:'ACTIVE'`). Use the existing finalist test setup patterns.
|
||||
- [ ] **Step 2:** Run → fail.
|
||||
- [ ] **Step 3:** Implement the `notifyAdmins` calls. Import from `../services/in-app-notification` (note: `finalist-confirmation.ts` is a service — import directly).
|
||||
- [ ] **Step 4:** Run → pass; re-run `tests/unit/finalist-confirmation.test.ts` for no regressions.
|
||||
- [ ] **Step 5: Commit** — `git commit -am "feat(finalist): admin alerts on confirm/decline/expire/promote"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Withdrawal emails to teams
|
||||
|
||||
**Files:** `src/server/routers/finalist.ts`; test `tests/unit/finalist-comms.test.ts`.
|
||||
|
||||
When a team's slot is withdrawn by an admin, notify the team lead with `FINALIST_WITHDRAWN` (in-app + email). Events: `adminDecline`, `unconfirm` (CONFIRMED→SUPERSEDED), and `unenroll` when a CONFIRMED confirmation existed.
|
||||
|
||||
- [ ] **Step 1: Failing test** — after `adminDecline`, assert a `FINALIST_WITHDRAWN` `InAppNotification` exists for the team lead's userId.
|
||||
- [ ] **Step 2:** Run → fail.
|
||||
- [ ] **Step 3:** Implement: resolve the lead (`teamMembers where role:'LEAD'`), `createNotification({ userId: lead.userId, type: NotificationTypes.FINALIST_WITHDRAWN, title:'Grand finale slot withdrawn', message:`Your team "${title}" is no longer a confirmed finalist.${reason? ' Reason: '+reason : ''}`, linkUrl:'/applicant', metadata:{ projectTitle: title, reason } })` in try/catch. In `unenroll`, capture whether a CONFIRMED row existed BEFORE the delete, and only notify then.
|
||||
- [ ] **Step 4:** Run → pass; re-run `finalist-unconfirm`, `finalist-unenroll`, `finalist-admin-confirm` suites.
|
||||
- [ ] **Step 5: Commit** — `git commit -am "feat(finalist): withdrawal notification to team on decline/unconfirm/unenroll"`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Confirmation reminder cron
|
||||
|
||||
**Files:** `src/server/services/finalist-confirmation.ts`, `src/app/api/cron/finalist-confirmations/route.ts`; test `tests/unit/finalist-reminders.test.ts`.
|
||||
|
||||
Add `sendDueConfirmationReminders(prisma): Promise<{ remindersSent: number }>`:
|
||||
- Resolve a reminder lead time: read each program's LIVE_FINAL round `configJson.reminderHoursBeforeDeadline` (default 12).
|
||||
- Query `FinalistConfirmation` where `status:'PENDING' AND reminderSentAt IS NULL AND deadline > now AND deadline <= now + reminderHours`. (Simplest: load all PENDING with `reminderSentAt:null AND deadline>now`, then filter by each program's lead time.)
|
||||
- For each: send via `createNotification({ userId: lead.userId, type: FINALIST_REMINDER, title, message, linkUrl: confirmUrl (the public token URL — build like selectFinalists), metadata:{ projectTitle, deadline } })`, then `update reminderSentAt = now`. Best-effort per row (try/catch).
|
||||
- Reset `reminderSentAt` is NOT needed (deadlines don't move here; if re-invited, `resetOrCreatePendingConfirmation` should also clear `reminderSentAt` — ADD that field reset in the Wave 1 helper).
|
||||
|
||||
- [ ] **Step 1:** In `resetOrCreatePendingConfirmation` (`src/server/services/finalist-enrollment.ts`), add `reminderSentAt: null` to the reset `update` data (so re-invited teams get a fresh reminder window).
|
||||
- [ ] **Step 2: Failing test** — create a PENDING confirmation with `deadline = now + 6h` and `reminderSentAt:null`, a LIVE_FINAL round with `reminderHoursBeforeDeadline: 12`, a lead user; call `sendDueConfirmationReminders`; assert `remindersSent===1`, a `FINALIST_REMINDER` notification exists for the lead, and `reminderSentAt` is now set. Second call → `remindersSent===0` (idempotent).
|
||||
- [ ] **Step 3:** Run → fail.
|
||||
- [ ] **Step 4:** Implement `sendDueConfirmationReminders` and call it from the cron route (before or after `expirePendingPastDeadline`).
|
||||
- [ ] **Step 5:** Run → pass.
|
||||
- [ ] **Step 6: Commit** — `git commit -am "feat(finalist): deadline reminder emails via cron"`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Travel + visa attendee emails
|
||||
|
||||
**Files:** `src/server/routers/logistics.ts`; test `tests/unit/logistics-comms.test.ts`.
|
||||
|
||||
- `setFlightStatus` → when set to `CONFIRMED`: load the attendee's user + the program hotel; `createNotification({ userId: attendee.userId, type: TRAVEL_CONFIRMED, ..., metadata:{ projectTitle, arrival/departure fields, hotel } })`. (No email when set back to PENDING.)
|
||||
- `updateVisaApplication` → when `input.status` changes to one of `INVITATION_SENT|APPOINTMENT_BOOKED|GRANTED|DENIED` (and differs from `existing.status`): `createNotification({ userId: attendee.userId, type: VISA_STATUS_UPDATE, ..., metadata:{ projectTitle, status, note } })`. Gate on nothing (visa outcomes are always relevant); include the admin `notes` only if appropriate — default: don't leak internal notes, send status-only copy.
|
||||
|
||||
- [ ] **Step 1: Failing tests** — set a flight to CONFIRMED → assert `TRAVEL_CONFIRMED` notification for the attendee; update a visa to `GRANTED` → assert `VISA_STATUS_UPDATE` notification. (Reuse `logistics-flight.test.ts` / `visa-admin.test.ts` setup patterns.)
|
||||
- [ ] **Step 2:** Run → fail.
|
||||
- [ ] **Step 3:** Implement. For `setFlightStatus`, the procedure currently only has `flightDetailId`; join to `attendingMember.user` + program hotel. For `updateVisaApplication`, the existing row read already gives `attendingMember` — extend the select to include `user` + project title.
|
||||
- [ ] **Step 4:** Run → pass; re-run `logistics-flight`, `visa-admin`, `visa-application-lifecycle`.
|
||||
- [ ] **Step 5: Commit** — `git commit -am "feat(logistics): travel-confirmed + visa-status emails to attendees"`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Lunch reminder/recap fixes
|
||||
|
||||
**Files:** `src/app/api/cron/lunch-reminders/route.ts`, `src/server/routers/lunch.ts`, `src/components/admin/logistics/lunch-recap-actions.tsx`; test `tests/unit/lunch-reminder-filter.test.ts`.
|
||||
|
||||
- [ ] **Step 1: Failing test** — a CONFIRMED attendee with NO `MemberLunchPick` row should be counted as needing a reminder. Assert the cron's selection query (extract it into a small exported helper `selectUnpickedAttendees(prisma, event)` for testability) returns that attendee.
|
||||
- [ ] **Step 2:** Run → fail (current `is` filter misses null-relation rows).
|
||||
- [ ] **Step 3:** Fix the filter to `OR: [{ lunchPick: { is: null } }, { lunchPick: { is: { pickedAt: null } } }]` (cron `route.ts:44`).
|
||||
- [ ] **Step 4:** Recap failure surfacing (`lunch.ts:345`): only stamp `recapSentAt` + audit `LUNCH_RECAP_SENT` if `sendLunchRecapEmail` resolved; on failure, re-throw (or return `{ ok:false, error }`) so the admin sees a failure toast. Update `lunch-recap-actions.tsx` to show the error.
|
||||
- [ ] **Step 5:** Add `lunch.sendReminders` mutation (admin) that runs the same selection + `sendLunchReminderEmail` loop as the cron for a given `lunchEventId`, returns `{ sent }`; add a "Send reminders now" button to `lunch-recap-actions.tsx` (behind a confirm).
|
||||
- [ ] **Step 6:** Run tests → pass; `npm run typecheck` — clean.
|
||||
- [ ] **Step 7: Commit** — `git commit -am "fix(lunch): reminder filter, recap failure surfacing, manual send-reminders"`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Full verification
|
||||
|
||||
- [ ] **Step 1:** `npx vitest run` — full suite green (target: prior 256 + new tests).
|
||||
- [ ] **Step 2:** `npm run typecheck` — clean.
|
||||
- [ ] **Step 3:** Stop the dev server, `rm -rf .next`, `npm run build` — clean (don't build while dev server runs).
|
||||
- [ ] **Step 4:** Restart dev on :3001; `npx tsx prisma/seed-notification-settings.ts` to ensure settings exist. Dev smoke: enroll a team (ADMIN_CONFIRM) → set their flight CONFIRMED in Travel tab → set a visa GRANTED in Visas tab → confirm an `InAppNotification` row exists for the attendee (query DB) for `TRAVEL_CONFIRMED` and `VISA_STATUS_UPDATE`. Decline a PENDING team → confirm admin `FINALIST_DECLINED` + team `FINALIST_WITHDRAWN`. Clean up (unenroll).
|
||||
- [ ] **Step 5:** Summarize for review.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
- All comms calls are best-effort (try/catch, never throw inside a mutation/cron) — consistent with CLAUDE.md "round notifications never throw".
|
||||
- Email sending in dev uses `SMTP_HOST=localhost` (`.env.local`) → sends fail silently and are swallowed; tests assert on the `InAppNotification` row, not on actual delivery.
|
||||
- Prod gets the new `NotificationEmailSetting` rows automatically via `docker-entrypoint.sh` running `seed-notification-settings.ts` on deploy.
|
||||
- Deferred to later waves: Email Templates admin tab (Wave 3), team-facing "My Logistics" (Wave 4).
|
||||
@@ -0,0 +1,113 @@
|
||||
# Wave 3 — Enable the "Email Templates" tab (logistics hub)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||
|
||||
**Goal:** Turn the disabled "Email Templates (soon)" tab in `/admin/logistics` into a working surface where admins can, for every logistics email, toggle it on/off, customize the subject, **preview** the rendered email, and send a test to themselves — reusing the existing notification-settings infra.
|
||||
|
||||
**Architecture:** Reuse `notification.getEmailSettings` / `updateEmailSetting` / `sendTestEmail` (already built) scoped to the `logistics` category (the 8 types seeded in Wave 2). The only new server capability is **preview without sending**: extract a `renderNotificationEmail(...)` from `sendStyledNotificationEmail` and expose a `notification.previewEmailTemplate({ notificationType })` query returning `{ subject, html }`. UI reuses the existing `EmailPreviewDialog` (with `previewOnly`).
|
||||
|
||||
**Tech Stack:** Next.js 15, tRPC 11, shadcn/ui. No schema change.
|
||||
|
||||
**Key facts (verified 2026-06-04):**
|
||||
- `notification.getEmailSettings` (`src/server/routers/notification.ts:140`) returns all `NotificationEmailSetting` rows (incl. our 8 `category:'logistics'` rows).
|
||||
- `notification.updateEmailSetting` (`:152`) accepts `{ notificationType, sendEmail, emailSubject?, emailTemplate? }`.
|
||||
- `notification.sendTestEmail` (`:243`) renders via `sendStyledNotificationEmail` using a per-type `sampleData` map (which currently has NO logistics entries → previews/tests render with template fallbacks).
|
||||
- `sendStyledNotificationEmail` (`src/lib/email.ts:2400`) looks up `NOTIFICATION_EMAIL_TEMPLATES[type]`, else falls back to `getNotificationEmailTemplate`. Our 4 custom templates are registered; the 4 admin-alert types use the fallback.
|
||||
- `EmailPreviewDialog` (`src/components/admin/round/email-preview-dialog.tsx`) props: `{ open, onOpenChange, title, description, recipientCount, previewHtml, isPreviewLoading, onSend, isSending, showCustomMessage?, onRefreshPreview?, previewOnly? }`.
|
||||
- The existing global form `src/components/settings/notification-settings-form.tsx` renders categories team/jury/mentor/observer/admin — NOT `logistics`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Server — render-without-send + preview query + logistics sample data
|
||||
|
||||
**Files:** `src/lib/email.ts`, `src/server/routers/notification.ts`; test `tests/unit/notification-preview.test.ts`.
|
||||
|
||||
- [ ] **Step 1:** In `src/lib/email.ts`, extract the template-resolution logic of `sendStyledNotificationEmail` into an exported pure function:
|
||||
```ts
|
||||
export function renderNotificationEmail(
|
||||
name: string,
|
||||
type: string,
|
||||
context: NotificationEmailContext,
|
||||
subjectOverride?: string,
|
||||
): EmailTemplate {
|
||||
const generator = NOTIFICATION_EMAIL_TEMPLATES[type]
|
||||
const template = generator
|
||||
? generator({ ...context, name })
|
||||
: getNotificationEmailTemplate(name, subjectOverride || context.title, context.message, ensureAbsoluteUrl(context.linkUrl))
|
||||
return subjectOverride ? { ...template, subject: subjectOverride } : template
|
||||
}
|
||||
```
|
||||
Then refactor `sendStyledNotificationEmail` to call `renderNotificationEmail(...)` and `sendEmail(...)` the result (keep its existing signature/behavior identical — verify by re-running any notification email tests).
|
||||
|
||||
- [ ] **Step 2:** In `src/server/routers/notification.ts`, hoist the `sampleData` map (currently inside `sendTestEmail`) to a module-level `const NOTIFICATION_SAMPLE_DATA` and ADD logistics entries so previews/tests are realistic:
|
||||
```ts
|
||||
FINALIST_CONFIRMED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
|
||||
FINALIST_DECLINED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
|
||||
FINALIST_EXPIRED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
|
||||
FINALIST_WAITLIST_PROMOTED:{ projectTitle: 'Reef Guardians', category: 'STARTUP' },
|
||||
FINALIST_REMINDER: { projectTitle: 'Ocean Cleanup Initiative', deadline: new Date(Date.now()+86_400_000).toISOString() },
|
||||
FINALIST_WITHDRAWN: { projectTitle: 'Ocean Cleanup Initiative', reason: 'Schedule conflict' },
|
||||
TRAVEL_CONFIRMED: { projectTitle: 'Ocean Cleanup Initiative', arrivalAt: new Date(Date.now()+5*86_400_000).toISOString(), arrivalFlightNumber: 'AF1234', arrivalAirport: 'NCE', departureAt: new Date(Date.now()+7*86_400_000).toISOString(), departureFlightNumber: 'AF1235', departureAirport: 'NCE', hotel: { name: 'Hotel de Paris', address: 'Place du Casino, Monaco', link: 'https://example.com' } },
|
||||
VISA_STATUS_UPDATE: { projectTitle: 'Ocean Cleanup Initiative', status: 'GRANTED' },
|
||||
```
|
||||
Have `sendTestEmail` use `NOTIFICATION_SAMPLE_DATA` (behavior unchanged otherwise).
|
||||
|
||||
- [ ] **Step 3:** Add a `previewEmailTemplate` adminProcedure query:
|
||||
```ts
|
||||
previewEmailTemplate: adminProcedure
|
||||
.input(z.object({ notificationType: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const setting = await ctx.prisma.notificationEmailSetting.findUnique({ where: { notificationType: input.notificationType } })
|
||||
const label = setting?.label || input.notificationType
|
||||
const metadata = NOTIFICATION_SAMPLE_DATA[input.notificationType] || {}
|
||||
const rendered = renderNotificationEmail(ctx.user.name || 'Admin', input.notificationType, {
|
||||
title: label,
|
||||
message: `Preview of the "${label}" email.`,
|
||||
linkUrl: `${process.env.NEXTAUTH_URL || ''}/applicant`,
|
||||
linkLabel: 'Open',
|
||||
metadata,
|
||||
}, setting?.emailSubject || undefined)
|
||||
return { subject: rendered.subject, html: rendered.html, hasStyledTemplate: input.notificationType in NOTIFICATION_EMAIL_TEMPLATES }
|
||||
}),
|
||||
```
|
||||
Import `renderNotificationEmail` from `@/lib/email`.
|
||||
|
||||
- [ ] **Step 4: Test** (`tests/unit/notification-preview.test.ts`): call `notification.previewEmailTemplate({ notificationType: 'VISA_STATUS_UPDATE' })` via an admin caller; assert `html` contains a recognizable string (e.g. 'visa' or 'Grand Finale') and `subject` is non-empty. Also test a fallback type (`FINALIST_EXPIRED`) returns non-empty `html`. (Pattern: `createCaller(notificationRouter, {SUPER_ADMIN})`.)
|
||||
- [ ] **Step 5:** `npx vitest run tests/unit/notification-preview.test.ts` → pass. `npm run typecheck` → clean.
|
||||
- [ ] **Step 6: Commit** — `git commit -am "feat(notifications): renderNotificationEmail + previewEmailTemplate + logistics sample data"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: UI — logistics Email Templates tab + show logistics in global settings
|
||||
|
||||
**Files:** create `src/components/admin/logistics/email-templates-tab.tsx`; modify `src/components/settings/notification-settings-form.tsx`.
|
||||
|
||||
- [ ] **Step 1:** Add `logistics: { label: 'Logistics', icon: Plane }` to the `CATEGORIES` map in `notification-settings-form.tsx` (import `Plane` from lucide-react) so logistics settings are also manageable on the global settings page.
|
||||
|
||||
- [ ] **Step 2:** Build `EmailTemplatesTab` (`src/components/admin/logistics/email-templates-tab.tsx`), `'use client'`, mirroring `NotificationSettingsForm`'s structure but: (a) filter `trpc.notification.getEmailSettings` to `category === 'logistics'`; (b) per row show the toggle (`updateEmailSetting`), a **subject** input (debounced `onBlur` → `updateEmailSetting({ notificationType, sendEmail, emailSubject })`), a **Test** button (`sendTestEmail`), and a **Preview** button; (c) Preview opens `EmailPreviewDialog` with `previewOnly`, fetching `trpc.notification.previewEmailTemplate({ notificationType })` (lazy, `enabled: !!previewType`) and passing its `html` to `previewHtml`. Loading → Skeleton; empty → "No logistics email types found — run the notification settings seed." Use sonner toasts + `trpc.useUtils()` invalidation.
|
||||
|
||||
- [ ] **Step 3:** `npm run typecheck` → clean.
|
||||
- [ ] **Step 4: Commit** — `git commit -am "feat(logistics): Email Templates tab (toggle/subject/preview/test) + logistics in global settings"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Enable the tab in the logistics page
|
||||
|
||||
**Files:** `src/app/(admin)/admin/logistics/page.tsx`.
|
||||
|
||||
- [ ] **Step 1:** Remove `disabled` + the "(soon)" span from the `email-templates` `TabsTrigger`; import and add `<TabsContent value="email-templates"><EmailTemplatesTab programId={programId} /></TabsContent>` (the tab doesn't strictly need programId — settings are global — but pass it for consistency / future scoping; if unused, omit the prop).
|
||||
- [ ] **Step 2:** `npm run typecheck` → clean.
|
||||
- [ ] **Step 3: Commit** — `git commit -am "feat(logistics): enable Email Templates tab"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Verify
|
||||
|
||||
- [ ] **Step 1:** `npx vitest run` — full suite green.
|
||||
- [ ] **Step 2:** `npm run typecheck` — clean. Stop dev server, `rm -rf .next`, `npm run build` — clean.
|
||||
- [ ] **Step 3:** Restart dev on :3001; dev smoke: `/admin/logistics` → Email Templates tab renders the 8 logistics types; toggle one off/on (persists); click Preview on `VISA_STATUS_UPDATE` → dialog shows the rendered branded email; click Test → success toast (email swallowed by localhost SMTP in dev — fine). Screenshot.
|
||||
- [ ] **Step 4:** Summarize.
|
||||
|
||||
## Notes
|
||||
- No new email is sent automatically by this wave — it only adds admin visibility/control over the Wave-2 emails.
|
||||
- Deferred to Wave 4: team-facing "My Logistics" + travel/visa UX fixes.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Wave 4 — Team-facing "My Logistics" + travel/visa UX fixes
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||
|
||||
**Goal:** Close the loop for finalist teams. Today a confirmed team can see only their attendee roster + a visa badge; they have no view of their flights or hotel, can't self-enter passport nationality, and the submitter (non-TeamMember lead) can't even see the card. This wave adds a team-facing "My Logistics" view (flights + hotel + visa + nationality self-entry), fixes the submitter gap, links the confirm-success page to the dashboard, and adds the admin-side travel/visa correctness fixes (departure-after-arrival validation, CSV export).
|
||||
|
||||
**Architecture:** New read procedures on the applicant router (`getMyLogistics`) reusing the same caller→project resolution as the existing finalist procedures, plus a self-service `updateMyVisaNationality`. A new applicant dashboard card. Admin-side: input validation on `logistics.upsertFlightDetail` + CSV export buttons mirroring the lunch manifest.
|
||||
|
||||
**Tech Stack:** Next.js 15, tRPC 11, Prisma 6, shadcn/ui. No schema change.
|
||||
|
||||
**Key facts (verified 2026-06-04):**
|
||||
- `applicant.getMyFinalistConfirmation` (`src/server/routers/applicant.ts:2748`) resolves the project via `teamMembers: { some: { userId } }` — MISSES a lead who submitted but has no TeamMember row, and has no role guard.
|
||||
- `applicant.getMyVisaApplications` (`:2819`) returns visa rows when `program.visaStatusVisibleToMembers`, else null. Reuse its visibility logic.
|
||||
- Admin-only: `logistics.getHotel`, `logistics.listFlightDetails` (`src/server/routers/logistics.ts`). No team-facing equivalents → teams never see hotel/flights.
|
||||
- Applicant dashboard composes cards in `src/app/(applicant)/applicant/page.tsx` (~line 409): `LunchBanner`, `ExternalAttendeesStrip`, `AttendingMembersCard`. Add the new card here.
|
||||
- Confirm-success copy at `src/app/(public)/finalist/confirm/[token]/page.tsx:183` promises "your project page" with no link.
|
||||
- `logistics.upsertFlightDetail` input (`:145`) has no departure-after-arrival check. Lunch CSV export pattern: `src/components/admin/logistics/lunch-manifest.tsx:28-57`.
|
||||
- Models: `Hotel{ name, address?, link?, notes? }` (programId 1:1); `FlightDetail{ arrival/departure At/FlightNumber/Airport, status, adminNotes }` (per AttendingMember); `VisaApplication{ status, nationality?, ... }`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Team-facing read endpoint + submitter fix
|
||||
|
||||
**Files:** `src/server/routers/applicant.ts`; test `tests/unit/applicant-my-logistics.test.ts`.
|
||||
|
||||
- [ ] **Step 1:** Fix `getMyFinalistConfirmation` resolution: change the `where` to
|
||||
`{ OR: [{ submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id } } }], finalistConfirmation: { isNot: null } }`
|
||||
(verify `Project.submittedByUserId` is the correct field name in schema). Keep the rest. (No role guard added — APPLICANT/lead may not have an explicit role; the project-scoping is the guard. If other procedures in this router use a role check, match it; otherwise leave project-scoping as the guard and note it.)
|
||||
|
||||
- [ ] **Step 2:** Add `getMyLogistics: protectedProcedure.query`:
|
||||
- Resolve the caller's confirmed-finalist project (same OR-resolution as above; return `null` if none or confirmation not CONFIRMED).
|
||||
- Return:
|
||||
```ts
|
||||
{
|
||||
projectTitle: string,
|
||||
confirmationStatus: FinalistConfirmationStatus,
|
||||
hotel: { name, address, link, notes } | null, // program Hotel (1:1)
|
||||
myFlight: { arrivalAt, arrivalFlightNumber, arrivalAirport, departureAt, departureFlightNumber, departureAirport, status } | null, // the caller's own AttendingMember.flightDetail
|
||||
visaVisible: boolean, // program.visaStatusVisibleToMembers
|
||||
myVisa: { status, nationality } | null, // caller's own VisaApplication, only when visaVisible
|
||||
}
|
||||
```
|
||||
- `myFlight`: find the caller's `AttendingMember` for this confirmation (`where confirmationId + userId`), include `flightDetail`. `hotel`: `prisma.hotel.findUnique({ where: { programId } })`. `myVisa`: only when `visaVisible` and the caller's AttendingMember has a `visaApplication`.
|
||||
|
||||
- [ ] **Step 3: Test** — set up a CONFIRMED finalist with the caller as an AttendingMember, a Hotel, a FlightDetail (CONFIRMED), `visaStatusVisibleToMembers:true`, a VisaApplication GRANTED. Call `getMyLogistics` as that user → assert `hotel.name`, `myFlight.arrivalFlightNumber`, `visaVisible:true`, `myVisa.status==='GRANTED'`. Also: a non-finalist user → `null`. (Caller via `createCaller(applicantRouter, { id, email, role:'APPLICANT' })`.)
|
||||
- [ ] **Step 4:** `npx vitest run tests/unit/applicant-my-logistics.test.ts` → pass; re-run any applicant tests touching these procedures. `npm run typecheck` → clean.
|
||||
- [ ] **Step 5: Commit** — `git commit -am "feat(applicant): getMyLogistics (hotel+flight+visa) + submitter-match fix"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Self-service nationality entry
|
||||
|
||||
**Files:** `src/server/routers/applicant.ts`; test in the same file.
|
||||
|
||||
- [ ] **Step 1:** Add `updateMyVisaNationality: protectedProcedure.input(z.object({ nationality: z.string().max(100) })).mutation`:
|
||||
- Find the caller's `AttendingMember` whose program has `visaStatusVisibleToMembers:true` and which has a `VisaApplication`; if none → `TRPCError NOT_FOUND` ("No visa application to update").
|
||||
- Update that `VisaApplication.nationality`. Audit `VISA_NATIONALITY_SELF_SET`. Return `{ ok: true }`.
|
||||
- (Optional nicety: also copy to `User.nationality` if empty.)
|
||||
- [ ] **Step 2: Test** — caller with a visible VisaApplication sets nationality → assert it persisted. Caller without one → rejects.
|
||||
- [ ] **Step 3:** Run → pass. `npm run typecheck` → clean.
|
||||
- [ ] **Step 4: Commit** — `git commit -am "feat(applicant): self-service visa nationality entry"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: "My Logistics" card + confirm-page link
|
||||
|
||||
**Files:** create `src/components/applicant/my-logistics-card.tsx`; modify `src/app/(applicant)/applicant/page.tsx`, `src/app/(public)/finalist/confirm/[token]/page.tsx`.
|
||||
|
||||
- [ ] **Step 1:** Build `MyLogisticsCard` (`'use client'`): `trpc.applicant.getMyLogistics.useQuery()`. If `null` or loading → render nothing / Skeleton. Otherwise a Card "Your grand-finale logistics" with sections:
|
||||
- **Hotel** — name + address + link (if present), else "Hotel details coming soon."
|
||||
- **Flights** — the caller's arrival/departure (flight no, airport, formatted date/time in Europe/Paris) + a status badge, else "Your flight details will appear here once arranged."
|
||||
- **Visa** (only if `visaVisible`) — status badge + a nationality field: shows current `myVisa.nationality` or an inline editable input → `trpc.applicant.updateMyVisaNationality` (sonner toast + invalidate).
|
||||
Follow the visual pattern of `attending-members-card.tsx`. Visible affordances only.
|
||||
- [ ] **Step 2:** Render `<MyLogisticsCard />` in `src/app/(applicant)/applicant/page.tsx` near `AttendingMembersCard` (~line 415).
|
||||
- [ ] **Step 3:** In the confirm-success block (`finalist/confirm/[token]/page.tsx:168-185`), replace the dead "your project page" sentence with a real link/button to `/applicant` ("Go to my dashboard"). (The confirm page is public; the link will hit auth and land them on their dashboard after login.)
|
||||
- [ ] **Step 4:** `npm run typecheck` → clean.
|
||||
- [ ] **Step 5: Commit** — `git commit -am "feat(applicant): My Logistics card (hotel/flights/visa+nationality) + confirm-page dashboard link"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Admin travel/visa UX — validation + CSV export
|
||||
|
||||
**Files:** `src/server/routers/logistics.ts`, `src/components/admin/logistics/travel-tab.tsx`, `src/components/admin/logistics/visas-tab.tsx`; test `tests/unit/logistics-flight.test.ts` (extend).
|
||||
|
||||
- [ ] **Step 1:** Server validation in `logistics.upsertFlightDetail`: after building `data`, if both `arrivalAt` and `departureAt` are present and `departureAt < arrivalAt`, throw `TRPCError BAD_REQUEST` ("Departure must be after arrival"). Add a test asserting the rejection.
|
||||
- [ ] **Step 2:** CSV export — add a "Download CSV" button to `travel-tab.tsx` (columns: Project, Attendee, Email, Arrival date/time, Arrival flight, Arrival airport, Departure date/time, Departure flight, Departure airport, Status, Needs visa) and to `visas-tab.tsx` (columns: Project, Attendee, Email, Nationality, Status, Invitation sent, Appointment, Decision, Notes). MIRROR the CSV builder in `src/components/admin/logistics/lunch-manifest.tsx:28-57` (Blob + anchor download; escape commas/quotes). Build from the data already loaded by each tab's query.
|
||||
- [ ] **Step 3:** `npx vitest run tests/unit/logistics-flight.test.ts` → pass. `npm run typecheck` → clean.
|
||||
- [ ] **Step 4: Commit** — `git commit -am "feat(logistics): departure-after-arrival validation + travel/visa CSV export"`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Verify
|
||||
|
||||
- [ ] **Step 1:** `npx vitest run` — full suite green.
|
||||
- [ ] **Step 2:** `npm run typecheck` — clean. Stop dev, `rm -rf .next`, `npm run build` — clean.
|
||||
- [ ] **Step 3:** Restart dev on :3001. Dev smoke: as admin, enroll a team (ADMIN_CONFIRM) and add flight + hotel + set a visa; then log in as that team's lead (seed user `matt@letsbe.solutions` is a MEMBER of "Revamp Flips" — or use the lead) and confirm the "My Logistics" card shows the hotel + flight + visa + nationality field. Verify the CSV export downloads. Clean up.
|
||||
- [ ] **Step 4:** Summarize.
|
||||
|
||||
## Notes
|
||||
- Deep timezone overhaul of the admin flight `datetime-local` inputs (storing explicit Europe/Paris) is a separate design decision — NOT in this wave; the validation + Europe/Paris display labels are the pragmatic improvement. Flag as remaining polish.
|
||||
- This completes the 4-wave logistics overhaul.
|
||||
@@ -0,0 +1,745 @@
|
||||
# Grand Final Judge-Doc Curation + Optional Uploads Implementation Plan
|
||||
|
||||
> **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:** Let admins curate which previously-submitted documents finale judges see, and make the "optional revised uploads" mode render correctly for finalists.
|
||||
|
||||
**Architecture:** Everything builds on the existing `final-documents.ts` service + `finalist` tRPC router + the LIVE_FINAL round's `configJson` (same pattern as the shipped `allowFinalistRevisedUploads` toggle). A new configJson key `reviewVisibleRequirementIds` filters the judge review payload; new status fields `hasRequired`/`allUploaded` drive optional-mode rendering in the finalist banner/panel. No schema migration.
|
||||
|
||||
**Tech Stack:** Next.js 15 App Router, tRPC 11 + Zod, Prisma 6, Vitest 4 (sequential, real test DB), shadcn/ui (Card/Switch/Checkbox).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-09-finale-doc-curation-optional-uploads-design.md`
|
||||
|
||||
**Conventions for every task:** TypeScript strict, `type` over `interface`. Tests use the factories in `tests/helpers.ts` (`createTestProgram`, `createTestCompetition`, `createTestRound`, `createTestProject`, `createTestProjectRoundState`, `createTestUser`, `uid`) and clean up with `cleanupTestData(programId, userIds?)` in `afterAll`. Run a single file with `npx vitest run tests/unit/<file>.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `hasRequired` + `allUploaded` on `FinalDocumentStatus`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/services/final-documents.ts` (type at ~line 14, computation at ~line 95)
|
||||
- Test: `tests/unit/final-documents.test.ts` (extend existing file)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
In `tests/unit/final-documents.test.ts`, first extend the `makeFinaleProgram` factory (top of file) with an `optionalRequirements` option — change the two `fileRequirement.create` calls to use it:
|
||||
|
||||
```ts
|
||||
async function makeFinaleProgram(
|
||||
opts: { roundStatus?: 'ROUND_ACTIVE' | 'ROUND_DRAFT' | 'ROUND_CLOSED'; closeAt?: Date; skipRequirements?: boolean; uploadsEnabled?: boolean; optionalRequirements?: boolean } = {},
|
||||
) {
|
||||
// ... existing body unchanged, except both requirement creates:
|
||||
// isRequired: !opts.optionalRequirements
|
||||
}
|
||||
```
|
||||
|
||||
Then add inside the existing `describe('getFinalDocumentStatusForProject', ...)` block:
|
||||
|
||||
```ts
|
||||
it('all-optional round: hasRequired false, allUploaded flips when every slot has a file', async () => {
|
||||
const { program, round, reqPlan, reqVideo } = await makeFinaleProgram({ optionalRequirements: true })
|
||||
const project = await createTestProject(program.id)
|
||||
await createTestProjectRoundState(project.id, round.id)
|
||||
|
||||
const before = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||
expect(before!.hasRequired).toBe(false)
|
||||
expect(before!.allUploaded).toBe(false)
|
||||
expect(before!.allRequiredUploaded).toBe(false)
|
||||
|
||||
for (const req of [reqPlan!, reqVideo!]) {
|
||||
await prisma.projectFile.create({
|
||||
data: {
|
||||
id: uid('file'), projectId: project.id, roundId: round.id, requirementId: req.id,
|
||||
fileType: 'SUPPORTING_DOC', fileName: `f-${req.id}`, mimeType: 'application/pdf', size: 10,
|
||||
bucket: 'b', objectKey: uid('key'),
|
||||
},
|
||||
})
|
||||
}
|
||||
const after = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||
expect(after!.hasRequired).toBe(false)
|
||||
expect(after!.allUploaded).toBe(true)
|
||||
})
|
||||
|
||||
it('mixed round: hasRequired true; allUploaded only when optional slots are filled too', async () => {
|
||||
const { program, round, reqPlan } = await makeFinaleProgram()
|
||||
await prisma.fileRequirement.update({ where: { id: reqPlan!.id }, data: { isRequired: false } })
|
||||
const project = await createTestProject(program.id)
|
||||
await createTestProjectRoundState(project.id, round.id)
|
||||
const status = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||
expect(status!.hasRequired).toBe(true) // reqVideo still required
|
||||
expect(status!.allUploaded).toBe(false)
|
||||
})
|
||||
|
||||
it('zero slots: allUploaded false (no vacuous completeness)', async () => {
|
||||
const { program, round } = await makeFinaleProgram({ skipRequirements: true })
|
||||
const project = await createTestProject(program.id)
|
||||
await createTestProjectRoundState(project.id, round.id)
|
||||
const status = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||
expect(status!.hasRequired).toBe(false)
|
||||
expect(status!.allUploaded).toBe(false)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run tests/unit/final-documents.test.ts`
|
||||
Expected: the 3 new tests FAIL with TypeScript/undefined errors on `hasRequired` / `allUploaded` (fields don't exist yet); all pre-existing tests still pass.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `src/server/services/final-documents.ts`, extend the type (~line 14):
|
||||
|
||||
```ts
|
||||
export type FinalDocumentStatus = {
|
||||
roundId: string
|
||||
roundName: string
|
||||
deadline: Date | null
|
||||
deadlinePassed: boolean
|
||||
requirements: FinalDocRequirement[]
|
||||
allRequiredUploaded: boolean
|
||||
hasRequired: boolean // any slot is marked required
|
||||
allUploaded: boolean // every listed slot has a file (false when no slots exist)
|
||||
}
|
||||
```
|
||||
|
||||
And in `getFinalDocumentStatusForProject` (~line 95), replace the return-value computation:
|
||||
|
||||
```ts
|
||||
const required = reqStatuses.filter((r) => r.isRequired)
|
||||
const allRequiredUploaded = required.length > 0 && required.every((r) => r.uploaded)
|
||||
const hasRequired = required.length > 0
|
||||
const allUploaded = reqStatuses.length > 0 && reqStatuses.every((r) => r.uploaded)
|
||||
const deadline = round.windowCloseAt ?? null
|
||||
return {
|
||||
roundId: round.id,
|
||||
roundName: round.name,
|
||||
deadline,
|
||||
deadlinePassed: deadline ? new Date() > deadline : false,
|
||||
requirements: reqStatuses,
|
||||
allRequiredUploaded,
|
||||
hasRequired,
|
||||
allUploaded,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run tests/unit/final-documents.test.ts`
|
||||
Expected: ALL tests pass (new + pre-existing).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/services/final-documents.ts tests/unit/final-documents.test.ts
|
||||
git commit -m "feat(final-docs): hasRequired/allUploaded on FinalDocumentStatus for optional-uploads mode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Curation filter in `listFinalistDocumentsForReview`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/services/final-documents.ts` (`listFinalistDocumentsForReview`, ~line 257; new helper next to `finalistUploadsEnabled` ~line 45)
|
||||
- Test: Create `tests/unit/final-documents-curation.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/unit/final-documents-curation.test.ts`. The review service presigns every file via MinIO, so mock `getPresignedUrl` (partial module mock — keeps `BUCKET_NAME` etc. real):
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, afterAll, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/minio', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/minio')>()
|
||||
return { ...actual, getPresignedUrl: vi.fn(async () => 'https://example.test/presigned') }
|
||||
})
|
||||
|
||||
import { prisma } from '../setup'
|
||||
import {
|
||||
createTestProgram,
|
||||
createTestCompetition,
|
||||
createTestRound,
|
||||
createTestProject,
|
||||
createTestProjectRoundState,
|
||||
cleanupTestData,
|
||||
uid,
|
||||
} from '../helpers'
|
||||
import { listFinalistDocumentsForReview } from '@/server/services/final-documents'
|
||||
|
||||
const programIds: string[] = []
|
||||
afterAll(async () => {
|
||||
for (const id of programIds) await cleanupTestData(id)
|
||||
})
|
||||
|
||||
/**
|
||||
* One finalist team with 4 files:
|
||||
* - Business Plan (prior SUBMISSION round, via requirement reqBP)
|
||||
* - Pitch Deck (prior SUBMISSION round, via requirement reqDeck)
|
||||
* - loose.pdf (prior SUBMISSION round, NO requirement)
|
||||
* - final.mp4 (uploaded directly to the LIVE_FINAL round, via reqFinal)
|
||||
*/
|
||||
async function setupCuration() {
|
||||
const program = await createTestProgram()
|
||||
programIds.push(program.id)
|
||||
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||
const priorRound = await createTestRound(comp.id, { roundType: 'SUBMISSION', status: 'ROUND_CLOSED', sortOrder: 2 })
|
||||
const reqBP = await prisma.fileRequirement.create({
|
||||
data: { id: uid('req'), roundId: priorRound.id, name: 'Business Plan', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 },
|
||||
})
|
||||
const reqDeck = await prisma.fileRequirement.create({
|
||||
data: { id: uid('req'), roundId: priorRound.id, name: 'Pitch Deck', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 2 },
|
||||
})
|
||||
const finale = await createTestRound(comp.id, {
|
||||
roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6,
|
||||
windowCloseAt: new Date(Date.now() + 86_400_000),
|
||||
configJson: { allowFinalistRevisedUploads: true },
|
||||
})
|
||||
const reqFinal = await prisma.fileRequirement.create({
|
||||
data: { id: uid('req'), roundId: finale.id, name: '1-minute Video', acceptedMimeTypes: ['video/*'], isRequired: false, sortOrder: 1 },
|
||||
})
|
||||
const project = await createTestProject(program.id)
|
||||
await createTestProjectRoundState(project.id, finale.id)
|
||||
|
||||
const mkFile = (roundId: string, requirementId: string | null, fileName: string) =>
|
||||
prisma.projectFile.create({
|
||||
data: {
|
||||
id: uid('file'), projectId: project.id, roundId, requirementId,
|
||||
fileType: 'SUPPORTING_DOC', fileName, mimeType: 'application/pdf', size: 10,
|
||||
bucket: 'b', objectKey: uid('key'),
|
||||
},
|
||||
})
|
||||
await mkFile(priorRound.id, reqBP.id, 'bp.pdf')
|
||||
await mkFile(priorRound.id, reqDeck.id, 'deck.pdf')
|
||||
await mkFile(priorRound.id, null, 'loose.pdf')
|
||||
await mkFile(finale.id, reqFinal.id, 'final.mp4')
|
||||
|
||||
return { program, priorRound, finale, reqBP, reqDeck, reqFinal, project }
|
||||
}
|
||||
|
||||
async function setSelection(roundId: string, ids: string[] | null) {
|
||||
const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { configJson: true } })
|
||||
const cfg = (round.configJson ?? {}) as Record<string, unknown>
|
||||
if (ids === null) delete cfg.reviewVisibleRequirementIds
|
||||
else cfg.reviewVisibleRequirementIds = ids
|
||||
await prisma.round.update({ where: { id: roundId }, data: { configJson: cfg as object } })
|
||||
}
|
||||
|
||||
describe('listFinalistDocumentsForReview curation', () => {
|
||||
it('no selection key → all files visible (current behavior)', async () => {
|
||||
const { program } = await setupCuration()
|
||||
const result = await listFinalistDocumentsForReview(prisma, program.id)
|
||||
expect(result.teams).toHaveLength(1)
|
||||
expect(result.teams[0].files).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('selection → only matching prior files, finale uploads always visible', async () => {
|
||||
const { program, finale, reqBP } = await setupCuration()
|
||||
await setSelection(finale.id, [reqBP.id])
|
||||
const result = await listFinalistDocumentsForReview(prisma, program.id)
|
||||
const names = result.teams[0].files.map((f) => f.fileName).sort()
|
||||
expect(names).toEqual(['bp.pdf', 'final.mp4']) // deck.pdf and loose.pdf hidden
|
||||
})
|
||||
|
||||
it('empty selection → only finale uploads visible', async () => {
|
||||
const { program, finale } = await setupCuration()
|
||||
await setSelection(finale.id, [])
|
||||
const result = await listFinalistDocumentsForReview(prisma, program.id)
|
||||
expect(result.teams[0].files.map((f) => f.fileName)).toEqual(['final.mp4'])
|
||||
})
|
||||
|
||||
it('prior file without a requirement is excluded under any selection', async () => {
|
||||
const { program, finale, reqBP, reqDeck } = await setupCuration()
|
||||
await setSelection(finale.id, [reqBP.id, reqDeck.id])
|
||||
const result = await listFinalistDocumentsForReview(prisma, program.id)
|
||||
expect(result.teams[0].files.map((f) => f.fileName)).not.toContain('loose.pdf')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
|
||||
Expected: the first test PASSES (current behavior), the other three FAIL (filter not implemented — they see all 4 files).
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `src/server/services/final-documents.ts`, add a config reader next to `finalistUploadsEnabled` (~line 45):
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Which prior-round FileRequirement ids are visible to finale judges.
|
||||
* null = no curation (show all prior files). Empty array = hide all prior
|
||||
* files (Grand Final round uploads are always shown regardless).
|
||||
*/
|
||||
export function reviewVisibleRequirementIds(configJson: unknown): string[] | null {
|
||||
const v = (configJson as { reviewVisibleRequirementIds?: unknown } | null)?.reviewVisibleRequirementIds
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : null
|
||||
}
|
||||
```
|
||||
|
||||
In `listFinalistDocumentsForReview`:
|
||||
1. After the `if (!round) return ...` guard, read the selection: `const visibleIds = reviewVisibleRequirementIds(round.configJson)`
|
||||
2. Add `requirementId: true` to the `allFiles` select.
|
||||
3. At the top of the `for (const f of allFiles)` loop, before building `rf`:
|
||||
|
||||
```ts
|
||||
const isFinaleUpload = f.roundId === round.id
|
||||
// Curated mode: prior-round files must match a selected requirement; finale uploads always pass.
|
||||
if (!isFinaleUpload && visibleIds !== null && (!f.requirementId || !visibleIds.includes(f.requirementId))) continue
|
||||
```
|
||||
|
||||
4. Use the `isFinaleUpload` const in the `rf` object (replacing the inline `f.roundId === round.id`).
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
|
||||
Expected: all 4 PASS.
|
||||
|
||||
Also run the neighbors to catch regressions: `npx vitest run tests/unit/final-documents.test.ts`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/services/final-documents.ts tests/unit/final-documents-curation.test.ts
|
||||
git commit -m "feat(final-docs): filter judge review by reviewVisibleRequirementIds (finale uploads always shown)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Picker options helper `listReviewVisibilityOptions`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/services/final-documents.ts` (new exported function + type, after `listFinalistDocumentsForReview`)
|
||||
- Test: `tests/unit/final-documents-curation.test.ts` (extend)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `tests/unit/final-documents-curation.test.ts` (import `listReviewVisibilityOptions` from the same service module):
|
||||
|
||||
```ts
|
||||
describe('listReviewVisibilityOptions', () => {
|
||||
it('lists distinct prior-round slots with counts; excludes finale-round slots and requirement-less files', async () => {
|
||||
const { program, reqBP, reqDeck } = await setupCuration()
|
||||
const options = await listReviewVisibilityOptions(prisma, program.id)
|
||||
expect(options.map((o) => o.requirementId).sort()).toEqual([reqBP.id, reqDeck.id].sort())
|
||||
const bp = options.find((o) => o.requirementId === reqBP.id)!
|
||||
expect(bp.name).toBe('Business Plan')
|
||||
expect(bp.fileCount).toBe(1)
|
||||
expect(bp.roundName).toBeTruthy()
|
||||
})
|
||||
|
||||
it('returns [] when there is no open finale round', async () => {
|
||||
const program = await createTestProgram()
|
||||
programIds.push(program.id)
|
||||
expect(await listReviewVisibilityOptions(prisma, program.id)).toEqual([])
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
|
||||
Expected: FAIL — `listReviewVisibilityOptions` is not exported.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `src/server/services/final-documents.ts`, after `listFinalistDocumentsForReview`:
|
||||
|
||||
```ts
|
||||
export type ReviewDocSlot = {
|
||||
requirementId: string
|
||||
name: string
|
||||
roundName: string
|
||||
roundSort: number
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct prior-round document slots (FileRequirements) that the finalist
|
||||
* teams have files for — the options offered in the admin "documents shown to
|
||||
* judges" picker. Excludes the finale round's own slots (those uploads are
|
||||
* always visible to judges) and files without a requirement.
|
||||
*/
|
||||
export async function listReviewVisibilityOptions(prisma: PrismaClient, programId: string): Promise<ReviewDocSlot[]> {
|
||||
const round = await getOpenFinaleRound(prisma, programId)
|
||||
if (!round) return []
|
||||
const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id }, select: { projectId: true } })
|
||||
const files = await prisma.projectFile.findMany({
|
||||
where: { projectId: { in: states.map((s) => s.projectId) }, requirement: { roundId: { not: round.id } } },
|
||||
select: {
|
||||
requirementId: true,
|
||||
requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } },
|
||||
},
|
||||
})
|
||||
const slots = new Map<string, ReviewDocSlot>()
|
||||
for (const f of files) {
|
||||
if (!f.requirementId || !f.requirement) continue
|
||||
const existing = slots.get(f.requirementId)
|
||||
if (existing) existing.fileCount++
|
||||
else slots.set(f.requirementId, {
|
||||
requirementId: f.requirementId,
|
||||
name: f.requirement.name.trim(),
|
||||
roundName: f.requirement.round.name,
|
||||
roundSort: f.requirement.round.sortOrder,
|
||||
fileCount: 1,
|
||||
})
|
||||
}
|
||||
return [...slots.values()].sort((a, b) => a.roundSort - b.roundSort || a.name.localeCompare(b.name))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/services/final-documents.ts tests/unit/final-documents-curation.test.ts
|
||||
git commit -m "feat(final-docs): listReviewVisibilityOptions — distinct prior-round doc slots for the curation picker"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: tRPC procedures `getReviewDocSettings` / `setReviewVisibleRequirements`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/finalist.ts` (add two procedures next to `getRevisedUploadSetting`/`setRevisedUploadSetting`, ~line 1695; extend the existing `@/server/services/final-documents` import with `listReviewVisibilityOptions` and `reviewVisibleRequirementIds`)
|
||||
- Test: `tests/unit/final-documents-curation.test.ts` (extend)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `tests/unit/final-documents-curation.test.ts`:
|
||||
|
||||
```ts
|
||||
import * as finalistRouter from '@/server/routers/finalist'
|
||||
import { createCaller } from '../setup'
|
||||
import { createTestUser } from '../helpers' // merge into the existing helpers import
|
||||
|
||||
describe('finalist review-doc settings procedures', () => {
|
||||
const userIds: string[] = []
|
||||
afterAll(async () => {
|
||||
// cleanupTestData of programIds already runs in the file-level afterAll;
|
||||
// pass userIds through an extra cleanup for the admin users:
|
||||
for (const id of programIds) await cleanupTestData(id, userIds)
|
||||
})
|
||||
|
||||
it('round-trips a selection and preserves sibling configJson keys', async () => {
|
||||
const { program, finale, reqBP } = await setupCuration()
|
||||
const admin = await createTestUser('PROGRAM_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
const caller = createCaller(finalistRouter.finalistRouter, admin)
|
||||
|
||||
const initial = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
|
||||
expect(initial.selectedIds).toBeNull()
|
||||
expect(initial.options.length).toBe(2)
|
||||
|
||||
await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: [reqBP.id] })
|
||||
const curated = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
|
||||
expect(curated.selectedIds).toEqual([reqBP.id])
|
||||
|
||||
// sibling key from setupCuration must survive
|
||||
const round = await prisma.round.findUniqueOrThrow({ where: { id: finale.id }, select: { configJson: true } })
|
||||
expect((round.configJson as Record<string, unknown>).allowFinalistRevisedUploads).toBe(true)
|
||||
|
||||
await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: null })
|
||||
const cleared = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
|
||||
expect(cleared.selectedIds).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a non-LIVE_FINAL round', async () => {
|
||||
const { program, priorRound } = await setupCuration()
|
||||
const admin = await createTestUser('PROGRAM_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
const caller = createCaller(finalistRouter.finalistRouter, admin)
|
||||
await expect(
|
||||
caller.setReviewVisibleRequirements({ roundId: priorRound.id, requirementIds: [] }),
|
||||
).rejects.toThrow()
|
||||
void program
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
(Adjust the top-of-file `../helpers` import to include `createTestUser` rather than re-importing.)
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
|
||||
Expected: FAIL — `getReviewDocSettings` does not exist on the router.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `src/server/routers/finalist.ts`, extend the existing service import with `listReviewVisibilityOptions, reviewVisibleRequirementIds`, then add after `setRevisedUploadSetting`:
|
||||
|
||||
```ts
|
||||
/** Options + current selection for the "documents shown to judges" picker. */
|
||||
getReviewDocSettings: adminProcedure
|
||||
.input(z.object({ programId: z.string(), roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true } })
|
||||
return {
|
||||
options: await listReviewVisibilityOptions(ctx.prisma, input.programId),
|
||||
selectedIds: reviewVisibleRequirementIds(round?.configJson ?? null),
|
||||
}
|
||||
}),
|
||||
|
||||
/** Set which prior-round documents finale judges see. null = show all (clears curation). */
|
||||
setReviewVisibleRequirements: adminProcedure
|
||||
.input(z.object({ roundId: z.string(), requirementIds: z.array(z.string()).nullable() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true, roundType: true } })
|
||||
if (!round || round.roundType !== 'LIVE_FINAL') {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a grand-final round' })
|
||||
}
|
||||
const { reviewVisibleRequirementIds: _omit, ...rest } = (round.configJson ?? {}) as Record<string, unknown>
|
||||
const next = input.requirementIds === null ? rest : { ...rest, reviewVisibleRequirementIds: input.requirementIds }
|
||||
await ctx.prisma.round.update({ where: { id: input.roundId }, data: { configJson: next } })
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'FINALIST_REVIEW_DOCS_CURATED',
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: { requirementIds: input.requirementIds },
|
||||
})
|
||||
return { ok: true }
|
||||
}),
|
||||
```
|
||||
|
||||
Note: `data: { configJson: next }` may need `next as Prisma.InputJsonValue` depending on inference — match how the file already imports/uses Prisma types if the typechecker complains.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
|
||||
Expected: all PASS. Then `npm run typecheck` — clean.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/routers/finalist.ts tests/unit/final-documents-curation.test.ts
|
||||
git commit -m "feat(final-docs): admin procedures to read/set judge-visible document curation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Optional-mode rendering — banner + panel
|
||||
|
||||
No component-test infrastructure exists in this repo (vitest covers server code only) — verify via typecheck + the manual smoke in Task 7.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/applicant/final-documents-banner.tsx`
|
||||
- Modify: `src/components/applicant/final-documents-panel.tsx`
|
||||
|
||||
- [ ] **Step 1: Update the banner**
|
||||
|
||||
In `final-documents-banner.tsx`:
|
||||
|
||||
1. Guard (line 11): `if (!status || status.requirements.length === 0) return null`
|
||||
2. Replace the `done` computation (line 18) and add the mode flag:
|
||||
|
||||
```ts
|
||||
const optionalMode = !status.hasRequired
|
||||
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded
|
||||
```
|
||||
|
||||
3. Replace the title span (lines 26-28):
|
||||
|
||||
```tsx
|
||||
<span className="font-semibold">
|
||||
{done
|
||||
? optionalMode ? 'Grand Final documents uploaded' : 'Grand Final documents submitted'
|
||||
: optionalMode ? 'Upload updated Grand Final documents (optional)' : 'Upload your Grand Final documents'}
|
||||
</span>
|
||||
```
|
||||
|
||||
Everything else (styling, checklist, deadline, button gated on `!done`) stays as is.
|
||||
|
||||
- [ ] **Step 2: Update the panel**
|
||||
|
||||
In `final-documents-panel.tsx`:
|
||||
|
||||
1. Guard (line 21): `if (!status || status.requirements.length === 0) return null`
|
||||
2. Add after the guard: `const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded`
|
||||
3. Replace both `status.allRequiredUploaded` usages (badge at line 29, team upload-button gate at line 51) with `done`.
|
||||
4. Badge label: `{status.hasRequired ? 'Submitted' : 'Uploaded'}`
|
||||
5. Description (line 38) — append the optional hint:
|
||||
|
||||
```tsx
|
||||
<CardDescription>
|
||||
{props.variant === 'team' ? 'Your final deliverables for the Grand Finale.' : 'This team\'s final deliverables for the Grand Finale.'}
|
||||
{!status.hasRequired && ' These uploads are optional.'}
|
||||
</CardDescription>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Typecheck**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
Expected: clean. (The tRPC client types pick up `hasRequired`/`allUploaded` from Task 1 automatically.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/applicant/final-documents-banner.tsx src/components/applicant/final-documents-panel.tsx
|
||||
git commit -m "feat(final-docs): optional-mode rendering for finalist banner + panel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Admin "Documents shown to judges" card
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/admin/grand-finale/review-docs-picker.tsx`
|
||||
- Modify: `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (import near line 100; render near line 1535)
|
||||
|
||||
- [ ] **Step 1: Create the picker component**
|
||||
|
||||
`src/components/admin/grand-finale/review-docs-picker.tsx` (full file):
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { toast } from 'sonner'
|
||||
import { Eye } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Admin picker: which previously-submitted documents finale judges see on the
|
||||
* review page. Default (switch off) shows everything; switching to curated
|
||||
* mode starts with all slots ticked, and the admin unticks what to hide.
|
||||
* Grand Final round uploads are always visible regardless.
|
||||
*/
|
||||
export function ReviewDocsPicker({ programId, roundId }: { programId: string; roundId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data } = trpc.finalist.getReviewDocSettings.useQuery({ programId, roundId })
|
||||
const set = trpc.finalist.setReviewVisibleRequirements.useMutation({
|
||||
onSuccess: () => utils.finalist.getReviewDocSettings.invalidate({ programId, roundId }),
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
if (!data || data.options.length === 0) return null
|
||||
|
||||
const curated = data.selectedIds !== null
|
||||
const selected = new Set(data.selectedIds ?? data.options.map((o) => o.requirementId))
|
||||
const toggleSlot = (id: string, on: boolean) => {
|
||||
const next = new Set(selected)
|
||||
if (on) next.add(id)
|
||||
else next.delete(id)
|
||||
set.mutate({ roundId, requirementIds: [...next] })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Eye className="h-5 w-5" /> Documents shown to judges
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Choose which previously submitted documents judges see on the finalist review page.
|
||||
Documents uploaded directly to this Grand Final round are always visible.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="curate-review-docs"
|
||||
checked={curated}
|
||||
disabled={set.isPending}
|
||||
onCheckedChange={(v) =>
|
||||
set.mutate({ roundId, requirementIds: v ? data.options.map((o) => o.requirementId) : null })}
|
||||
/>
|
||||
<Label htmlFor="curate-review-docs" className="text-sm text-muted-foreground cursor-pointer">
|
||||
{curated ? 'Curated — judges see only the checked documents' : 'Showing all submitted documents'}
|
||||
</Label>
|
||||
</div>
|
||||
{curated && (
|
||||
<div className="space-y-2">
|
||||
{data.options.map((o) => (
|
||||
<label key={o.requirementId} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={selected.has(o.requirementId)}
|
||||
disabled={set.isPending}
|
||||
onCheckedChange={(v) => toggleSlot(o.requirementId, v === true)}
|
||||
/>
|
||||
<span>{o.name} — {o.roundName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({o.fileCount} file{o.fileCount === 1 ? '' : 's'})
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire into the round admin page**
|
||||
|
||||
In `src/app/(admin)/admin/rounds/[roundId]/page.tsx`:
|
||||
|
||||
Import (next to the other grand-finale imports, ~line 100):
|
||||
|
||||
```tsx
|
||||
import { ReviewDocsPicker } from '@/components/admin/grand-finale/review-docs-picker'
|
||||
```
|
||||
|
||||
Render inside the existing `isGrandFinale && programId` block, directly after the flex row containing `<FinalDocsUploadsToggle …>` (the `</div>` around line 1545):
|
||||
|
||||
```tsx
|
||||
<ReviewDocsPicker programId={programId} roundId={roundId} />
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Typecheck**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/admin/grand-finale/review-docs-picker.tsx "src/app/(admin)/admin/rounds/[roundId]/page.tsx"
|
||||
git commit -m "feat(final-docs): admin card to curate documents shown to finale judges"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Full verification
|
||||
|
||||
**Files:** none (verification only)
|
||||
|
||||
- [ ] **Step 1: Full test suite**
|
||||
|
||||
Run: `npx vitest run`
|
||||
Expected: all tests pass (was 321 before this work; now more).
|
||||
|
||||
- [ ] **Step 2: Lint + typecheck + build**
|
||||
|
||||
Run: `npm run lint && npm run typecheck && npm run build`
|
||||
Expected: all clean. (CLAUDE.md: always build before push.)
|
||||
|
||||
- [ ] **Step 3: Manual smoke (dev server + Playwright or browser)**
|
||||
|
||||
1. As admin, open the Grand Final round page → the "Documents shown to judges" card lists the prior-round slots with counts; flip to curated, untick one slot.
|
||||
2. Open `/admin/finals-documents` → the unticked document type disappears from every team; any Grand Final uploads remain.
|
||||
3. Flip the curation switch off → all documents reappear.
|
||||
4. With `allowFinalistRevisedUploads` ON and all finale slots optional (set in dev data), check the applicant dashboard banner shows "Upload updated Grand Final documents (optional)" and turns green only when every slot is filled.
|
||||
|
||||
- [ ] **Step 4: Commit anything outstanding**
|
||||
|
||||
```bash
|
||||
git status --short # should be clean except untracked screenshots/docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed at planning time)
|
||||
|
||||
- **Spec coverage:** configJson key + semantics → Tasks 2/4; always-visible finale uploads → Task 2; requirement-less files excluded under selection → Task 2; picker options with counts → Task 3; admin UI next to toggle → Task 6; `hasRequired`/`allUploaded` + zero-slot edge → Tasks 1/5; sibling-key preservation + audit → Task 4; reminders unchanged → verified in spec, no task needed.
|
||||
- **Placeholder scan:** none.
|
||||
- **Type consistency:** `ReviewDocSlot`, `reviewVisibleRequirementIds(configJson)`, `getReviewDocSettings`/`setReviewVisibleRequirements`, `hasRequired`/`allUploaded` used consistently across tasks.
|
||||
1646
docs/superpowers/plans/2026-06-09-grand-final-documents.md
Normal file
1646
docs/superpowers/plans/2026-06-09-grand-final-documents.md
Normal file
File diff suppressed because it is too large
Load Diff
592
docs/superpowers/plans/2026-06-10-grand-finale-ceremony.md
Normal file
592
docs/superpowers/plans/2026-06-10-grand-finale-ceremony.md
Normal file
@@ -0,0 +1,592 @@
|
||||
# Grand Finale Ceremony System Implementation Plan
|
||||
|
||||
> **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:** Ship the full Option-C grand-finale ceremony system (admin-driven presentation phases with real timers + overtime log, audience QR favorite-voting with per-category windows, persisted juror notes/comments, deliberation completion, big-screen ceremony view with cinematic results reveal) before the 2026-06-11 event.
|
||||
|
||||
**Architecture:** Extend the existing `LiveProgressCursor` with a per-project phase state machine and server-stamped timers; extend `LiveVotingSession` with audience-window state and a new `AudienceFavoriteVote` pick-one model; big screen is a pure derivation of existing state plus a small `RevealState` controller. No new session-level phase machine.
|
||||
|
||||
**Tech Stack:** Next.js 15 App Router, tRPC 11, Prisma 6/PostgreSQL, Tailwind 4 + shadcn/ui, `motion` v11 (already installed) for reveal animations, `qrcode.react` (new tiny dep), Vitest 4.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-10-grand-finale-ceremony-design.md`
|
||||
|
||||
**Critical context for the implementer:**
|
||||
- Two parallel live systems exist: `live.ts` (LiveProgressCursor, cohort-based votes) and `live-voting.ts` (LiveVotingSession, jury criteria votes + audience tokens). The finale uses **cursor for presentation flow** and **LiveVotingSession for all voting**. The cohort-based `castVote`/`castStageVote` in `live.ts` are NOT used for the finale — leave them alone.
|
||||
- Source of truth for presentation order: `round.configJson.projectOrder` (managed by `live.start`/`live.reorder`).
|
||||
- Known bug: jury live page passes `params.roundId` as `sessionId` to `getSessionForVoting` → NOT_FOUND. Fixed in Task 7.
|
||||
- Known bug: deliberation jury page has `juryMemberId: ''` and `hasVoted = false` hardcoded. Fixed in Task 10.
|
||||
- `LiveVotingSession.roundId` is `@unique`, so by-round lookup is safe.
|
||||
- Project category field is `competitionCategory` (`STARTUP | BUSINESS_CONCEPT`), nullable.
|
||||
- **NEVER** run `prisma migrate dev` if `migrate status` shows drift (memory rule) — use the create-only + `db execute` + `migrate resolve` path in Task 2.
|
||||
- Run tests with `npx vitest run tests/unit/<file>` (sequential forks pool). Build check: `npm run build`. Always build before push.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Public paths for audience + ceremony routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/auth.config.ts:52-65`
|
||||
- Test: `tests/unit/auth-public-paths.test.ts` (extend existing)
|
||||
|
||||
- [ ] **Step 1: Extend the existing public-paths test** — read `tests/unit/auth-public-paths.test.ts` first and follow its existing assertion style; add cases asserting `/vote/competition/abc`, `/vote/xyz`, `/live-scores/xyz`, `/live/ceremony/abc` are public and that `/live` alone (jury route prefix is `/jury/...` so no conflict) does not accidentally open admin routes (assert `/admin` still private).
|
||||
- [ ] **Step 2: Run** `npx vitest run tests/unit/auth-public-paths.test.ts` — expect new cases FAIL.
|
||||
- [ ] **Step 3: Implement** — in `src/lib/auth.config.ts` add to `publicPaths`:
|
||||
|
||||
```ts
|
||||
'/vote', // audience QR voting (token-based, no account)
|
||||
'/live-scores', // public live scoreboard
|
||||
'/live/ceremony', // big-screen ceremony view (projector)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test again** — expect PASS. Also `curl -sI http://localhost:3000/vote/competition/x | head -3` (dev server) → must NOT be a redirect to /login.
|
||||
- [ ] **Step 5: Commit** `fix(auth): make audience vote, live-scores and ceremony routes public`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Schema migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `prisma/schema.prisma` (LiveProgressCursor ~2152, LiveVotingSession ~1165, LiveVote ~1202, AudienceVoter ~1230, Round, Project, User back-relations)
|
||||
- Create: `prisma/migrations/<ts>_grand_finale_ceremony/migration.sql`
|
||||
|
||||
- [ ] **Step 1: Add enums** (near other enums):
|
||||
|
||||
```prisma
|
||||
enum LivePhase {
|
||||
ON_DECK
|
||||
PRESENTING
|
||||
QA
|
||||
SCORING
|
||||
}
|
||||
|
||||
enum AudiencePhase {
|
||||
CLOSED
|
||||
OPEN
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend `LiveProgressCursor`:**
|
||||
|
||||
```prisma
|
||||
projectPhase LivePhase @default(ON_DECK)
|
||||
phaseStartedAt DateTime?
|
||||
phaseDurationSeconds Int?
|
||||
phasePausedAt DateTime?
|
||||
phasePausedAccumMs Int @default(0)
|
||||
timingLogJson Json? @db.JsonB // [{projectId, phase, startedAt, endedAt, configuredSeconds, overranSeconds}]
|
||||
overrideSlide String? // 'welcome' | 'break' | 'deliberation' | 'thanks'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Extend `LiveVotingSession`:**
|
||||
|
||||
```prisma
|
||||
// Audience favorite-vote window (grand finale)
|
||||
audiencePhase AudiencePhase @default(CLOSED)
|
||||
audienceWindowKey String? // 'CATEGORY:STARTUP' | 'CATEGORY:BUSINESS_CONCEPT' | 'OVERALL'
|
||||
audienceWindowOpenedAt DateTime?
|
||||
audienceWindowClosesAt DateTime?
|
||||
allowOverallFavorite Boolean @default(false)
|
||||
```
|
||||
|
||||
and relations `favoriteVotes AudienceFavoriteVote[]`, `revealState RevealState?`.
|
||||
|
||||
- [ ] **Step 4: Extend `LiveVote`** with `comment String? @db.Text`, and `AudienceVoter` with `favoriteVotes AudienceFavoriteVote[]`.
|
||||
- [ ] **Step 5: New models** (after AudienceVoter):
|
||||
|
||||
```prisma
|
||||
model AudienceFavoriteVote {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
windowKey String // matches LiveVotingSession.audienceWindowKey at cast time
|
||||
projectId String
|
||||
audienceVoterId String
|
||||
ipAddress String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
audienceVoter AudienceVoter @relation(fields: [audienceVoterId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([sessionId, windowKey, audienceVoterId])
|
||||
@@index([sessionId, windowKey, ipAddress])
|
||||
@@index([sessionId, windowKey, projectId])
|
||||
}
|
||||
|
||||
model LiveNote {
|
||||
id String @id @default(cuid())
|
||||
roundId String
|
||||
projectId String
|
||||
userId String
|
||||
content String @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([roundId, projectId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model RevealState {
|
||||
id String @id @default(cuid())
|
||||
sessionId String @unique
|
||||
status String @default("DRAFT") // DRAFT | ARMED | REVEALING | DONE
|
||||
stepsJson Json @db.JsonB // RevealStep[] — see Task 8
|
||||
currentStepIndex Int @default(-1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
```
|
||||
|
||||
Add back-relations: `Project.audienceFavoriteVotes AudienceFavoriteVote[]`, `Project.liveNotes LiveNote[]`, `Round.liveNotes LiveNote[]`, `User.liveNotes LiveNote[]`.
|
||||
|
||||
- [ ] **Step 6: Migrate safely.** `npx prisma migrate status` first. If clean: `npx prisma migrate dev --name grand_finale_ceremony`. If drifted: `npx prisma migrate dev --create-only --name grand_finale_ceremony`, review SQL, then `npx prisma db execute --file prisma/migrations/<ts>_grand_finale_ceremony/migration.sql` and `npx prisma migrate resolve --applied <ts>_grand_finale_ceremony`. Then `npx prisma generate`.
|
||||
- [ ] **Step 7: Verify** `npm run typecheck` passes (pre-existing errors aside). Commit `feat(finale): schema for phases, audience windows, favorite votes, notes, reveal`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Timer helper `src/lib/live-timer.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/live-timer.ts`
|
||||
- Test: `tests/unit/live-timer.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests** (pure functions, no DB):
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { elapsedMs, remainingSeconds, formatClock } from '@/lib/live-timer'
|
||||
|
||||
const t0 = new Date('2026-06-11T10:00:00Z')
|
||||
const at = (s: number) => new Date(t0.getTime() + s * 1000)
|
||||
|
||||
describe('live-timer', () => {
|
||||
it('elapsedMs counts from phaseStartedAt', () => {
|
||||
expect(elapsedMs({ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: null, phasePausedAccumMs: 0 }, at(90))).toBe(90_000)
|
||||
})
|
||||
it('elapsedMs freezes while paused and subtracts accumulated pause', () => {
|
||||
// paused at +60s, asked at +90s → frozen at 60s
|
||||
expect(elapsedMs({ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: at(60), phasePausedAccumMs: 0 }, at(90))).toBe(60_000)
|
||||
// resumed with 30s pause accumulated, asked at +120s → 90s elapsed
|
||||
expect(elapsedMs({ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: null, phasePausedAccumMs: 30_000 }, at(120))).toBe(90_000)
|
||||
})
|
||||
it('remainingSeconds goes negative on overtime', () => {
|
||||
expect(remainingSeconds({ phaseStartedAt: t0, phaseDurationSeconds: 60, phasePausedAt: null, phasePausedAccumMs: 0 }, at(75))).toBe(-15)
|
||||
})
|
||||
it('remainingSeconds is null without timer', () => {
|
||||
expect(remainingSeconds({ phaseStartedAt: null, phaseDurationSeconds: null, phasePausedAt: null, phasePausedAccumMs: 0 }, t0)).toBeNull()
|
||||
})
|
||||
it('formatClock renders mm:ss and overtime', () => {
|
||||
expect(formatClock(305)).toBe('5:05')
|
||||
expect(formatClock(0)).toBe('0:00')
|
||||
expect(formatClock(-83)).toBe('+1:23')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run** — FAIL (module not found).
|
||||
- [ ] **Step 3: Implement:**
|
||||
|
||||
```ts
|
||||
export type PhaseTimerState = {
|
||||
phaseStartedAt: Date | string | null
|
||||
phaseDurationSeconds: number | null
|
||||
phasePausedAt: Date | string | null
|
||||
phasePausedAccumMs: number
|
||||
}
|
||||
|
||||
export function elapsedMs(t: PhaseTimerState, now: Date = new Date()): number {
|
||||
if (!t.phaseStartedAt) return 0
|
||||
const start = new Date(t.phaseStartedAt).getTime()
|
||||
const end = t.phasePausedAt ? new Date(t.phasePausedAt).getTime() : now.getTime()
|
||||
return Math.max(0, end - start - t.phasePausedAccumMs)
|
||||
}
|
||||
|
||||
export function remainingSeconds(t: PhaseTimerState, now: Date = new Date()): number | null {
|
||||
if (!t.phaseStartedAt || t.phaseDurationSeconds == null) return null
|
||||
return t.phaseDurationSeconds - Math.floor(elapsedMs(t, now) / 1000)
|
||||
}
|
||||
|
||||
export function formatClock(seconds: number): string {
|
||||
const over = seconds < 0
|
||||
const abs = Math.abs(seconds)
|
||||
const m = Math.floor(abs / 60)
|
||||
const s = abs % 60
|
||||
return `${over ? '+' : ''}${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): server-stamped phase timer helper`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Phase machine + notes in `live.ts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/live.ts`
|
||||
- Test: `tests/unit/live-phase.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests.** Use `createCaller(liveRouter, adminUser)` + factories (`createTestProgram/Competition/Round/Project`, round status `ROUND_ACTIVE`, `live.start` with 2 projects). Cases:
|
||||
- `sendToScreens` sets `projectPhase='ON_DECK'`, target project active, timer fields null, `overrideSlide` cleared.
|
||||
- `startPresentation` → `PRESENTING`, `phaseStartedAt` set, `phaseDurationSeconds` from input (e.g. 120) else from `round.configJson.presentationDurationMinutes*60` else 300.
|
||||
- `startQA` after PRESENTING appends a timing-log entry `{projectId, phase:'PRESENTING', configuredSeconds, overranSeconds}` and starts QA timer.
|
||||
- `openScoring` appends QA entry, phase `SCORING`, timer cleared.
|
||||
- `pausePhase`/`resumePhase`: after pause, `phasePausedAt` set; resume folds into `phasePausedAccumMs` and clears `phasePausedAt`; pausing twice errors; resuming unpaused errors.
|
||||
- overtime: startPresentation with `durationSeconds: 1`, manipulate by directly `prisma.liveProgressCursor.update({phaseStartedAt: new Date(Date.now()-10_000)})`, then `startQA` → log entry `overranSeconds >= 9`.
|
||||
- `setOverrideSlide` sets/clears.
|
||||
- `saveNote` upserts by (roundId, projectId, userId); second save with same juror overwrites content; `getMyNotes` returns only caller's notes.
|
||||
- [ ] **Step 2: Run** — FAIL (procedures missing).
|
||||
- [ ] **Step 3: Implement in `live.ts`.** Shared helper at top of file:
|
||||
|
||||
```ts
|
||||
type TimingEntry = {
|
||||
projectId: string
|
||||
phase: 'PRESENTING' | 'QA'
|
||||
startedAt: string
|
||||
endedAt: string
|
||||
configuredSeconds: number | null
|
||||
overranSeconds: number
|
||||
}
|
||||
|
||||
function closedOutTiming(cursor: {
|
||||
activeProjectId: string | null
|
||||
projectPhase: string
|
||||
phaseStartedAt: Date | null
|
||||
phaseDurationSeconds: number | null
|
||||
phasePausedAt: Date | null
|
||||
phasePausedAccumMs: number
|
||||
timingLogJson: unknown
|
||||
}, now: Date): Prisma.InputJsonValue | undefined {
|
||||
if (!cursor.phaseStartedAt || !cursor.activeProjectId) return undefined
|
||||
if (cursor.projectPhase !== 'PRESENTING' && cursor.projectPhase !== 'QA') return undefined
|
||||
const end = cursor.phasePausedAt ?? now
|
||||
const elapsedSec = Math.max(0, Math.floor((end.getTime() - cursor.phaseStartedAt.getTime() - cursor.phasePausedAccumMs) / 1000))
|
||||
const entry: TimingEntry = {
|
||||
projectId: cursor.activeProjectId,
|
||||
phase: cursor.projectPhase,
|
||||
startedAt: cursor.phaseStartedAt.toISOString(),
|
||||
endedAt: now.toISOString(),
|
||||
configuredSeconds: cursor.phaseDurationSeconds,
|
||||
overranSeconds: cursor.phaseDurationSeconds == null ? 0 : Math.max(0, elapsedSec - cursor.phaseDurationSeconds),
|
||||
}
|
||||
const log = Array.isArray(cursor.timingLogJson) ? (cursor.timingLogJson as TimingEntry[]) : []
|
||||
return [...log, entry] as unknown as Prisma.InputJsonValue
|
||||
}
|
||||
|
||||
async function getRoundDurations(prisma: PrismaClient, roundId: string) {
|
||||
const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId } })
|
||||
const cfg = (round.configJson as Record<string, unknown>) ?? {}
|
||||
return {
|
||||
presentation: typeof cfg.presentationDurationMinutes === 'number' ? cfg.presentationDurationMinutes * 60 : 300,
|
||||
qa: typeof cfg.qaDurationMinutes === 'number' ? cfg.qaDurationMinutes * 60 : 300,
|
||||
projectOrder: (cfg.projectOrder as string[]) ?? [],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Mutations (all `adminProcedure`, all audit-logged following the file's existing `logAudit` pattern with actions `LIVE_SEND_TO_SCREENS`, `LIVE_PHASE_STARTED`, `LIVE_PHASE_PAUSED`, `LIVE_PHASE_RESUMED`, `LIVE_OVERRIDE_SLIDE`):
|
||||
|
||||
```ts
|
||||
sendToScreens: input {roundId, projectId} → cursor findUniqueOrThrow by roundId; durations+order;
|
||||
index = order.indexOf(projectId) (BAD_REQUEST if -1);
|
||||
update: { activeProjectId, activeOrderIndex: index, projectPhase: 'ON_DECK',
|
||||
phaseStartedAt: null, phaseDurationSeconds: null, phasePausedAt: null, phasePausedAccumMs: 0,
|
||||
overrideSlide: null, timingLogJson: closedOutTiming(cursor, now) }
|
||||
|
||||
startPresentation: input {roundId, durationSeconds?: z.number().int().min(10).max(7200).optional()}
|
||||
→ update { projectPhase: 'PRESENTING', phaseStartedAt: now,
|
||||
phaseDurationSeconds: input.durationSeconds ?? durations.presentation,
|
||||
phasePausedAt: null, phasePausedAccumMs: 0, timingLogJson: closedOutTiming(cursor, now) }
|
||||
|
||||
startQA: same shape, phase 'QA', default durations.qa
|
||||
openScoring: { projectPhase: 'SCORING', phaseStartedAt: null, phaseDurationSeconds: null,
|
||||
phasePausedAt: null, phasePausedAccumMs: 0, timingLogJson: closedOutTiming(cursor, now) }
|
||||
|
||||
pausePhase: PRECONDITION_FAILED if !cursor.phaseStartedAt || cursor.phasePausedAt; set phasePausedAt: now
|
||||
resumePhase: PRECONDITION_FAILED if !cursor.phasePausedAt;
|
||||
set phasePausedAccumMs: cursor.phasePausedAccumMs + (now - cursor.phasePausedAt), phasePausedAt: null
|
||||
|
||||
setOverrideSlide: input {roundId, slide: z.enum(['welcome','break','deliberation','thanks']).nullable()}
|
||||
→ update { overrideSlide: input.slide }
|
||||
```
|
||||
|
||||
Notes procedures (`protectedProcedure`):
|
||||
|
||||
```ts
|
||||
saveNote: input {roundId, projectId, content: z.string().max(20_000)} →
|
||||
prisma.liveNote.upsert({ where: { roundId_projectId_userId: { roundId, projectId, userId: ctx.user.id } },
|
||||
create: {...}, update: { content } })
|
||||
getMyNotes: input {roundId} → prisma.liveNote.findMany({ where: { roundId, userId: ctx.user.id } })
|
||||
```
|
||||
|
||||
Extend `getCursor` return: spread now includes the new cursor fields automatically (`...cursor`); additionally fetch `orderedProjects` (id, title, teamName, competitionCategory) for the whole `projectOrder` (one `findMany` + reorder in JS) and include `activeProject.competitionCategory` in its select.
|
||||
|
||||
- [ ] **Step 4: Run** `npx vitest run tests/unit/live-phase.test.ts` — PASS. Run `npx vitest run tests/unit/auth-public-paths.test.ts` too (regression).
|
||||
- [ ] **Step 5: Commit** `feat(finale): per-project phase machine, server timers, overtime log, juror notes`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Audience windows + favorite votes in `live-voting.ts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/live-voting.ts`
|
||||
- Test: `tests/unit/audience-window.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests.** Setup: program/competition/round (LIVE_FINAL, ROUND_ACTIVE), 3 projects (2 STARTUP, 1 BUSINESS_CONCEPT via `prisma.project.update` setting `competitionCategory`), `round.configJson.projectOrder` set, LiveVotingSession created with `allowAudienceVotes: true`, two AudienceVoter rows (tokens A, B). Cases:
|
||||
1. `openAudienceWindow({windowKey:'CATEGORY:STARTUP', durationMinutes:5})` → phase OPEN, closesAt ≈ now+5m. Opening again → CONFLICT.
|
||||
2. `castFavoriteVote` token A for STARTUP project → row created. Re-cast token A other STARTUP project → same row updated (count still 1).
|
||||
3. Cast for the BUSINESS_CONCEPT project while STARTUP window open → BAD_REQUEST.
|
||||
4. Set `audienceWindowClosesAt` to past via prisma, cast → PRECONDITION_FAILED (server-side time check, no cron).
|
||||
5. `closeAudienceWindow` then cast → PRECONDITION_FAILED. Re-open works (new window, key CATEGORY:BUSINESS_CONCEPT) → casting BUSINESS_CONCEPT project OK.
|
||||
6. `openAudienceWindow({windowKey:'OVERALL'})` with `allowOverallFavorite:false` → FORBIDDEN; after `updateSessionConfig({allowOverallFavorite:true})` → OK; any ordered project castable.
|
||||
7. IP cap: create 3 voters with ctx ip '1.2.3.4' casting in same window (use `createTestContext` with custom ip — check `tests/setup.ts` signature; if ip not injectable, set `ipAddress` on rows directly and cast the 4th via caller whose ctx.ip is '1.2.3.4') → 4th distinct voter from same IP → TOO_MANY_REQUESTS. A voter updating their own vote from that IP still succeeds.
|
||||
8. `getFavoriteTallies` returns per-windowKey per-project counts.
|
||||
9. `getAudienceWindow` (public) reports phase CLOSED once `closesAt` past even without an explicit close, includes eligible projects in order, and `myVote` for a token.
|
||||
- [ ] **Step 2: Run** — FAIL.
|
||||
- [ ] **Step 3: Implement.** Zod: `const windowKeySchema = z.enum(['CATEGORY:STARTUP', 'CATEGORY:BUSINESS_CONCEPT', 'OVERALL'])`. Helper in file:
|
||||
|
||||
```ts
|
||||
function windowIsOpen(s: { audiencePhase: string; audienceWindowClosesAt: Date | null }, now = new Date()) {
|
||||
return s.audiencePhase === 'OPEN' && !!s.audienceWindowClosesAt && now <= s.audienceWindowClosesAt
|
||||
}
|
||||
function categoryForKey(key: string): 'STARTUP' | 'BUSINESS_CONCEPT' | null {
|
||||
return key === 'CATEGORY:STARTUP' ? 'STARTUP' : key === 'CATEGORY:BUSINESS_CONCEPT' ? 'BUSINESS_CONCEPT' : null
|
||||
}
|
||||
async function getOrderedFinaleProjects(prisma: PrismaClient, session: { roundId: string | null; projectOrderJson: unknown }) {
|
||||
let order: string[] = []
|
||||
if (session.roundId) {
|
||||
const round = await prisma.round.findUnique({ where: { id: session.roundId } })
|
||||
order = ((round?.configJson as Record<string, unknown>)?.projectOrder as string[]) ?? []
|
||||
}
|
||||
if (order.length === 0) order = (session.projectOrderJson as string[]) ?? []
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { id: { in: order } },
|
||||
select: { id: true, title: true, teamName: true, competitionCategory: true },
|
||||
})
|
||||
const byId = new Map(projects.map((p) => [p.id, p]))
|
||||
return order.map((id) => byId.get(id)).filter(Boolean) as typeof projects
|
||||
}
|
||||
```
|
||||
|
||||
Procedures:
|
||||
|
||||
```ts
|
||||
openAudienceWindow (adminProcedure): input {sessionId, windowKey: windowKeySchema, durationMinutes: z.number().int().min(1).max(120).default(5)}
|
||||
→ session findUniqueOrThrow; if windowIsOpen(session) → CONFLICT 'An audience window is already open';
|
||||
if windowKey==='OVERALL' && !session.allowOverallFavorite → FORBIDDEN 'Overall favorite vote is not enabled';
|
||||
update { audiencePhase:'OPEN', audienceWindowKey: windowKey, audienceWindowOpenedAt: now,
|
||||
audienceWindowClosesAt: new Date(now + durationMinutes*60_000) }; audit 'AUDIENCE_WINDOW_OPENED'.
|
||||
|
||||
closeAudienceWindow (adminProcedure): update { audiencePhase:'CLOSED', audienceWindowKey:null,
|
||||
audienceWindowOpenedAt:null, audienceWindowClosesAt:null }; audit 'AUDIENCE_WINDOW_CLOSED'.
|
||||
|
||||
getAudienceWindow (publicProcedure): input {sessionId, token: z.string().optional()} →
|
||||
session select audiencePhase/windowKey/closesAt/allowAudienceVotes/roundId/projectOrderJson;
|
||||
const open = windowIsOpen(session); const key = open ? session.audienceWindowKey : null;
|
||||
projects = open ? getOrderedFinaleProjects(...).filter(p => { const cat = categoryForKey(key!); return cat ? p.competitionCategory === cat : true }) : [];
|
||||
myVote: if token && key → voter by token → favoriteVote findUnique by (sessionId, windowKey:key, audienceVoterId) → projectId;
|
||||
return { open, windowKey: key, closesAt: open ? session.audienceWindowClosesAt : null, projects, myVoteProjectId }
|
||||
|
||||
castFavoriteVote (publicProcedure): input {sessionId, token, projectId} →
|
||||
voter by token (UNAUTHORIZED if missing/mismatched session);
|
||||
session findUniqueOrThrow; if !windowIsOpen → PRECONDITION_FAILED 'Voting is not open right now';
|
||||
const key = session.audienceWindowKey!; const cat = categoryForKey(key);
|
||||
project findUniqueOrThrow select competitionCategory; ordered = getOrderedFinaleProjects(...);
|
||||
if (!ordered.some(p => p.id === input.projectId)) → BAD_REQUEST 'Project is not part of this vote';
|
||||
if (cat && project.competitionCategory !== cat) → BAD_REQUEST 'Project is not in the open category';
|
||||
existing = favoriteVote findUnique (sessionId, windowKey:key, audienceVoterId: voter.id);
|
||||
if (!existing && ctx.ip) { const ipCount = await prisma.audienceFavoriteVote.count({ where: { sessionId, windowKey: key, ipAddress: ctx.ip } });
|
||||
if (ipCount >= 3) → TOO_MANY_REQUESTS 'Vote limit reached for this network' }
|
||||
upsert (update: { projectId, ipAddress: ctx.ip ?? existing?.ipAddress }); return { projectId }
|
||||
|
||||
getFavoriteTallies (adminProcedure): input {sessionId} →
|
||||
groupBy ['windowKey','projectId'] _count; plus projects (title/teamName) join; plus per-window total counts.
|
||||
```
|
||||
|
||||
Add `allowOverallFavorite: z.boolean().optional()` to `updateSessionConfig` input (passes straight through to `data`).
|
||||
|
||||
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): audience favorite-vote windows with category gating + IP cap`.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Jury vote comment + by-round session resolution + my-votes query
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/live-voting.ts`
|
||||
- Test: `tests/unit/live-vote-comment.test.ts`
|
||||
|
||||
- [ ] **Step 1: Failing tests:** (a) `vote` with `comment: 'strong pitch'` persists it; re-vote updates it; (b) new `getSessionForVotingByRound({roundId})` returns the same payload shape as `getSessionForVoting` and creates nothing (null when no session); (c) new `getMyFinaleInputs({roundId})` returns caller's LiveVotes (score, criterionScoresJson, comment, projectId) and LiveNotes for the round.
|
||||
- [ ] **Step 2: Run** — FAIL.
|
||||
- [ ] **Step 3: Implement:**
|
||||
- `vote` input gains `comment: z.string().max(5000).optional()`; include in upsert create/update (`comment: input.comment ?? undefined` on update so an omitted comment doesn't erase).
|
||||
- `getSessionForVotingByRound` (protectedProcedure): `findUnique({ where: { roundId } })`; if null return null; else reuse the body of `getSessionForVoting` (extract a shared `buildVotingPayload(session, ctx)` helper used by both procedures — DRY).
|
||||
- `getMyFinaleInputs` (protectedProcedure): input `{roundId}` → session by roundId (null-safe) → `liveVote.findMany({ where: { sessionId, userId: ctx.user.id } , select: { projectId, score, criterionScoresJson, comment, votedAt } })` + `liveNote.findMany({ where: { roundId, userId: ctx.user.id } })`. Return `{ votes, notes, session: { id, votingMode, criteriaJson } | null }`.
|
||||
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): vote comments, by-round session lookup, my-finale-inputs query`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Jury live page rework
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(jury)/jury/competitions/[roundId]/live/page.tsx`
|
||||
- Modify: `src/components/jury/live-voting-form.tsx` (add comment field — read it first; pass `comment` through `onVoteSubmit`)
|
||||
|
||||
No DB logic here; verified by build + Playwright in Task 13. Behaviors:
|
||||
|
||||
- [ ] **Step 1: Fix session wiring** — replace `getSessionForVoting({sessionId: params.roundId})` with `getSessionForVotingByRound({roundId: params.roundId})` (poll 2000ms). Keep `live.getCursor` poll at 2000ms (was 5000 — tighten for ceremony).
|
||||
- [ ] **Step 2: Phase rendering** from `cursor.projectPhase`:
|
||||
- `ON_DECK`: full-width banner card — "Up next" eyebrow, project title XL, team name; muted note "Presentation starting shortly". No scoring form.
|
||||
- `PRESENTING` / `QA`: project card with phase badge (`Presentation` / `Q&A`) and live countdown chip using `remainingSeconds`/`formatClock` from `@/lib/live-timer` (tick via 1s `setInterval`, computed from server stamps — never local countdown state); red text when negative. Notes + scoring form below.
|
||||
- `SCORING`: same but scoring card gets a highlighted ring (`ring-2 ring-[#de0f1e]`) and a "Scoring is open" badge.
|
||||
- [ ] **Step 3: Persisted notes** — replace local `notes` state: load via `trpc.live.getMyNotes({roundId})`, keep a `Record<projectId, string>` local draft, debounce 800ms → `trpc.live.saveNote.mutate({roundId, projectId, content})`; show "Saved" / "Saving…" microcopy. Notes keyed per active project (switching project switches the note).
|
||||
- [ ] **Step 4: Comment field** — add optional `Textarea` "Comment (visible to admins with your scores)" inside the voting form submission; include `comment` in `vote` mutation.
|
||||
- [ ] **Step 5:** `npm run build` green. Commit `feat(finale): phase-aware jury live page with persisted notes + comments`.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Reveal controller (backend)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/live-voting.ts`
|
||||
- Test: `tests/unit/reveal.test.ts`
|
||||
|
||||
- [ ] **Step 1: Failing tests:** (a) `saveReveal` upserts steps in DRAFT; (b) `armReveal` requires ≥1 step, DRAFT→ARMED; (c) `revealNext` ARMED→REVEALING idx 0, increments, last step → DONE (idx stays last); (d) `resetReveal` → DRAFT idx -1; (e) **no-leak:** `getCeremonyState` (public, Task 9 — write the test now against the procedure added there if sequencing demands; otherwise assert via a `getPublicReveal` helper) returns only steps `0..currentStepIndex`, empty when ARMED, none when DRAFT.
|
||||
- [ ] **Step 2: Run** — FAIL.
|
||||
- [ ] **Step 3: Implement.** Step schema:
|
||||
|
||||
```ts
|
||||
const revealStepSchema = z.object({
|
||||
kind: z.enum(['category-intro', 'place', 'audience-award', 'overall-favorite', 'thanks']),
|
||||
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
||||
place: z.number().int().min(1).max(10).optional(),
|
||||
projectId: z.string().optional(),
|
||||
title: z.string().max(200).optional(), // resolved display strings, stored denormalized
|
||||
subtitle: z.string().max(300).optional(),
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
saveReveal (adminProcedure): {sessionId, steps: z.array(revealStepSchema).max(50)} →
|
||||
revealState upsert by sessionId { stepsJson: steps, status: 'DRAFT', currentStepIndex: -1 }
|
||||
armReveal (adminProcedure): requires existing DRAFT with steps.length>0 → status 'ARMED'
|
||||
revealNext (adminProcedure): ARMED → { status:'REVEALING', currentStepIndex: 0 };
|
||||
REVEALING → idx+1; if idx+1 === steps.length-1 → also status 'DONE'…
|
||||
// careful: advance then check — newIndex = currentStepIndex + 1; clamp to steps.length-1;
|
||||
// status = newIndex >= steps.length - 1 ? 'DONE' : 'REVEALING'
|
||||
resetReveal (adminProcedure): → { status:'DRAFT', currentStepIndex: -1 }
|
||||
getRevealAdmin (adminProcedure): full state incl. all steps (for preview)
|
||||
```
|
||||
|
||||
Audit-log arm/next/reset with action names `REVEAL_ARMED`, `REVEAL_ADVANCED`, `REVEAL_RESET`.
|
||||
|
||||
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): results reveal controller with step-through state`.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Public ceremony state endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/live-voting.ts`
|
||||
- Test: extend `tests/unit/reveal.test.ts` + `tests/unit/audience-window.test.ts` no-leak/shape cases
|
||||
|
||||
- [ ] **Step 1: Failing test:** `getCeremonyState({roundId})` (publicProcedure) returns `{ overrideSlide, phase: {projectPhase, phaseStartedAt, phaseDurationSeconds, phasePausedAt, phasePausedAccumMs}, activeProject: {title, teamName, competitionCategory} | null, audience: { open, windowKey, closesAt, voteCount }, reveal: { status, steps: <revealed only>, currentStepIndex } | null, programName }`. Assert: never includes scores, never includes un-revealed steps, includes audience voteCount for the open window only.
|
||||
- [ ] **Step 2: Run** — FAIL.
|
||||
- [ ] **Step 3: Implement** — compose from `liveProgressCursor.findUnique({roundId})`, session by roundId, `audienceFavoriteVote.count({sessionId, windowKey})` when open, `revealState` (slice steps `0..currentStepIndex` only when REVEALING/DONE; `[]` when ARMED; null when DRAFT/absent). One procedure, ~60 lines.
|
||||
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): public ceremony-state endpoint for big screen`.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Deliberation jury completion
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(jury)/jury/competitions/deliberation/[sessionId]/page.tsx`
|
||||
- Modify: `src/server/services/deliberation.ts` (only if `getSessionWithVotes` lacks project list — verify first)
|
||||
- Modify: `src/server/routers/deliberation.ts` (add projects to getSession payload if needed)
|
||||
- Read first: `src/components/jury/deliberation-ranking-form.tsx`
|
||||
- Test: `tests/unit/deliberation-jury-wiring.test.ts`
|
||||
|
||||
- [ ] **Step 1: Investigate** `getSessionWithVotes` — confirm what `session.participants[].user` contains (expect JuryGroupMember incl. `user`), and where the rank-able project list comes from (`session.results` is empty before finalize — the form currently gets `[]`!). Decide: extend `getSessionWithVotes` to include `projects` = projects of the session's round + category (via `ProjectRoundState` where `roundId`, project `competitionCategory === session.category`), selecting id/title/teamName.
|
||||
- [ ] **Step 2: Failing test:** caller = juror user who is a JuryGroupMember + DeliberationParticipant; `deliberation.getSession` exposes `projects` (non-empty pre-finalize) and participant rows that let the client resolve `juryMemberId`; `submitVote` with that `juryMemberId` succeeds and `getSession` then shows the vote (`hasVoted` derivable). Also assert a juror cannot submit with another member's `juryMemberId` (existing enforcement — pin it).
|
||||
- [ ] **Step 3: Implement service/router change**, run test — PASS.
|
||||
- [ ] **Step 4: Fix the page:**
|
||||
|
||||
```ts
|
||||
const { data: me } = trpc.user.me.useQuery() // or useSession() — match codebase pattern (check src for existing usage)
|
||||
const myParticipant = session?.participants?.find((p: any) => p.user?.user?.id === me?.id)
|
||||
const juryMemberId = myParticipant?.user?.id ?? null // JuryGroupMember.id
|
||||
const hasVoted = !!session?.votes?.some((v: any) => v.juryMember?.user?.id === me?.id)
|
||||
```
|
||||
|
||||
Pass `projects={session.projects}` to `DeliberationRankingForm`. Submit all votes in ONE call sequence with `juryMemberId`; disable submit when `!juryMemberId` with explanatory text ("You are not a participant of this deliberation").
|
||||
|
||||
- [ ] **Step 5: Context panels** (below the ranking form, one collapsible card per project): my finale criteria scores + comment from `trpc.liveVoting.getMyFinaleInputs({roundId: session.roundId})` (criteria labels from `session` payload's criteriaJson), editable via the same `LiveVotingForm` in a dialog (submits `liveVoting.vote` — works because session status check is `IN_PROGRESS`; **verify**: if finale session will be COMPLETED by deliberation time, relax `vote`'s status guard to allow `IN_PROGRESS | PAUSED` and gate `currentProjectId` check to only apply when phase-voting — simplest: allow voting for any ordered project when `round.roundType === 'DELIBERATION'`-linked… **Decision:** add `allowRevote: true` behavior — `vote` accepts any `projectId` in the finale order when the session status is `IN_PROGRESS` or `PAUSED`; keep the `currentProjectId` equality check ONLY when `projectPhase` voting is live i.e. when the cursor's active project equals the voted project OR session.status === 'PAUSED'. Implement as: skip the `currentProjectId !== input.projectId` check when `input.projectId` is in the session's project order and the cursor for the round is in `SCORING` or session is `PAUSED`. Write a unit test for this relaxation.) Also show my `LiveNote` per project, and a link row to the finals documents page (route: check `src/app/(jury)` for the finals docs page added 2026-06-09 — link to it with the project preselected if supported, else plain link).
|
||||
- [ ] **Step 6:** Tests + `npm run build` green. Commit `feat(finale): working jury deliberation flow with finale-score review and notes`.
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Admin control panel revamp
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/admin/live/live-control-panel.tsx` (becomes orchestrator)
|
||||
- Create: `src/components/admin/live/run-order-list.tsx`, `phase-controls.tsx`, `audience-window-panel.tsx`, `timing-log-card.tsx`, `reveal-panel.tsx`
|
||||
- Modify: `package.json` (add `qrcode.react`)
|
||||
- Find the admin page hosting `LiveControlPanel` (grep usage) — ensure it passes `roundId` + `competitionId` and has room for the new layout (2-col grid on lg).
|
||||
|
||||
Pure UI — verified via Playwright in Task 13. Behaviors per component:
|
||||
|
||||
- [ ] **Step 1:** `npm i qrcode.react`.
|
||||
- [ ] **Step 2: `run-order-list.tsx`** — props `{roundId}`; uses `live.getCursor` data (`orderedProjects`, `activeProjectId`, `activeOrderIndex`). Groups rows under `BUSINESS_CONCEPT` / `STARTUP` headings (preserving global order); each row: index, title, teamName, category dot, ▲▼ buttons (swap in `projectOrder`, call `live.reorder`), and a "Send to screens" button (`live.sendToScreens`). Active row highlighted; ON_DECK row shows "on deck" badge.
|
||||
- [ ] **Step 3: `phase-controls.tsx`** — props `{roundId}`. Shows active project + phase badge; one primary button for the next transition (ON_DECK→"Start presentation", PRESENTING→"Start Q&A", QA→"Open scoring", SCORING→"Send next project" which calls `sendToScreens` with the next project in order); secondary buttons for pause/resume; the big server-derived countdown (`remainingSeconds`/`formatClock`, 1s tick, `text-red-600 animate-pulse` when negative with "OVER" label); duration override `Input` (minutes, prefilled from round config) applied to the next start call. Keep legacy session pause/resume (cursor.isPaused) as a small row.
|
||||
- [ ] **Step 4: `audience-window-panel.tsx`** — props `{roundId}`. Resolves session via `liveVoting.getSession({roundId})`. Buttons "Open vote — Business Concepts" / "Open vote — Startups" / "Open vote — Overall favorite" (last disabled unless `allowOverallFavorite`; a `Switch` toggles it via `updateSessionConfig`), shared duration `Input` (default 5). When open: countdown, live vote count (`getFavoriteTallies` poll 3s — render per-window totals; per-project tallies in a collapsible "Tallies (admin only)"), "Close now" `destructive` button. "Show QR" button → `Dialog` with `<QRCodeSVG value={origin + '/vote/competition/' + roundId} size={420}/>` + the URL printed beneath.
|
||||
- [ ] **Step 5: `timing-log-card.tsx`** — renders `cursor.timingLogJson` rows: project title (lookup from orderedProjects), phase, configured vs actual, overran chip (red `+m:ss`) when `overranSeconds > 0`.
|
||||
- [ ] **Step 6: `reveal-panel.tsx`** — props `{roundId}`. "Compose from results" button: pulls `liveVoting.getResults({sessionId})`, `getFavoriteTallies`, and `deliberation.listSessions({competitionId})` → for each category with a finalized deliberation use its results order, else fall back to jury `getResults` order filtered by category; builds default steps (category-intro → places 3,2,1 → audience-award per category → overall-favorite if tallies exist → thanks) with resolved `title` (team/project name) and `subtitle` ("3rd place — Business Concepts" etc.); shows editable preview list (delete/reorder steps); "Save draft" → `saveReveal`. Then "Arm" (confirm dialog: "Big screen will switch to Results mode"), "Reveal next" (primary, shows `currentStepIndex+1 / steps.length`), "Reset". Show current step preview text so the admin always knows what fires next.
|
||||
- [ ] **Step 7:** Compose all into `live-control-panel.tsx` (left col: phase-controls + run-order-list; right col: audience-window-panel + timing-log-card + reveal-panel). `npm run build` green. Commit `feat(finale): admin ceremony control panel — phases, run order, audience windows, QR, reveal`.
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Audience voting page + big-screen ceremony page
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(public)/vote/competition/[roundId]/page.tsx` (read existing first; rework content)
|
||||
- Create: `src/app/(public)/live/ceremony/[roundId]/page.tsx`
|
||||
- Create: `src/components/public/ceremony/` (slides: `ceremony-shell.tsx`, `presentation-slide.tsx`, `audience-vote-slide.tsx`, `reveal-slide.tsx`, `static-slide.tsx`)
|
||||
|
||||
**Invoke the `frontend-design` skill before building these two surfaces** — the reveal especially must be projector-gorgeous (spec §9): Montserrat 700, dark-blue `#053d57` field, red `#de0f1e` accent, `motion` (v11, import from `'motion/react'`) AnimatePresence transitions, confetti-grade flourish on 1st place + audience award, 16:9-safe, high contrast, no text below ~32px effective.
|
||||
|
||||
- [ ] **Step 1: Audience page** — resolve session: add tiny public procedure `liveVoting.getAudienceContextByRound({roundId})` returning `{sessionId, allowAudienceVotes, programName, roundName}` (5 lines, include in Task 5's test file as a shape assertion). Page flow: on mount ensure token in `localStorage['mopc-audience-' + sessionId]` else `registerAudienceVoter` → store. Poll `getAudienceWindow({sessionId, token})` every 3s. States: **waiting** (brand header, "Voting opens after the presentations — keep this page open", subtle wave animation), **open** (windowKey title — "Pick your favorite Business Concept", big tappable project cards (title + team), selected ring, confirm button → `castFavoriteVote`, then **voted** state: green check, "Vote recorded — you can change it until voting closes", countdown chip, tap-again-to-change), **closed** ("Voting is closed — thanks!"). Friendly error toast for IP-cap rejection. Mobile-first, thumb-sized targets, zero instructions needed.
|
||||
- [ ] **Step 2: Ceremony page** — `'use client'`; poll `liveVoting.getCeremonyState({roundId})` every 2s; full-screen `ceremony-shell` (fixed inset-0, `bg-[#053d57]`, MOPC wordmark small top-left, no nav chrome). Render precedence exactly: overrideSlide → reveal (ARMED: "Results" splash; REVEALING/DONE: `reveal-slide` for `steps[currentStepIndex]` with AnimatePresence between steps) → audience window open (`audience-vote-slide`: giant centered QR (white tile, rounded), "Vote for your favorite …", mm:ss countdown, "N votes cast" ticker) → cursor phase (ON_DECK: "Up next" + team; PRESENTING/QA: team name hero + phase label + huge countdown `formatClock`, red glow when negative; SCORING: "The jury is scoring" interstitial) → welcome slide. 1s local tick for countdowns computed from server stamps.
|
||||
- [ ] **Step 3: Reveal slide details** — `place` step: eyebrow ("3rd place — Startups"), team name scales in (motion spring, `initial={{opacity:0, y:40, scale:0.9}}`), 1st place gets gold treatment + confetti burst (CSS/motion particles — ~40 absolutely-positioned animated divs, no new dep); `audience-award`/`overall-favorite`: red accent treatment "Audience Choice"; `category-intro` and `thanks`: typographic full-bleed statements.
|
||||
- [ ] **Step 4:** `npm run build` green. Commit `feat(finale): audience voting page + big-screen ceremony view with animated reveal`.
|
||||
|
||||
---
|
||||
|
||||
### Task 13: End-to-end verification + tally audit
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/unit/live-results-tally.test.ts`
|
||||
- All previous files (fixes as found)
|
||||
|
||||
- [ ] **Step 1: Tally audit tests** — `getResults`: 2 jury voters scoring 2 projects (criteria mode: assert weighted normalization matches hand-computed values), audienceWeight 0 default keeps jury-only ordering; tie detection fires on equal totals; `getFavoriteTallies` counts match casts. Run — PASS (fix `getResults` if hand-computed values disagree; document any fix in the commit).
|
||||
- [ ] **Step 2: Full suite** `npx vitest run` — all green. `npm run typecheck` and `npm run build` — green.
|
||||
- [ ] **Step 3: Manual drive (Playwright MCP against dev server), screenshots at each stop:** seed/identify a LIVE_FINAL round with projects in both categories → admin: start live session, send project to screens, start presentation (1 min override), watch countdown go red, start Q&A, open scoring → jury (second context): see ON_DECK→phases follow along, write a note, refresh (note persists), submit criteria scores + comment → admin: open Business-Concepts audience window → **clean browser context** (NOT the logged-in profile): load `/vote/competition/[roundId]`, cast favorite, change vote, see voted state → ceremony page shows QR + count → close window → compose reveal from results, arm, step through all steps on ceremony page → deliberation: create session, open voting, juror ranks (verify the Task 10 fix), close, aggregate, adminDecide override, finalize.
|
||||
- [ ] **Step 4: Public-route curl checks** for `/vote/competition/<id>`, `/live/ceremony/<id>`, `/live-scores/<id>` — 200, no login redirect.
|
||||
- [ ] **Step 5: Fix everything found; re-run suite; commit** `test(finale): tally audit + e2e ceremony verification fixes`.
|
||||
|
||||
---
|
||||
|
||||
### Task 14 (STRETCH — only if all above is done and verified): Live ranking mode toggle
|
||||
|
||||
Skip unless time clearly permits. Admin toggle on the session (`votingMode: 'ranking'`), juror drag-rank of seen-so-far projects persisted to a new `LiveRank` model, results by Borda. **Do not start this before Task 13 is fully green.**
|
||||
|
||||
---
|
||||
|
||||
## Execution ground rules
|
||||
|
||||
- Commit after every task (or sub-step where marked); never push without `npm run build` green.
|
||||
- Local dev DB only tonight; prod deploy is a separate explicit step with the user (memory: backup first, never `docker compose down -v`).
|
||||
- If a step's investigation contradicts this plan (shapes, routes, component props), trust the code, adjust minimally, note the deviation in the commit message.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Mentorship Communications & Welcome/Reminder Email — Design
|
||||
|
||||
- **Date:** 2026-06-01
|
||||
- **Status:** Approved (pending spec review)
|
||||
- **Author:** Matt + Claude
|
||||
- **Topic:** Make mentor↔team contact effortless and add a re-sendable, instructional "welcome/reminder" email for mentoring rounds.
|
||||
|
||||
## Context
|
||||
|
||||
MOPC already has a working mentorship feature:
|
||||
|
||||
- **Two-way in-app messaging** exists (`MentorMessage` model; `WorkspaceChat` + `MentorChat` components; `trpc.mentor.sendMessage` / `getMessages` and `trpc.applicant.sendMentorMessage` / `getMentorMessages`). Mentors are auto-notified when applicants write.
|
||||
- **Contact emails are already visible**: mentors see each team member's email as individual `mailto:` links (`src/app/(mentor)/mentor/projects/[id]/page.tsx`); applicants see their mentor's name+email (`src/app/(applicant)/applicant/mentor/page.tsx`) and teammates' emails (`src/app/(applicant)/applicant/team/page.tsx`).
|
||||
- **Round-open auto emails already fire**: flipping a `MENTORING` round draft→active sends a coalesced *"you've been assigned to N projects"* email to each mentor (`getMentorBulkAssignmentTemplate` / `sendMentorBulkAssignmentEmail`) and a *"meet your mentors"* intro to each team (`getTeamMentorIntroductionTemplate` / `sendTeamMentorIntroductionEmail`). These are one-time, gated by `MentorAssignment.notificationSentAt` and `MentorAssignment.teamIntroducedAt` (`src/server/services/round-engine.ts`).
|
||||
|
||||
Two gaps remain:
|
||||
|
||||
1. There is **no single "email all team members"** affordance for mentors — only per-person `mailto:` links.
|
||||
2. The round-open emails **don't explain how to use the mentorship features**, and there is **no way to re-send** them later as a reminder.
|
||||
|
||||
## Goals
|
||||
|
||||
- A mentor can email their whole team in one click (opens their mail client, all members in `To:`).
|
||||
- The round-open assignment emails are **upgraded in place** to include (a) the relevant contact emails and (b) how-to-use-the-mentorship-features instructions.
|
||||
- An admin can **re-send** that same email on demand (a "welcome/reminder" blast) to all mentors + teams in a mentoring round, with an optional custom note.
|
||||
- The admin can **preview** the exact email (mentor + team versions) before sending.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No new in-app messaging surfaces (the chat already exists).
|
||||
- No new email-provider infrastructure (reuse `src/lib/email.ts` wrapper, helpers, throttling, `NotificationLog`).
|
||||
- No mentors-only / teams-only targeting toggle for v1 — the reminder sends to **both** audiences. (Can be added later if needed.)
|
||||
|
||||
## Feature 1 — Mentor "Email all team members" button
|
||||
|
||||
- **Location:** `src/app/(mentor)/mentor/projects/[id]/page.tsx`, in the existing Team Members card, alongside the per-member `mailto:` links.
|
||||
- **Behavior:** builds `mailto:<comma-joined emails>?subject=...` with **all active team members in `To:`** (per decision), subject pre-filled `MOPC Mentorship — {project title}`. Clicking opens the mentor's default mail app.
|
||||
- **Edge cases:** filter out blank/missing emails defensively (schema makes `User.email` required+non-null, but be safe); hide the button when the team has zero emailable members.
|
||||
- **Scope:** pure client-side; no backend changes.
|
||||
|
||||
## Feature 2 — Unified mentorship welcome/reminder email
|
||||
|
||||
### Decision: upgrade in place, don't duplicate
|
||||
|
||||
Rather than send a second email on round-open, the **existing** two templates are enhanced so they carry the instructions + contact emails. The same template code is reused by both trigger paths below. One email per audience; one source of truth.
|
||||
|
||||
### Content — Mentor version (coalesced per mentor, across their projects in the round)
|
||||
|
||||
- Greeting by mentor name.
|
||||
- Optional custom note (rendered in an info box near the top) — only present on the manual reminder path.
|
||||
- For **each** assigned project: project title (linked) + the **team members listed with name + email**.
|
||||
- "How to mentor on MOPC" instructions block: where the workspace chat lives, file sharing, the mentor dashboard.
|
||||
- CTA → Mentor Dashboard.
|
||||
|
||||
### Content — Team version (per project)
|
||||
|
||||
- Greeting by recipient name.
|
||||
- Optional custom note (info box) — manual path only.
|
||||
- The assigned **mentor(s) listed with name + email**.
|
||||
- The team's **own members listed with email** (per decision: include teammates too).
|
||||
- "How to work with your mentor" instructions block: where the in-app chat is, how to reach the mentor, what to expect.
|
||||
- CTA → mentoring page.
|
||||
|
||||
Both reuse `getEmailWrapper()` and existing helpers (`sectionTitle`, `paragraph`, `ctaButton`, `infoBox`, `escapeHtml`) for consistent branding.
|
||||
|
||||
### Trigger path A — auto on round-open (existing flow, upgraded content)
|
||||
|
||||
- `src/server/services/round-engine.ts` draft→active flow keeps its one-time semantics (`notificationSentAt` / `teamIntroducedAt` gating) and coalescing.
|
||||
- It now passes the additional data the upgraded templates need: team-member name+email for the mentor email, and mentor name+email + teammate emails for the team email.
|
||||
- No custom note on this path.
|
||||
|
||||
### Trigger path B — manual reminder button (admin, on demand)
|
||||
|
||||
- New `adminProcedure`: `mentor.sendMentorshipWelcome({ roundId, customNote?: string })`.
|
||||
- Resolves **all current** active assignments for the round (`droppedAt: null`) → groups by mentor → sends mentor emails; resolves all projects with assignments → sends team emails to all members.
|
||||
- **Ignores** `notificationSentAt` / `teamIntroducedAt` (deliberate re-send). Does **not** mutate those flags.
|
||||
- Throttled + fire-and-forget like existing bulk sends; writes `NotificationLog` rows + a `DecisionAuditLog`/audit entry.
|
||||
- Returns counts: `{ mentorCount, teamMemberCount, teamCount }` for the success toast.
|
||||
|
||||
### Preview
|
||||
|
||||
- New query: `mentor.previewMentorshipWelcome({ roundId, customNote?: string })` → `{ mentor: { subject, html }, team: { subject, html } }`.
|
||||
- Calls the **same** template functions used by the real send.
|
||||
- Picks a representative recipient: first mentor with assignments + first project/team in the round. If the round has none yet, returns clearly-labeled sample-data output so the layout is still previewable.
|
||||
- Rendered in the send dialog inside a sandboxed `<iframe srcDoc={html}>` (isolates email CSS from the app), with **Mentor / Team** sub-tabs. The custom-note textarea updates the preview live (debounced).
|
||||
|
||||
### Admin UI
|
||||
|
||||
- New component `src/components/admin/round/send-mentorship-welcome-button.tsx`:
|
||||
- Lives in the round detail page's **Notifications** section (`src/app/(admin)/admin/rounds/[roundId]/page.tsx`, near `NotifyAdvancedButton` / `NotifyRejectedButton` / `BulkInviteButton`), rendered **only when the round is `MENTORING`**.
|
||||
- Opens a dialog: recipient summary ("N mentors · M team members across K teams"), optional custom-note textarea, live Preview (Mentor/Team tabs), and a Send button with confirmation.
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/app/(mentor)/mentor/projects/[id]/page.tsx` | Add "Email all team members" button (mailto, all in To:) |
|
||||
| `src/lib/email.ts` | Enhance `getMentorBulkAssignmentTemplate` + `getTeamMentorIntroductionTemplate` (contacts, instructions, optional `customNote`); update `sendMentorBulkAssignmentEmail` / `sendTeamMentorIntroductionEmail` signatures + all call sites |
|
||||
| `src/server/services/round-engine.ts` | Pass team-member/mentor emails into upgraded templates on round-open |
|
||||
| `src/server/routers/mentor.ts` | New `sendMentorshipWelcome` (adminProcedure) + `previewMentorshipWelcome` (adminProcedure query) |
|
||||
| `src/components/admin/round/send-mentorship-welcome-button.tsx` (new) | Dialog: counts, custom note, live iframe preview, send |
|
||||
| `src/app/(admin)/admin/rounds/[roundId]/page.tsx` | Wire the button into the Notifications section, gated to mentoring rounds |
|
||||
|
||||
## Implementation ordering note
|
||||
|
||||
Build the templates first and render both to standalone `.html` files (and/or screenshots) for copy review **before** wiring the send path — gives an early visual check with zero throwaway work.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Template unit tests** (`src/lib/email.ts` fns return `{ subject, html, text }`, easy to assert): mentor email contains each team member's email + instructions block; team email contains mentor email(s) + teammate emails + instructions; custom note appears when passed, absent when not.
|
||||
- **tRPC test** for `sendMentorshipWelcome` on a seeded mentoring round: correct recipient resolution and returned counts; does not flip the one-time flags.
|
||||
- **tRPC test** for `previewMentorshipWelcome`: returns non-empty mentor + team HTML for a seeded round; sample-data fallback for an empty round.
|
||||
|
||||
## Decisions (resolved during brainstorming)
|
||||
|
||||
1. Upgrade existing intro emails in place (single source of truth), reused by both auto-open and the manual reminder; fallback would have been a standalone manual-only blast.
|
||||
2. Tailored content per audience (mentor vs team), **with contact emails embedded** in the relevant spot.
|
||||
3. Manual reminder: fixed branded template **+ optional custom note**.
|
||||
4. "Email all" button: **all members in `To:`**.
|
||||
5. Team email includes **both** the mentor's email and teammates' emails.
|
||||
6. Manual reminder sends to **both** audiences (no per-audience toggle in v1).
|
||||
7. Preview via an in-app button (live, real-data, iframe) rather than pasted static HTML.
|
||||
108
docs/superpowers/specs/2026-06-04-multi-hotel-rooming-design.md
Normal file
108
docs/superpowers/specs/2026-06-04-multi-hotel-rooming-design.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Multiple Hotels + Room Assignments — Design Spec
|
||||
|
||||
**Date:** 2026-06-04
|
||||
**Status:** Approved (design), pending implementation
|
||||
**Context:** The grand-finale logistics feature currently supports exactly **one hotel per edition** (`Hotel.programId @unique`), with no way to say who stays where. Admins need **multiple hotels** and the ability to assign each confirmed attendee to a hotel — usually a whole team together, but with per-member flexibility — including **room number and check-in/out dates**.
|
||||
|
||||
> Separately resolved (not part of this spec): the finalist attendee cap is configurable (Admin → Settings → Edition) and was set to 4 in production; because every confirmation path reads `Program.defaultAttendeeCap` live, this applied retroactively to already-sent confirmation links.
|
||||
|
||||
## Goals
|
||||
- Many hotels per edition (CRUD).
|
||||
- Assign each **confirmed attendee** to a hotel, with **per-member granularity** and a **"assign whole team"** shortcut.
|
||||
- Track **room number + check-in/check-out** per attendee.
|
||||
- Surface each attendee's assignment in their team-facing "My Logistics" view and the travel-confirmed email.
|
||||
|
||||
## Non-goals (YAGNI)
|
||||
- Room-sharing modeling (two attendees can simply share a `roomNumber` string — no explicit room entity).
|
||||
- Hotel booking/availability/pricing.
|
||||
- External (non-portal) lunch guests are unrelated and untouched.
|
||||
|
||||
## Data model
|
||||
|
||||
Mirror the existing `FlightDetail` pattern (a 1:1 detail record per `AttendingMember`).
|
||||
|
||||
**`Hotel`** — relax the uniqueness so an edition can have many:
|
||||
```prisma
|
||||
model Hotel {
|
||||
id String @id @default(cuid())
|
||||
programId String // was @unique — now many hotels per edition
|
||||
name String
|
||||
address String? @db.Text
|
||||
link String?
|
||||
notes String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||
stays HotelStay[]
|
||||
|
||||
@@index([programId])
|
||||
}
|
||||
```
|
||||
|
||||
**`HotelStay`** (new, 1:1 with `AttendingMember`):
|
||||
```prisma
|
||||
model HotelStay {
|
||||
id String @id @default(cuid())
|
||||
attendingMemberId String @unique
|
||||
hotelId String
|
||||
roomNumber String?
|
||||
checkInAt DateTime?
|
||||
checkOutAt DateTime?
|
||||
notes String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
|
||||
hotel Hotel @relation(fields: [hotelId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([hotelId])
|
||||
}
|
||||
```
|
||||
- `AttendingMember` gains `hotelStay HotelStay?` (back-relation).
|
||||
- `onDelete: Restrict` on `hotel` means a hotel with occupants can't be deleted — the router pre-checks and returns a friendly "reassign N occupants first" error.
|
||||
- Assigning = upsert a `HotelStay`; unassigning = delete it.
|
||||
|
||||
## Server (logistics router, `src/server/routers/logistics.ts`)
|
||||
|
||||
Replace the 1:1 hotel procedures with a list-based set; add rooming/assignment procedures. All `adminProcedure`, all audited (mirror existing `HOTEL_UPSERT` etc.).
|
||||
|
||||
| Procedure | Input | Behavior |
|
||||
|---|---|---|
|
||||
| `listHotels` | `{ programId }` | All hotels for the edition + `_count` of stays (occupancy). |
|
||||
| `createHotel` | `{ programId, name, address?, link?, notes? }` | Create. |
|
||||
| `updateHotel` | `{ id, name, address?, link?, notes? }` | Update. |
|
||||
| `deleteHotel` | `{ id }` | Pre-check stays: if >0 → `BAD_REQUEST` "Reassign N occupants first." Else delete. |
|
||||
| `listRooming` | `{ programId }` | One row per **CONFIRMED** attendee: team (project title), member (user), and their `hotelStay` (hotelId, roomNumber, checkInAt, checkOutAt) or null. Sorted by team then member. |
|
||||
| `assignStay` | `{ attendingMemberId, hotelId, roomNumber?, checkInAt?, checkOutAt?, notes? }` | Upsert the attendee's `HotelStay`. |
|
||||
| `assignTeamToHotel` | `{ confirmationId, hotelId, checkInAt?, checkOutAt? }` | For every `AttendingMember` of the confirmation, upsert `HotelStay` with `hotelId` (and optional shared dates); preserve existing `roomNumber`. The "assign whole team" shortcut. |
|
||||
| `unassignStay` | `{ attendingMemberId }` | Delete the `HotelStay` (no-op safe). |
|
||||
|
||||
**Applicant** (`applicant.getMyLogistics`): replace the program-hotel lookup with the caller's `AttendingMember.hotelStay` → return `hotel: { name, address, link, notes } | null` plus `room: { roomNumber, checkInAt, checkOutAt } | null`.
|
||||
|
||||
**Email** (`logistics.setFlightStatus` → CONFIRMED, in the `TRAVEL_CONFIRMED` notification metadata): include the attendee's **assigned** hotel + room (from their `HotelStay`) instead of the edition's single hotel. The `getTravelConfirmedTemplate` already accepts a `hotel` object — extend its metadata to carry room/dates.
|
||||
|
||||
## Admin UI (`src/components/admin/logistics/hotels-tab.tsx`, reworked)
|
||||
|
||||
Two sections:
|
||||
1. **Hotels** — a list of the edition's hotels; add/edit/delete each (dialog), with an occupancy badge per hotel. Delete shows the "reassign first" error inline.
|
||||
2. **Rooming** — a table driven by `listRooming`, grouped by team: columns `Member | Hotel (Select) | Room # | Check-in | Check-out`. Each team header has an **"Assign whole team to…"** Select (calls `assignTeamToHotel`). Per-row edits call `assignStay` (debounced on blur for room/dates; immediate on hotel change); clearing the hotel calls `unassignStay`. A **Download CSV** button (mirror the travel/visa export). Empty state when no confirmed attendees yet. shadcn components, visible affordances only (no keyboard shortcuts).
|
||||
|
||||
## Team-facing (`src/components/applicant/my-logistics-card.tsx`)
|
||||
The Hotel section shows the attendee's **assigned** hotel (name/address/link) + **Room** (number) + **check-in/check-out** (Monaco-time labels), or "Hotel details coming soon" when unassigned.
|
||||
|
||||
## Migration
|
||||
- Drop `Hotel_programId_key` unique constraint; add `Hotel_programId_idx`.
|
||||
- Create `HotelStay` table + FKs (`attendingMemberId` unique → AttendingMember CASCADE; `hotelId` → Hotel RESTRICT) + `HotelStay_hotelId_idx`.
|
||||
- No data backfill: no `HotelStay` rows exist yet; any existing single `Hotel` row simply becomes the first of many.
|
||||
- Additive/safe for prod; applied via `prisma migrate deploy` on container start.
|
||||
|
||||
## Testing
|
||||
- Hotel CRUD: create multiple hotels for one program; `deleteHotel` rejected when occupied, succeeds when empty.
|
||||
- `assignStay` upsert (create then update room/dates); `assignTeamToHotel` assigns all of a team's attendees; `unassignStay` removes.
|
||||
- `listRooming` returns confirmed attendees with their stay (and null for unassigned).
|
||||
- `getMyLogistics` returns the assigned hotel + room for the caller; null when unassigned.
|
||||
- Migration applies cleanly; existing finalist/logistics tests stay green (callers updated from `getHotel`/`upsertHotel`).
|
||||
|
||||
## Affected call sites to update (from 1:1 → multi)
|
||||
- `hotels-tab.tsx` (reworked), `getMyLogistics` (applicant.ts), `setFlightStatus` travel email (logistics.ts), and any other `getHotel`/`upsertHotel` references — grep to confirm before removing the old procedures.
|
||||
@@ -0,0 +1,199 @@
|
||||
# External Attendee Dish Self-Selection — Design Spec
|
||||
|
||||
**Date:** 2026-06-05
|
||||
**Status:** Approved (design), pending implementation plan
|
||||
**Author:** Matt + Claude
|
||||
|
||||
## Problem
|
||||
|
||||
External lunch attendees (e.g. partners, VIPs added by an admin in the logistics
|
||||
screen) currently have **no way to choose their own dish**. The admin is expected
|
||||
to set each external's `dishId` inline. There is no email and no self-service page.
|
||||
|
||||
This surfaced when an admin (Marine Jacq-Pietri, `marine@monaco-impact.org`) added
|
||||
herself as an external attendee expecting to receive an email to pick a dish, and
|
||||
never got one — because the flow does not exist. Verified in prod 2026-06-05:
|
||||
the `ExternalAttendee` row exists with `dishId = null`, and no email path targets
|
||||
externals.
|
||||
|
||||
### Current behaviour (verified in code + prod)
|
||||
|
||||
- `lunch.createExternal` / `lunch.updateExternal` write the row and send **no email**.
|
||||
- The only "Pick your lunch dish" email (`sendLunchReminderEmail`) is driven by
|
||||
`selectUnpickedAttendees`, which queries **`AttendingMember` rows tied to a
|
||||
CONFIRMED `FinalistConfirmation`** — finalist team members only. Externals are
|
||||
never in that set.
|
||||
- `sendLunchRecapEmail` goes to admins + `extraRecipients` only (a manifest, not a picker).
|
||||
- Externals' dishes are meant to be set by the admin inline via `dishId`.
|
||||
|
||||
## Goal
|
||||
|
||||
External attendees with an email on file receive a dish-selection email containing
|
||||
a tokenized link to a dedicated, no-login page where they choose a dish, declare
|
||||
allergens, and add allergen notes — mirroring the finalist team-member picker.
|
||||
|
||||
## Design decisions (locked)
|
||||
|
||||
1. **Email trigger:** auto-send on add (when the external has an email) **plus** a
|
||||
per-row "Resend invite" button in the logistics screen.
|
||||
2. **Reminders:** unpicked externals are included in both the reminder cron and the
|
||||
manual "Send reminders" action.
|
||||
3. **Page fields:** dish + allergens + allergen notes (mirror the member picker).
|
||||
4. **Dish write precedence:** last-write-wins. Both the inline admin `dishId` field
|
||||
and the self-service page can write the dish; the admin can always override.
|
||||
|
||||
## Reference pattern
|
||||
|
||||
This feature mirrors the existing **finalist confirmation flow**:
|
||||
|
||||
- `src/lib/finalist-token.ts` — HMAC-signed token (`{ confirmationId, exp }`) via
|
||||
`NEXTAUTH_SECRET`.
|
||||
- `src/app/(public)/finalist/confirm/[token]/page.tsx` — public, tokenized, no-login page.
|
||||
- `finalist.getByToken` / `finalist.confirm` / `finalist.decline` — `publicProcedure`s.
|
||||
|
||||
We replicate this shape for externals.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Data model
|
||||
|
||||
`prisma/schema.prisma` — `ExternalAttendee` gains one nullable field:
|
||||
|
||||
```prisma
|
||||
inviteSentAt DateTime? // when the dish-selection email was last sent
|
||||
```
|
||||
|
||||
- Drives an "invited ✓" indicator in the admin UI.
|
||||
- Does **not** gate resends or reminders (those are intentionally repeatable).
|
||||
- Nullable, so the migration is additive with no backfill.
|
||||
|
||||
**No `token` column.** The link is a stateless HMAC-signed token; the external is
|
||||
loaded by the `externalId` embedded in the verified payload. Trade-off accepted:
|
||||
individual links can't be revoked without rotating `NEXTAUTH_SECRET` — acceptable
|
||||
for low-stakes dish picking. (This is the one intentional divergence from the
|
||||
finalist flow, which stores a DB token for supersede/rotation scenarios that
|
||||
externals don't have.)
|
||||
|
||||
Migration: single additive column. Apply in prod via `prisma migrate deploy`
|
||||
(runs automatically on container start per the entrypoint). **Do not** run
|
||||
`migrate dev` against the drifted dev DB — create the migration SQL and use
|
||||
`db execute` + `migrate resolve` if needed locally.
|
||||
|
||||
### 2. Token helper — `src/lib/external-lunch-token.ts`
|
||||
|
||||
Mirror `finalist-token.ts`:
|
||||
|
||||
```ts
|
||||
export type ExternalLunchTokenPayload = { externalId: string; exp: number }
|
||||
export function signExternalLunchToken(payload): string
|
||||
export function verifyExternalLunchToken(token): ExternalLunchTokenPayload // throws on bad sig / expired
|
||||
```
|
||||
|
||||
- HMAC-SHA256 over base64url payload, `timingSafeEqual` comparison.
|
||||
- `exp` = `eventAt + 24h` when `eventAt` is set, else `now + 30d`. Generous so the
|
||||
link outlives the change deadline (the deadline is enforced separately at write time).
|
||||
|
||||
### 3. tRPC — `src/server/routers/lunch.ts`
|
||||
|
||||
- **`getExternalByToken`** (`publicProcedure`, input `{ token }`):
|
||||
verify token → load external (+ its `LunchEvent`, ordered `dishes`, current
|
||||
`dish`/`allergens`/`allergenOther`) → return payload incl. computed
|
||||
`changeDeadline = eventAt − changeCutoffHours`. Throws map to the page's friendly
|
||||
error states (`expired` / `signature` / not found).
|
||||
|
||||
- **`setExternalPick`** (`publicProcedure`, input
|
||||
`{ token, dishId: string | null, allergens, allergenOther }`):
|
||||
verify token → if `eventAt` set and `now > changeDeadline` → `PRECONDITION_FAILED`
|
||||
→ update the external's `dishId` / `allergens` / `allergenOther`. No audit row
|
||||
(no authenticated user on a public pick).
|
||||
|
||||
- **`sendExternalInvite`** (`adminProcedure`, input `{ externalId }`):
|
||||
load external (must have an email, else `PRECONDITION_FAILED`) → sign token →
|
||||
`sendExternalDishInviteEmail(...)` → stamp `inviteSentAt = now` → audit
|
||||
`LUNCH_EXTERNAL_INVITE_SENT`. Returns the updated row.
|
||||
|
||||
- **`createExternal`** (existing, modified): after insert, if `input.email` present,
|
||||
fire-and-forget send the invite (sign token, send email, stamp `inviteSentAt`)
|
||||
wrapped in `try/catch` — **never throws** (per the "round notifications never
|
||||
throw" project constraint). A failed send leaves `inviteSentAt = null` so the
|
||||
admin can resend.
|
||||
|
||||
### 4. Email — `src/lib/email.ts`
|
||||
|
||||
```ts
|
||||
export async function sendExternalDishInviteEmail(opts: {
|
||||
to: string
|
||||
name: string
|
||||
eventAt: Date | null
|
||||
venue: string | null
|
||||
notes: string | null
|
||||
changeDeadline: Date | null
|
||||
pickUrl: string
|
||||
}): Promise<void>
|
||||
```
|
||||
|
||||
- Uses the existing branded wrapper.
|
||||
- Subject: `Choose your lunch dish — MOPC grand finale`.
|
||||
- Body: greeting, event date (Europe/Monaco), venue, optional notes, deadline, CTA
|
||||
button → `pickUrl`.
|
||||
- One template serves both the initial invite and reminders.
|
||||
|
||||
### 5. Reminders — extend existing flow
|
||||
|
||||
- **`src/server/services/lunch-reminders.ts`**: add
|
||||
`selectUnpickedExternals(prisma, event)` → externals where `email` is set and
|
||||
`dishId IS NULL` for the event.
|
||||
- **`src/app/api/cron/lunch-reminders/route.ts`** and **`lunch.sendReminders`**:
|
||||
after the existing `AttendingMember` loop, also loop unpicked externals and send
|
||||
`sendExternalDishInviteEmail` with a freshly signed token URL. External links go
|
||||
to `/lunch/pick/<token>` (not `/applicant`). Per-send errors are caught and
|
||||
logged, consistent with the member loop.
|
||||
|
||||
### 6. Public page — `src/app/(public)/lunch/pick/[token]/page.tsx`
|
||||
|
||||
Mirror `(public)/finalist/confirm/[token]/page.tsx`:
|
||||
|
||||
- `'use client'`, reads `token` from params, queries `lunch.getExternalByToken`.
|
||||
- States: loading skeleton; invalid/expired/not-found friendly cards (reuse the
|
||||
`FriendlyError` pattern with `info@monaco-opc.com` fallback).
|
||||
- Header card: event date, venue, notes, deadline countdown (reuse `CountdownLabel`).
|
||||
- Form: dish radio list with dietary-tag badges, allergen checkboxes, allergen-notes
|
||||
textarea. Submit → `lunch.setExternalPick`.
|
||||
- Success state: "Your dish is saved", editable until the deadline.
|
||||
- Past deadline: read-only with "contact an admin" message.
|
||||
|
||||
### 7. Admin UI — logistics externals table
|
||||
|
||||
- Per-row status chip: `no email` / `Invited` / `Picked`.
|
||||
- Per-row **Resend invite** button → `lunch.sendExternalInvite` (disabled when no email).
|
||||
- The inline `dishId` editor stays (admin override path).
|
||||
|
||||
### Manifest / recap
|
||||
|
||||
No change. `lunch-recap.ts` already includes externals, so self-service picks flow
|
||||
into the manifest, CSV export, and recap email automatically.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **No email on external:** auto-send skipped; resend button disabled; reminders skip.
|
||||
- **Tampered / expired link:** friendly error card; no data leak.
|
||||
- **Pick after deadline:** `PRECONDITION_FAILED`; page shows read-only state.
|
||||
- **Admin and external both set a dish:** last-write-wins (intended).
|
||||
- **Email added later via `updateExternal`:** no auto-send on update; admin uses the
|
||||
resend button (keeps `updateExternal` side-effect-free).
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: token sign/verify roundtrip + tamper + expiry rejection (`external-lunch-token`).
|
||||
- Unit: `selectUnpickedExternals` returns only emailed + unpicked externals.
|
||||
- Integration: `getExternalByToken` happy path; bad/expired token errors.
|
||||
- Integration: `setExternalPick` happy path; deadline rejection.
|
||||
- Integration: `createExternal` with email stamps `inviteSentAt` (mocked email send);
|
||||
without email leaves it null.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- External attendee decline / RSVP (this is dish-only).
|
||||
- Reworking the member picker.
|
||||
- Audience-window / live-voting rework (tracked separately).
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
# Grand Final: judge-visible document curation + optional revised uploads
|
||||
|
||||
**Date:** 2026-06-09
|
||||
**Status:** Approved (Matt, this session)
|
||||
**Builds on:** `2026-06-09-grand-final-documents-design.md` and the same-day pivot (commits `f8f2d77`, `8a4184d`)
|
||||
|
||||
## Problem
|
||||
|
||||
Feedback from the other program admin:
|
||||
|
||||
> Jury actually need to see BP + Exec summary + 1min video — the ones they uploaded already. And candidates should be able to upload their PDF pres + video — optional, as some sent it another way.
|
||||
|
||||
Two gaps against what is deployed:
|
||||
|
||||
1. **Judges see too much.** `listFinalistDocumentsForReview` returns *every* file each finalist team ever submitted (5–7 per team on prod: Pitch Deck, Intro Video, Executive Summary, Business Plan, Promotional Video, plus any Grand Final uploads). The admin wants judges to see a curated subset (BP + exec summary + 1-min video). There is no way to choose which prior documents are surfaced.
|
||||
2. **"Optional uploads" mode renders wrong.** The three modes the admin wants are: no new uploads (toggle OFF — works), mandatory uploads (toggle ON + slots required — works), and optional uploads (toggle ON + slots marked not-required). In the all-optional case, `FinalDocumentStatus.allRequiredUploaded` is hardcoded `false` when zero slots are required, so the finalist banner/panel never reach a settled state and the copy implies the docs are mandatory.
|
||||
|
||||
Prod facts (verified 2026-06-09 via read-only query): 9 finalist teams, 48 prior files + 3 Grand Final uploads. Every team has all 5 prior doc types. The Business Plan and the 1-minute promo video live under *two different* `FileRequirement` rows depending on the team's path (Semi-Finals Document Submission for 8 teams, Spotlight on Africa Submission Round for 1 team — Blue Fields Company).
|
||||
|
||||
## Part 1 — Admin curation of judge-visible documents
|
||||
|
||||
### Storage
|
||||
|
||||
New optional key on the LIVE_FINAL round's `configJson`:
|
||||
|
||||
```ts
|
||||
reviewVisibleRequirementIds?: string[] // FileRequirement ids from prior rounds
|
||||
```
|
||||
|
||||
Semantics:
|
||||
- **absent / null** → show all prior files (current behavior; safe default, no migration needed)
|
||||
- **non-empty array** → show only prior files whose `requirementId` is in the list
|
||||
- **empty array** → hide all prior files (only Grand Final uploads remain visible)
|
||||
- **Grand Final round uploads are always shown**, regardless of the selection — they are what the team explicitly submitted for the finale
|
||||
- Prior files with no `requirementId` (fileType-only) are excluded whenever a selection is active. (All 48 prod files have a requirement, so nothing is lost in practice.)
|
||||
|
||||
### Service (`src/server/services/final-documents.ts`)
|
||||
|
||||
`listFinalistDocumentsForReview` adds `requirementId` to its file select and applies the filter above using the finale round's `configJson`. No signature change; `ReviewPayload` unchanged.
|
||||
|
||||
New helper to power the admin picker: list the distinct prior-round requirement slots referenced by the finalist teams' files — `{ requirementId, name, roundName, fileCount }`, ordered by round sort then name. Derived from the same file query, so the picker only offers slots that actually have files.
|
||||
|
||||
### tRPC (`src/server/routers/finalist.ts`, adminProcedure)
|
||||
|
||||
- `getReviewDocSettings` → `{ options: Slot[], selectedIds: string[] | null }` (null = "all" mode)
|
||||
- `setReviewVisibleRequirements({ requirementIds: string[] | null })` → writes/clears the configJson key (null clears back to "show all"). Audited like `setRevisedUploadSetting`.
|
||||
|
||||
### Admin UI
|
||||
|
||||
New card "Documents shown to judges" placed next to the existing revised-uploads toggle (`src/components/admin/grand-finale/final-docs-uploads-toggle.tsx`, rendered on the LIVE_FINAL round admin page `src/app/(admin)/admin/rounds/[roundId]/page.tsx`):
|
||||
|
||||
- A "Show all submitted documents" master state (the default), and beneath it a checkbox per slot labeled `"<requirement name> — <round name>"` with the file count (e.g. "Business Plan — Semi-Finals Document Submission (8 files)").
|
||||
- Unchecking the master switches to curated mode with all boxes ticked; the admin then unticks what judges shouldn't see. Re-checking the master clears the selection (back to null/"all").
|
||||
- Copy notes that Grand Final uploads are always visible to judges.
|
||||
|
||||
For the admin's stated goal, they'd switch to curated mode and leave 5 boxes ticked: Executive Summary (Intake), Business Plan (Semi-Finals + Spotlight), Promotional Video (Semi-Finals) and 1 Minute Promotional Video (Spotlight) — judges then see exactly BP + exec summary + 1-min video per team, plus any finale uploads.
|
||||
|
||||
## Part 2 — All-optional upload mode fix
|
||||
|
||||
`FinalDocumentStatus` (in `final-documents.ts`) gains:
|
||||
|
||||
```ts
|
||||
hasRequired: boolean // any slot with isRequired
|
||||
allUploaded: boolean // requirements.length > 0 && every slot has a file, required or not
|
||||
```
|
||||
|
||||
`allRequiredUploaded` keeps its current semantics (meaningful only when `hasRequired`). Edge case: if the toggle is ON but no slots are defined at all (`requirements.length === 0`), the banner and panel render nothing — no vacuous "(0 of 0)" complete state.
|
||||
|
||||
UI changes:
|
||||
- **Banner** (`src/components/applicant/final-documents-banner.tsx`): when `hasRequired` is false — title "Upload updated Grand Final documents (optional)", same neutral blue styling, keep per-doc checklist/count/deadline/upload button; green settled state ("Grand Final documents uploaded") only when `allUploaded`.
|
||||
- **Panel** (`src/components/applicant/final-documents-panel.tsx`, team + mentor variants): "Submitted" badge driven by `hasRequired ? allRequiredUploaded : allUploaded`; description gains "(optional)" when nothing is required.
|
||||
|
||||
Reminders need **no change** — verified: the cron and the untargeted manual blast already skip teams with no missing *required* docs, so all-optional mode never nags; an explicitly targeted manual reminder still sends (intentional admin override).
|
||||
|
||||
## Out of scope (admin actions in existing UI, not code)
|
||||
|
||||
- Flipping `allowFinalistRevisedUploads` ON
|
||||
- Creating/adjusting the finale upload slots (PDF presentation + 1-min video, "Required" off) in the round's file-requirements editor
|
||||
- Ticking the curation checkboxes
|
||||
- Populating the Finals Jury group (still open from the previous ship)
|
||||
|
||||
## Testing
|
||||
|
||||
Vitest service tests (extend `tests/` final-documents coverage):
|
||||
- Curation: null selection → all files; selection → only matching prior files + finale uploads always; empty array → finale uploads only; file without requirementId excluded under a selection.
|
||||
- Picker helper returns distinct slots with correct counts.
|
||||
- `setReviewVisibleRequirements` round-trips null/array through configJson without clobbering other keys (`allowFinalistRevisedUploads`).
|
||||
- Status: `hasRequired`/`allUploaded` across mixed, all-optional (0 required), and fully-uploaded fixtures.
|
||||
|
||||
## Risks
|
||||
|
||||
- **configJson clobbering:** both toggles write the same JSON column — read-modify-write must preserve sibling keys (existing `setRevisedUploadSetting` pattern already does this; reuse it).
|
||||
- **Stale selection:** if a selected requirement is later deleted, its files simply stop matching; "all" fallback never breaks. No cleanup needed.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Grand-Final Documents — upload visibility, mentor surfacing, judge review, notifications
|
||||
|
||||
**Date:** 2026-06-09
|
||||
**Status:** Design — pending review
|
||||
**Edition:** MOPC 2026 (program "Monaco Ocean Protection Challenge", competition `MOPC 2026`)
|
||||
|
||||
## Problem
|
||||
|
||||
Nine finalist teams must submit two final deliverables ahead of the Grand Final — a **final PDF presentation** and a **~1-minute video** — and the upcoming Grand-Final judges need to review those documents. Today there is no discoverable upload prompt, no consolidated judge review surface, and no notifications driving teams to upload.
|
||||
|
||||
## Current state (verified against prod, 2026-06-09)
|
||||
|
||||
The data layer already exists and is correctly set up:
|
||||
|
||||
- **"Grand Final" (LIVE_FINAL) round is `ROUND_ACTIVE`** with `windowCloseAt = 2026-06-11 21:00 UTC` and the empty **"Finals Jury"** group attached (`juryGroupId` set, **0 members**).
|
||||
- **Two `FileRequirement` rows already exist on the Grand Final round** (legacy per-round system): **"PDF presentation support"** (`application/pdf`) and **"1 minute video"** (`video/*`). Both currently `required = false`, `maxSizeMB = null`, **0 files uploaded**. This set is being expanded to the confirmed 4-document set below.
|
||||
- **All 9 finalist teams are correctly enrolled**: each has a `ProjectRoundState` in both the (closed) Mentoring round and the (active) Grand Final round, and all 9 have `FinalistConfirmation.status = CONFIRMED`. No mismatches (confirmed↔enrolled↔in-mentor all aligned). All 9 share one mentor, **Camille Lopez**. Attendee counts 1–4 (program `defaultAttendeeCap = 4`).
|
||||
- Auto-enroll (confirmed + in mentor round → Grand Final round) is working via `finalist.enrollFinalists`; the **admin override already exists** (`finalist.adminConfirm` to mark attending without a token; `finalist.unenroll` to remove) in the `/admin/logistics` **Confirmations tab** + attendance dialog.
|
||||
|
||||
### What this means
|
||||
|
||||
The **upload already works today**: `/applicant/documents` renders an upload section for every round in `openRounds`, where `openRounds` = program rounds that are `ROUND_ACTIVE` **and** the project is a member of (`applicant.getMyDashboard`). The Grand Final round qualifies, so all 9 teams can upload the PDF + video right now. The upload procedure (`applicant.getUploadUrl`) permits any team member to upload against a requirement as long as the round is `ROUND_ACTIVE` (it is). The `windowCloseAt` deadline is **advisory only** (display, not blocking).
|
||||
|
||||
The gaps are therefore: **discoverability** (no banner/notification), **judge review** (no consolidated surface; jury can only see files for projects they are individually assigned to, and the Finals Jury group is empty), and **mentor-section surfacing** (the final documents never appear in the mentor area).
|
||||
|
||||
## Goals
|
||||
|
||||
1. Make the existing finalist upload **discoverable** via a dashboard banner and notifications.
|
||||
2. Give the upcoming Grand-Final judges a **read-only review page** of all finalists' documents.
|
||||
3. Surface the final documents (and a pre-deadline cue) inside **the mentor section**, on both the team's and the mentor's views.
|
||||
4. Add **email + in-app notifications**, triggered **automatically** (pre-deadline reminder cron) and **manually** (admin blast).
|
||||
|
||||
## Document set (confirmed)
|
||||
|
||||
The Grand Final round's `FileRequirement` rows are reconfigured to **four required documents**, identical for both categories (STARTUP and BUSINESS_CONCEPT) — the per-round `FileRequirement` model already applies one set to all teams in the round:
|
||||
|
||||
1. **Final Presentation** — `application/pdf` (rename of the existing "PDF presentation support" row)
|
||||
2. **Final Business Plan** — `application/pdf` (new)
|
||||
3. **1-minute Video** — `video/*` (existing "1 minute video" row)
|
||||
4. **Executive Summary** — `application/pdf` (new)
|
||||
|
||||
All four `required = true`. PDF-only for the three document slots (no Word). This is an additive/safe prod data change (0 files currently uploaded). If per-category document sets are ever needed, that is out of scope here (the per-round model does not support it without extra work).
|
||||
|
||||
## Non-goals (YAGNI)
|
||||
|
||||
- Admin approve / needs-changes review workflow on documents.
|
||||
- Migrating to the heavier `SubmissionWindow` system (the legacy `FileRequirement` anchor is already set up and proven).
|
||||
- A mentor-milestone tracker UI (`MentorMilestone`/`MentorMilestoneCompletion` models exist but have no UI; "last steps of the mentor round" is treated as descriptive framing, surfaced as a read-only Final Documents panel, not a milestone system).
|
||||
- Comments/threads on the judge review page.
|
||||
- New admin enrollment/override controls (`adminConfirm` + `unenroll` already exist; we only ensure they are reachable from the finale round overview).
|
||||
|
||||
## Architecture decision
|
||||
|
||||
Build thin additions on the **existing legacy `FileRequirement` → `ProjectFile` anchor** that is already configured on the Grand Final round. Reuse:
|
||||
|
||||
- Upload mechanics (`applicant.getUploadUrl` / `saveFileMetadata`, presigned MinIO PUT, `RequirementUploadList`).
|
||||
- File preview/download (`FilePreview` / `file-viewer`, `file.getDownloadUrl`).
|
||||
- The notification pipeline (`createNotification`, `notifyProjectTeam`/`notifyProjectMentors`, `NotificationEmailSetting`, `NOTIFICATION_EMAIL_TEMPLATES`, `sendStyledNotificationEmail`) and the reminder-cron pattern (`sendDueConfirmationReminders`).
|
||||
|
||||
**The deadline everywhere** (banner, mentor cue, reminder cron) is the Grand Final round's single `windowCloseAt` field, edited by admins in round settings. All deadline displays use **browser-local time + zone label** (`Intl.DateTimeFormat`), never UTC/fixed Monaco time, per the locked grand-finale timezone rule. Deadline behavior is **soft/advisory** — uploads stay open while the round is active; past the date, files are flagged "late" in the UI but still accepted.
|
||||
|
||||
## Shared service: `src/server/services/final-documents.ts`
|
||||
|
||||
A new service centralizes the logic, wrapped by thin tRPC procedures:
|
||||
|
||||
- `getFinalDocumentStatusForProject(prisma, projectId)` → `{ roundId, roundName, deadline, deadlinePassed, requirements: [{ id, name, acceptedMimeTypes, uploaded, file? }], allRequiredUploaded }` or `null` when the project is not a CONFIRMED finalist in an active LIVE_FINAL round. The single source of truth for the banner, the mentor panels, and reminder targeting.
|
||||
- `listFinalistDocumentsForReview(prisma, programId)` → `{ round: { name, deadline }, totalCount, submittedCount, teams: [{ projectId, teamName, category, confirmStatus, documents: [{ requirementId, requirementName, file? }] }] }`. File metadata only; presigned URLs are fetched per-file on demand by the client via existing `file.getDownloadUrl`.
|
||||
- `sendDueFinalDocReminders(prisma)` → cron entry. Targets CONFIRMED finalists in the active LIVE_FINAL round with at least one required document missing, whose `finalDocsReminderSentAt` is null and whose deadline is within the reminder window; creates `GRAND_FINAL_DOCS_REMINDER` notifications and stamps `finalDocsReminderSentAt`. Best-effort per row.
|
||||
- `sendManualFinalDocReminders(prisma, { programId, projectIds?, actorId })` → admin blast. For the given projects (default: all CONFIRMED finalists with missing required docs), create `GRAND_FINAL_DOCS_REMINDER` notifications regardless of `finalDocsReminderSentAt`. Returns `{ sent }`.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Finalist upload banner (applicant dashboard)
|
||||
|
||||
- New auto-hiding banner component (pattern of `LunchBanner`/`MyLogisticsCard`: returns `null` when not applicable) on `src/app/(applicant)/applicant/page.tsx`.
|
||||
- Backed by a new query **`applicant.getFinalDocumentStatus`** (wraps `getFinalDocumentStatusForProject` for the caller's project).
|
||||
- Shows: heading ("Upload your Grand Final documents"), each required document with a ✓ / empty state (e.g. "2 of 4 uploaded"), deadline in browser-local time + zone, and a CTA button → `/applicant/documents`. Collapses to a "✓ Submitted" confirmation once all required documents are uploaded. Non-dismissible while incomplete.
|
||||
|
||||
### 2. Mentor-section "Final Documents" panel (team + mentor)
|
||||
|
||||
A new read-only `FinalDocumentsPanel` component rendered on **both** mentor surfaces:
|
||||
|
||||
- **Team view** — `src/app/(applicant)/applicant/mentor/page.tsx` (adds a panel below the existing mentor cards / chat / workspace-files). Uses `applicant.getFinalDocumentStatus`.
|
||||
- **Mentor view** — `src/app/(mentor)/mentor/workspace/[projectId]/page.tsx` (adds a "Final Documents" section or tab for the viewed project). Uses a new **`mentor.getProjectFinalDocuments`** procedure (mentor/team access check) wrapping `getFinalDocumentStatusForProject`.
|
||||
|
||||
Behavior: before upload + as the deadline nears, a **visual cue** ("Final grand-final documents due [date] — upload now", with an upload CTA on the team view); after upload, the PDF + video appear as the team's read-only "final documents" (inline preview / video player / download), visible to both the team and their mentor. Same underlying `ProjectFile`s — no duplicate storage.
|
||||
|
||||
### 3. Judge review page (thin dedicated page, reusing existing components)
|
||||
|
||||
**Why dedicated rather than baking into the existing per-project jury page** (verified in prod): the finale has **0 `Assignment` rows**, the "Finals Jury" group has **0 members**, and no `LiveVotingSession` exists. The existing jury flow is assignment-gated — the round page lists `roundAssignment.getMyAssignments` (empty for the finale) and `file.listByProject` 403s any juror without an `Assignment` to the project. The finale runs on a **group + live-session** model, not per-project assignments. Baking in would require either fabricating an assignment per judge × finalist or rewiring the assignment-based access path — more work and risk, and a worse UX (one project at a time vs. all finalists at once). So a dedicated page is the better fit *because* the existing page's access model does not apply to the finale.
|
||||
|
||||
It stays thin by **reusing existing components** — the same `MultiWindowDocViewer` / `FilePreview` / `<video>` / `file.getDownloadUrl` used on the per-project page — laid out as a consolidated finale list. Not a reinvented viewer.
|
||||
|
||||
- New read-only page (e.g. `src/app/(jury)/jury/finals-documents/page.tsx`) listing all finalist teams grouped by category, each with its four documents (3 PDFs + video) via the reused viewer/preview components plus download. Missing documents show "Not yet uploaded". Header shows the deadline and "X of N submitted".
|
||||
- Backed by a new **`finalist.listReviewDocuments`** procedure (wraps `listFinalistDocumentsForReview`). Authorization: **SUPER_ADMIN / PROGRAM_ADMIN, OR a `JuryGroupMember` of the active LIVE_FINAL round's jury group**, via a new `assertFinalsReviewAccess(ctx)` helper. Unauthorized → access-denied state.
|
||||
- Entry points: a jury sidebar link ("Finalist Documents") shown when an active LIVE_FINAL round exists, and a "Review finalist documents" link on the admin Grand Final round overview.
|
||||
- **Implementation note:** verify the `(jury)` route-group layout does not hard-redirect admins; if it does, either relax it for this page or mount the page on a neutral path reachable by both. Authorization is enforced by the procedure regardless.
|
||||
|
||||
### 4. Notifications (email + in-app; auto + manual)
|
||||
|
||||
- **New notification type `GRAND_FINAL_DOCS_REMINDER`** (team-facing): added to `NotificationTypes`, with a `NOTIFICATION_EMAIL_TEMPLATES` entry (branded) and a `seed-notification-settings.ts` row (`category: "logistics"`, per-type `sendEmail` toggle, subject/body overridable). In-app + email.
|
||||
- **New notification type `GRAND_FINAL_DOCS_SUBMITTED`** (mentor-facing, light): when a team uploads a final document, notify the team's mentor(s) in-app so it surfaces in their mentor section. Seed row with `sendEmail` default **off** (in-app on). Triggered from `applicant.saveFileMetadata` when the file is for the LIVE_FINAL round (best-effort, never throws).
|
||||
- **Automatic (cron):** new route `src/app/api/cron/final-document-reminders/route.ts` (protected by `CRON_SECRET`) calling `sendDueFinalDocReminders`. Reminder window read from the round `configJson.finalDocsReminderHoursBeforeDeadline` (default 48h). Fires once per team (stamped via `finalDocsReminderSentAt`). This doubles as the initial "documents are open" nudge.
|
||||
- **Manual (admin):** a "Remind teams to upload final documents" action with a live `EmailPreviewDialog` (mirrors the existing finalist reminder-blast), backed by `finalist.sendDocumentReminders` → `sendManualFinalDocReminders`. Placed on the admin Grand Final round overview and/or the `/admin/logistics` Confirmations tab. Usable immediately to kick off the round.
|
||||
|
||||
### 5. Minor polish
|
||||
|
||||
- Reconfigure the round's `FileRequirement` rows to the 4-document set (rename "PDF presentation support" → "Final Presentation"; add "Final Business Plan" + "Executive Summary" as `application/pdf`; keep "1-minute Video"), all `required = true` (guarded prod data update at ship time, or via the admin round file-requirement editor if present). Additive/safe — 0 files uploaded.
|
||||
- Confirm admins can edit the round's `windowCloseAt` in round settings (the admin-set deadline). If no input exists, add a small one; likely already present.
|
||||
|
||||
## Data model changes
|
||||
|
||||
- `FinalistConfirmation.finalDocsReminderSentAt DateTime?` (new nullable column) — lets the auto reminder fire once per team. Migration required (additive, safe).
|
||||
- `NotificationTypes`: add `GRAND_FINAL_DOCS_REMINDER`, `GRAND_FINAL_DOCS_SUBMITTED`.
|
||||
- `seed-notification-settings.ts`: add rows for both new types (auto-provisions on deploy).
|
||||
- Optional round config field `finalDocsReminderHoursBeforeDeadline` (default 48) validated in the LIVE_FINAL round Zod config.
|
||||
|
||||
## tRPC procedures (new)
|
||||
|
||||
| Procedure | Router | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| `applicant.getFinalDocumentStatus` | applicant | protected (team member) | Banner + team mentor panel |
|
||||
| `mentor.getProjectFinalDocuments` | mentor | mentor/team access to project | Mentor workspace panel |
|
||||
| `finalist.listReviewDocuments` | finalist | admin OR finale jury-group member | Judge review page |
|
||||
| `finalist.sendDocumentReminders` | finalist | admin | Manual reminder blast |
|
||||
|
||||
(Reminder email preview reuses the existing `notification.previewEmailTemplate`.)
|
||||
|
||||
## Testing
|
||||
|
||||
Vitest (sequential, factories per `tests/helpers.ts`):
|
||||
|
||||
- `getFinalDocumentStatusForProject`: all 4 required uploaded / partial / none (`allRequiredUploaded` correct); returns `null` for a non-confirmed team and when no active LIVE_FINAL round; `deadlinePassed` reflects `windowCloseAt`.
|
||||
- `listFinalistDocumentsForReview`: returns all finalist teams with correct per-requirement file mapping and `submittedCount`.
|
||||
- Authorization matrix for `finalist.listReviewDocuments`: admin ✓, finale jury-group member ✓, non-finale jury member ✗, applicant ✗.
|
||||
- `sendDueFinalDocReminders`: targets only CONFIRMED finalists with missing required docs inside the window; stamps `finalDocsReminderSentAt`; idempotent (no double-send).
|
||||
- `finalist.sendDocumentReminders`: admin only; counts correctly.
|
||||
|
||||
Live-UI smoke on dev (lesson learned — catches what tests/build miss): banner renders for a finalist; team mentor panel + mentor-workspace panel render; judge page renders for an admin and for a finale jury-group member and denies a non-finale juror; upload still works; a manual reminder produces an in-app + email notification.
|
||||
|
||||
## Prerequisites / admin actions (outside code)
|
||||
|
||||
1. **Populate the "Finals Jury" group** with the actual judges (existing jury-group admin UI) — required before the review page is useful to them.
|
||||
2. **Extend the Grand Final `windowCloseAt`** (currently 2026-06-11, ~2 days out) to the intended deadline.
|
||||
3. Reconfigure the round's file requirements to the 4-document set (Final Presentation, Final Business Plan, 1-minute Video, Executive Summary), all `required = true` — I can do this as a guarded prod update.
|
||||
|
||||
## Build sequence (shippable in phases; deadline is imminent)
|
||||
|
||||
1. **Banner + manual admin reminder + minor polish** — makes the existing upload discoverable now (most urgent).
|
||||
2. **Judge review page** + access helper + entry points.
|
||||
3. **Mentor-section Final Documents panel** (team + mentor) + `mentor.getProjectFinalDocuments`.
|
||||
4. **Auto reminder cron** + `GRAND_FINAL_DOCS_SUBMITTED` on-upload mentor notification + new notification types/templates/seed + migration.
|
||||
|
||||
## Deployment
|
||||
|
||||
Per the prod-deploy runbook: after everything is reviewed and tested (build clean, `npx vitest run` green), commit to `main`, push to `code.monaco-opc.com/MOPC/MOPC-Portal`, **track the Gitea CI build** until it publishes `mopc/mopc-portal:latest`, then redeploy on prod (`ssh stefan@89.58.5.223:22022`, `/opt/letsbe/stacks/mopc-portal`): `docker compose pull && docker compose up -d` (NEVER `-v`). Confirm the additive migration applied and the new notification-settings rows seeded, then live-smoke the banner, judge page, and a manual reminder.
|
||||
@@ -0,0 +1,201 @@
|
||||
# Grand Finale Ceremony System — Design (Option C)
|
||||
|
||||
> **Status:** Approved 2026-06-10. Event is 2026-06-11 — build tonight in strict dependency order (see Build Order). Each layer must leave the system complete and operable if work stops there.
|
||||
>
|
||||
> Supersedes the operational scope of `2026-04-28-grand-finale-live-voting-rework.md` (the audience-window section of that spec is implemented here as designed).
|
||||
|
||||
## Decisions (locked with user)
|
||||
|
||||
1. **Jury scoring mode:** criteria scores + optional comment (like prior evaluation rounds). Live ranking mode is a stretch goal only.
|
||||
2. **Audience votes:** per-category favorite windows always; an **overall-favorite** window exists behind an admin toggle (decided day-of).
|
||||
3. **Vote gating:** one vote per browser token per window AND max **3 votes per IP per window** (venue NAT tolerance).
|
||||
4. **Deliberation:** per category (two sessions). Existing backend (FULL_RANKING/Borda, adminDecide override) is used as-is.
|
||||
5. **Architecture:** Option C = full B scope + big-screen ceremony view + results reveal controller. Big screen is **derived from existing state** — no new session-level phase machine. Only new state: reveal controller + display override slide.
|
||||
6. **During audience windows the big screen shows vote count only** ("147 votes cast"), never a live per-project tally.
|
||||
7. **Big-screen results reveal must be visually outstanding** — projector-grade, brand identity (dark blue `#053d57` field, red `#de0f1e` accent, Montserrat), animated transitions.
|
||||
|
||||
## Current foundation (verified 2026-06-10)
|
||||
|
||||
- `LiveProgressCursor` (cursor: activeProjectId, activeOrderIndex, isPaused) + `live.ts` router (start/jump/reorder/pause/resume/getCursor).
|
||||
- `LiveVotingSession` / `LiveVote` / `AudienceVoter` + `live-voting.ts` router (criteria voting, importCriteriaFromForm, getResults, registerAudienceVoter/castAudienceVote, public results).
|
||||
- Jury live page `/jury/competitions/[roundId]/live` follows cursor (poll 5s); notes textarea is **not persisted**; prior-data panel stubbed.
|
||||
- Admin `live-control-panel.tsx`: prev/next/pause/resume + **client-local fake timer**.
|
||||
- Public `/live-scores/[sessionId]` scoreboard with SSE.
|
||||
- Deliberation backend + router 100% complete; jury deliberation page has `juryMemberId=''` and `hasVoted=false` hardcoded (jurors cannot vote).
|
||||
- `publicPaths` in `auth.config.ts` does **not** include `/vote` or `/live-scores` → audience pages bounce to login. Launch blocker.
|
||||
|
||||
---
|
||||
|
||||
## 1. Public access (do first)
|
||||
|
||||
Add `/vote`, `/live-scores`, `/live/ceremony` to `publicPaths` in `src/lib/auth.config.ts` (wherever publicPaths lives). Verify with `curl -I` (not the logged-in Playwright profile).
|
||||
|
||||
## 2. Schema changes (one migration)
|
||||
|
||||
```prisma
|
||||
// LiveProgressCursor — per-project phase + server-stamped timer
|
||||
projectPhase LivePhase @default(ON_DECK) // ON_DECK | PRESENTING | QA | SCORING
|
||||
phaseStartedAt DateTime?
|
||||
phaseDurationSeconds Int?
|
||||
phasePausedAt DateTime?
|
||||
phasePausedAccumMs Int @default(0)
|
||||
timingLogJson Json? // [{projectId, phase, startedAt, endedAt, configuredSeconds, overranSeconds}]
|
||||
overrideSlide String? // 'welcome' | 'break' | 'deliberation' | 'thanks' | null
|
||||
|
||||
enum LivePhase { ON_DECK PRESENTING QA SCORING }
|
||||
|
||||
// LiveVotingSession — audience window (locked spec from 2026-04-28 + overall kind)
|
||||
audiencePhase AudiencePhase @default(CLOSED) // CLOSED | OPEN
|
||||
audienceWindowKey String? // 'CATEGORY:STARTUP' | 'CATEGORY:BUSINESS_CONCEPT' | 'OVERALL'
|
||||
audienceWindowOpenedAt DateTime?
|
||||
audienceWindowClosesAt DateTime?
|
||||
allowOverallFavorite Boolean @default(false) // admin toggle, decided day-of
|
||||
|
||||
enum AudiencePhase { CLOSED OPEN }
|
||||
|
||||
// LiveVote — optional overall comment
|
||||
comment String?
|
||||
|
||||
model AudienceFavoriteVote {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
windowKey String // matches audienceWindowKey at cast time
|
||||
projectId String
|
||||
audienceVoterId String
|
||||
ipAddress String?
|
||||
createdAt DateTime @default(now())
|
||||
@@unique([sessionId, windowKey, audienceVoterId])
|
||||
@@index([sessionId, windowKey, ipAddress])
|
||||
}
|
||||
|
||||
model LiveNote {
|
||||
id String @id @default(cuid())
|
||||
roundId String
|
||||
projectId String
|
||||
userId String
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@unique([roundId, projectId, userId])
|
||||
}
|
||||
|
||||
model RevealState {
|
||||
id String @id @default(cuid())
|
||||
sessionId String @unique
|
||||
status String @default("DRAFT") // DRAFT | ARMED | REVEALING | DONE
|
||||
stepsJson Json // ordered reveal steps, see §8
|
||||
currentStepIndex Int @default(-1)
|
||||
}
|
||||
```
|
||||
|
||||
Timer math (shared client+server helper `src/lib/live-timer.ts`): `remaining = phaseDurationSeconds − (now − phaseStartedAt − phasePausedAccumMs)`; negative = overtime, displayed `+m:ss` in red. On every phase transition the server appends a timing-log entry with `overranSeconds = max(0, elapsed − configured)`.
|
||||
|
||||
## 3. Ceremony control — `live.ts` router + admin panel revamp
|
||||
|
||||
New adminProcedure mutations on `live`:
|
||||
|
||||
- `sendToScreens({roundId, projectId?})` — advance cursor to project (or next), `projectPhase=ON_DECK`, no timer. This is the "next up" grace.
|
||||
- `startPresentation({roundId, durationSeconds?})` — `PRESENTING`, stamp `phaseStartedAt`, duration from round config `presentationDurationMinutes` unless overridden.
|
||||
- `startQA({roundId, durationSeconds?})` — close out PRESENTING into timing log, start QA timer (`qaDurationMinutes` default).
|
||||
- `openScoring({roundId})` — close QA into log, `phase=SCORING`, no timer.
|
||||
- `pausePhase` / `resumePhase` — stamp `phasePausedAt` / fold into `phasePausedAccumMs`.
|
||||
- `setOverrideSlide({roundId, slide})` — force big-screen slide or clear.
|
||||
- `getCursor` extended to return phase, timer stamps, timing log, override slide.
|
||||
|
||||
Admin panel (`live-control-panel.tsx` revamp):
|
||||
- Project order list **grouped by category**, current/next highlighted, reorder preserved (existing `reorder` mutation), tap-to-`sendToScreens` any project (handles schedule shuffles).
|
||||
- Big phase buttons in flow order; live server-derived countdown, red + counting up when over; pause/resume.
|
||||
- Per-project duration override inputs (pre-filled from config).
|
||||
- Timing log table (per project: presentation over by X, Q&A over by Y).
|
||||
- Audience section: window open/close buttons per category + overall (gated by `allowOverallFavorite` toggle), duration picker (default 5 min), live countdown, **live vote count**, "Close now". QR button → full-screen dialog with giant QR (`qrcode.react` or equivalent tiny dep) linking to `/vote/competition/[roundId]`.
|
||||
- Override-slide buttons: Welcome / Break / Deliberation / Thank you / Clear.
|
||||
- Reveal section: see §9.
|
||||
|
||||
## 4. Jury live page
|
||||
|
||||
- Phase-aware: ON_DECK → "Up next: Team X" banner; PRESENTING/QA → project details, same countdown the admin sees, persistent notes; SCORING → scoring form spotlighted (form already available from PRESENTING on — early scorers not blocked).
|
||||
- **Notes**: `LiveNote` autosave (debounced ~800ms) via new `live.saveNote` / `live.getMyNotes` (juryProcedure). Notes resurface in deliberation.
|
||||
- Scoring: existing criteria form + new optional **comment** textarea → stored on `LiveVote.comment`. Votes upsert-editable until session COMPLETED.
|
||||
|
||||
## 5. Audience voting
|
||||
|
||||
`live-voting.ts` additions:
|
||||
|
||||
- `openAudienceWindow({sessionId, windowKey, durationMinutes})` (admin) — errors if already OPEN; `OVERALL` requires `allowOverallFavorite`.
|
||||
- `closeAudienceWindow({sessionId})` (admin) — early close allowed anytime.
|
||||
- `getAudienceWindow({sessionId})` (public) — phase, windowKey, closesAt, eligible projects (category members or all), my-vote-for-this-window (by token).
|
||||
- `castFavoriteVote({sessionId, token, projectId})` (public). Server-side gates, in order:
|
||||
1. `audiencePhase === OPEN`
|
||||
2. `now <= audienceWindowClosesAt` (source of truth even if no cron closes it)
|
||||
3. project's category matches windowKey (or OVERALL → any finalist project)
|
||||
4. token valid for session
|
||||
5. unique (session, windowKey, voter) — re-vote within open window **updates** the row (change-your-mind allowed while open)
|
||||
6. IP cap: ≥3 distinct-voter rows for (session, windowKey, ipAddress) → reject with friendly message
|
||||
- `getFavoriteTallies({sessionId})` (admin) — counts per project per windowKey.
|
||||
- `setAllowOverallFavorite({sessionId, allow})` (admin).
|
||||
|
||||
Audience page `/vote/competition/[roundId]` (rework): scan → auto-`registerAudienceVoter`, token in localStorage → states: **waiting** ("Voting opens after the presentations" + current presenting team name), **open** (category title, project cards, tap → confirm → done, countdown chip), **voted** ("Vote recorded — you can change it until the window closes"), **closed**. Mobile-first, zero-instruction usable.
|
||||
|
||||
No cron needed: vote-time + read-time checks enforce the close; window auto-renders as closed everywhere once `closesAt` passes.
|
||||
|
||||
## 6. Deliberation completion
|
||||
|
||||
- Fix jury page wiring: resolve `juryMemberId` from `session.participants` matching current user; derive `hasVoted` from `session.votes`.
|
||||
- Add per-project context panels to the jury deliberation page: **my finale scores** (from `LiveVote`, editable inline — edits upsert the same vote, audit-logged; "keep" = do nothing), **my live notes** (`LiveNote`), **document links** (reuse the judge-docs components/links from the finals docs feature).
|
||||
- Admin: existing create-session (per category), aggregate, runoff, `adminDecide` (manual rankings), finalize, result lock — verify end-to-end, no new build expected.
|
||||
|
||||
## 7. Big-screen ceremony view — `/live/ceremony/[roundId]` (public)
|
||||
|
||||
Full-screen, no chrome, dark-blue field, Montserrat, brand accents. **Pure derivation** of state (poll ~2s + reuse SSE hook where available):
|
||||
|
||||
| State | Display |
|
||||
|---|---|
|
||||
| `overrideSlide` set | That slide (Welcome / Break / Deliberation in progress / Thank you) |
|
||||
| Reveal REVEALING/ARMED | Reveal mode (§9) |
|
||||
| Audience window OPEN | Giant QR + "Vote for your favorite {category}" + countdown + **vote count only** |
|
||||
| Cursor ON_DECK | "Up next: Team X" |
|
||||
| Cursor PRESENTING/QA | Team name, category chip, phase label, large countdown (red overtime) |
|
||||
| Cursor SCORING | "Jury is scoring" interstitial |
|
||||
| Nothing active | Welcome slide |
|
||||
|
||||
## 8. Reveal controller (admin) — §3 panel section
|
||||
|
||||
- "Build reveal" generates `stepsJson` from deliberation results (per category 3rd→2nd→1st) + audience favorite tallies (per-category winners, overall if enabled): `[{kind:'category-intro',category}, {kind:'place',category,place,projectId}, …, {kind:'audience-award',windowKey,projectId}, {kind:'thanks'}]`. Editable/rebuildable while DRAFT (e.g., after adminDecide changes order).
|
||||
- Admin previews all steps privately. "Arm" → big screen shows a Results splash. "Next" advances `currentStepIndex` one step at a time. "Reset" → back to DRAFT, screen leaves reveal mode.
|
||||
- Router: `liveVoting.buildReveal`, `armReveal`, `revealNext`, `resetReveal` (admin) + reveal state included in the public ceremony-state query (steps beyond `currentStepIndex` are **never** sent to the public endpoint).
|
||||
|
||||
## 9. Reveal visuals (the gorgeous part)
|
||||
|
||||
Use the `frontend-design` skill when building. Requirements: cinematic step transitions (place card slides/fades up, 1st-place moment visibly bigger than 3rd/2nd), confetti or equivalent flourish on 1st place and audience award, team name in very large Montserrat 700, category chip, no scores shown unless step includes them, safe on 16:9 projector at distance (high contrast, no small text). Prefer CSS/tailwind animation; adding `framer-motion` is acceptable if it materially raises quality. Must degrade gracefully (refresh mid-reveal lands on current step).
|
||||
|
||||
## 10. Results tally audit
|
||||
|
||||
- Jury results: existing `getResults` weighted aggregation + tie detection — dedicated tests.
|
||||
- Audience awards are **counts from `AudienceFavoriteVote`**, kept separate from jury scores (separate awards). `audienceVoteWeight` blending stays available but is NOT used in the reveal unless explicitly configured; default 0.
|
||||
|
||||
## Out of scope (explicit)
|
||||
|
||||
Live ranking mode for jurors (stretch only, after everything else), automated tie-breaker revotes (admin_decides stands), audience phones showing live scores (vote-only page), session-level ceremony phase machine.
|
||||
|
||||
## Build order (cut line moves down, never breaks)
|
||||
|
||||
1. publicPaths fix + schema migration
|
||||
2. Audience windows + favorite votes + IP cap + audience page + QR (audience system complete)
|
||||
3. Phase model + server timers + admin panel revamp (ceremony operable)
|
||||
4. Jury page phases + persisted notes + vote comments (jury complete)
|
||||
5. Deliberation wiring fix + context panels (deliberation complete)
|
||||
6. Big-screen ceremony view (derived states + override slides)
|
||||
7. Reveal controller + reveal visuals
|
||||
8. Tally audit tests → stretch: live ranking toggle
|
||||
|
||||
Each layer: vitest tests + `npm run build` green before the next.
|
||||
|
||||
## Test matrix (vitest, follows tests/helpers.ts factory pattern)
|
||||
|
||||
- Window: open/cast OK; wrong-category reject; cast after closesAt reject (no cron); early close reject; re-open works; OVERALL requires toggle.
|
||||
- Favorite votes: one per token per window; re-vote updates while open; IP cap at 3 distinct voters; tallies correct.
|
||||
- Phases: transition sequence; timing log entries with correct overranSeconds; pause/resume accumulator math.
|
||||
- Notes: upsert per (round, project, user).
|
||||
- Deliberation: juryMemberId resolution, hasVoted, vote→aggregate→finalize with real juror identity.
|
||||
- Reveal: build from results, step advance, public endpoint never leaks un-revealed steps.
|
||||
- Results: weighted jury aggregation + tie detection regression tests.
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -61,11 +61,11 @@
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.1.0",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^7.0.7",
|
||||
"openai": "^6.16.0",
|
||||
"papaparse": "^5.4.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -12143,16 +12143,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": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
@@ -13428,6 +13418,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||
|
||||
@@ -75,11 +75,11 @@
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.1.0",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^7.0.7",
|
||||
"openai": "^6.16.0",
|
||||
"papaparse": "^5.4.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "MentorAssignment" ADD COLUMN "teamIntroducedAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "FinalistConfirmation" ADD COLUMN "reminderSentAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1,33 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "Hotel_programId_key";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "HotelStay" (
|
||||
"id" TEXT NOT NULL,
|
||||
"attendingMemberId" TEXT NOT NULL,
|
||||
"hotelId" TEXT NOT NULL,
|
||||
"roomNumber" TEXT,
|
||||
"checkInAt" TIMESTAMP(3),
|
||||
"checkOutAt" TIMESTAMP(3),
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "HotelStay_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "HotelStay_attendingMemberId_key" ON "HotelStay"("attendingMemberId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "HotelStay_hotelId_idx" ON "HotelStay"("hotelId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Hotel_programId_idx" ON "Hotel"("programId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HotelStay" ADD CONSTRAINT "HotelStay_attendingMemberId_fkey" FOREIGN KEY ("attendingMemberId") REFERENCES "AttendingMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HotelStay" ADD CONSTRAINT "HotelStay_hotelId_fkey" FOREIGN KEY ("hotelId") REFERENCES "Hotel"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add inviteSentAt to ExternalAttendee for dish-selection email tracking
|
||||
ALTER TABLE "ExternalAttendee" ADD COLUMN "inviteSentAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "FinalistConfirmation" ADD COLUMN "finalDocsReminderSentAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1,104 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LivePhase" AS ENUM ('ON_DECK', 'PRESENTING', 'QA', 'SCORING');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AudiencePhase" AS ENUM ('CLOSED', 'OPEN');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiveProgressCursor" ADD COLUMN "overrideSlide" TEXT,
|
||||
ADD COLUMN "phaseDurationSeconds" INTEGER,
|
||||
ADD COLUMN "phasePausedAccumMs" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "phasePausedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "phaseStartedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "projectPhase" "LivePhase" NOT NULL DEFAULT 'ON_DECK',
|
||||
ADD COLUMN "timingLogJson" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiveVote" ADD COLUMN "comment" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiveVotingSession" ADD COLUMN "allowOverallFavorite" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "audiencePhase" "AudiencePhase" NOT NULL DEFAULT 'CLOSED',
|
||||
ADD COLUMN "audienceWindowClosesAt" TIMESTAMP(3),
|
||||
ADD COLUMN "audienceWindowKey" TEXT,
|
||||
ADD COLUMN "audienceWindowOpenedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AudienceFavoriteVote" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"windowKey" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"audienceVoterId" TEXT NOT NULL,
|
||||
"ipAddress" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AudienceFavoriteVote_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LiveNote" (
|
||||
"id" TEXT NOT NULL,
|
||||
"roundId" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LiveNote_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RevealState" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'DRAFT',
|
||||
"stepsJson" JSONB NOT NULL,
|
||||
"currentStepIndex" INTEGER NOT NULL DEFAULT -1,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "RevealState_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AudienceFavoriteVote_sessionId_windowKey_ipAddress_idx" ON "AudienceFavoriteVote"("sessionId", "windowKey", "ipAddress");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AudienceFavoriteVote_sessionId_windowKey_projectId_idx" ON "AudienceFavoriteVote"("sessionId", "windowKey", "projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AudienceFavoriteVote_sessionId_windowKey_audienceVoterId_key" ON "AudienceFavoriteVote"("sessionId", "windowKey", "audienceVoterId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LiveNote_userId_idx" ON "LiveNote"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LiveNote_roundId_projectId_userId_key" ON "LiveNote"("roundId", "projectId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RevealState_sessionId_key" ON "RevealState"("sessionId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AudienceFavoriteVote" ADD CONSTRAINT "AudienceFavoriteVote_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "LiveVotingSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AudienceFavoriteVote" ADD CONSTRAINT "AudienceFavoriteVote_audienceVoterId_fkey" FOREIGN KEY ("audienceVoterId") REFERENCES "AudienceVoter"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AudienceFavoriteVote" ADD CONSTRAINT "AudienceFavoriteVote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LiveNote" ADD CONSTRAINT "LiveNote_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LiveNote" ADD CONSTRAINT "LiveNote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LiveNote" ADD CONSTRAINT "LiveNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RevealState" ADD CONSTRAINT "RevealState_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "LiveVotingSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -29,7 +29,6 @@ enum UserRole {
|
||||
MENTOR
|
||||
OBSERVER
|
||||
APPLICANT
|
||||
AWARD_MASTER
|
||||
AUDIENCE
|
||||
}
|
||||
|
||||
@@ -119,7 +118,6 @@ enum NotificationChannel {
|
||||
NONE
|
||||
}
|
||||
|
||||
|
||||
enum PartnerVisibility {
|
||||
ADMIN_ONLY
|
||||
JURY_VISIBLE
|
||||
@@ -134,7 +132,6 @@ enum PartnerType {
|
||||
OTHER
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// COMPETITION / ROUND ENGINE ENUMS
|
||||
// =============================================================================
|
||||
@@ -172,7 +169,6 @@ enum ProjectRoundStateValue {
|
||||
WITHDRAWN
|
||||
}
|
||||
|
||||
|
||||
enum CapMode {
|
||||
HARD
|
||||
SOFT
|
||||
@@ -351,6 +347,7 @@ model User {
|
||||
resourceAccess ResourceAccess[]
|
||||
submittedProjects Project[] @relation("ProjectSubmittedBy")
|
||||
liveVotes LiveVote[]
|
||||
liveNotes LiveNote[]
|
||||
|
||||
// Team membership & mentorship
|
||||
teamMemberships TeamMember[]
|
||||
@@ -429,6 +426,10 @@ model User {
|
||||
// Grand-finale logistics
|
||||
finalistAttendances AttendingMember[]
|
||||
|
||||
// Mentor change requests
|
||||
mentorChangeRequestsRequested MentorChangeRequest[] @relation("MentorChangeRequester")
|
||||
mentorChangeRequestsResolved MentorChangeRequest[] @relation("MentorChangeResolver")
|
||||
|
||||
@@index([role])
|
||||
@@index([status])
|
||||
}
|
||||
@@ -506,7 +507,7 @@ model Program {
|
||||
// Grand-finale logistics
|
||||
finalistSlotQuotas FinalistSlotQuota[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
hotel Hotel?
|
||||
hotels Hotel[]
|
||||
lunchEvent LunchEvent?
|
||||
|
||||
@@unique([name, year])
|
||||
@@ -630,7 +631,9 @@ model Project {
|
||||
assignments Assignment[]
|
||||
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
|
||||
teamMembers TeamMember[]
|
||||
mentorAssignment MentorAssignment?
|
||||
mentorAssignments MentorAssignment[]
|
||||
mentorFiles MentorFile[]
|
||||
mentorChangeRequests MentorChangeRequest[]
|
||||
filteringResults FilteringResult[]
|
||||
awardEligibilities AwardEligibility[]
|
||||
awardVotes AwardVote[]
|
||||
@@ -655,6 +658,10 @@ model Project {
|
||||
finalistConfirmation FinalistConfirmation?
|
||||
externalLunchAttendees ExternalAttendee[]
|
||||
|
||||
// Grand-finale ceremony
|
||||
audienceFavoriteVotes AudienceFavoriteVote[]
|
||||
liveNotes LiveNote[]
|
||||
|
||||
@@index([programId])
|
||||
@@index([status])
|
||||
@@index([tags])
|
||||
@@ -1186,6 +1193,13 @@ model LiveVotingSession {
|
||||
audienceRequireId Boolean @default(false) // Require email/phone for audience
|
||||
audienceVotingDuration Int? // Minutes (null = same as jury)
|
||||
|
||||
// Audience favorite-vote window (grand finale)
|
||||
audiencePhase AudiencePhase @default(CLOSED)
|
||||
audienceWindowKey String? // 'CATEGORY:STARTUP' | 'CATEGORY:BUSINESS_CONCEPT' | 'OVERALL'
|
||||
audienceWindowOpenedAt DateTime?
|
||||
audienceWindowClosesAt DateTime?
|
||||
allowOverallFavorite Boolean @default(false) // admin toggle, decided day-of
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -1193,6 +1207,8 @@ model LiveVotingSession {
|
||||
round Round? @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
votes LiveVote[]
|
||||
audienceVoters AudienceVoter[]
|
||||
favoriteVotes AudienceFavoriteVote[]
|
||||
revealState RevealState?
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
@@ -1209,6 +1225,9 @@ model LiveVote {
|
||||
// Criteria scores (used when votingMode="criteria")
|
||||
criterionScoresJson Json? @db.JsonB // { [criterionId]: score } - null for simple mode
|
||||
|
||||
// Optional overall comment from the juror (grand finale)
|
||||
comment String? @db.Text
|
||||
|
||||
// Audience voter link
|
||||
audienceVoterId String?
|
||||
|
||||
@@ -1238,11 +1257,79 @@ model AudienceVoter {
|
||||
// Relations
|
||||
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
votes LiveVote[]
|
||||
favoriteVotes AudienceFavoriteVote[]
|
||||
|
||||
@@index([sessionId])
|
||||
@@index([token])
|
||||
}
|
||||
|
||||
// One pick-one-favorite vote per audience member per voting window.
|
||||
// windowKey snapshots LiveVotingSession.audienceWindowKey at cast time so
|
||||
// per-category and overall votes coexist in one table.
|
||||
model AudienceFavoriteVote {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
windowKey String // 'CATEGORY:STARTUP' | 'CATEGORY:BUSINESS_CONCEPT' | 'OVERALL'
|
||||
projectId String
|
||||
audienceVoterId String
|
||||
ipAddress String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
audienceVoter AudienceVoter @relation(fields: [audienceVoterId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([sessionId, windowKey, audienceVoterId])
|
||||
@@index([sessionId, windowKey, ipAddress])
|
||||
@@index([sessionId, windowKey, projectId])
|
||||
}
|
||||
|
||||
// Per-juror per-project free-text notes taken during the live ceremony.
|
||||
// Resurfaces during deliberation.
|
||||
model LiveNote {
|
||||
id String @id @default(cuid())
|
||||
roundId String
|
||||
projectId String
|
||||
userId String
|
||||
content String @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([roundId, projectId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// Admin-driven results reveal for the big-screen ceremony view.
|
||||
// Steps beyond currentStepIndex are never exposed publicly.
|
||||
model RevealState {
|
||||
id String @id @default(cuid())
|
||||
sessionId String @unique
|
||||
status String @default("DRAFT") // DRAFT | ARMED | REVEALING | DONE
|
||||
stepsJson Json @db.JsonB // RevealStep[]
|
||||
currentStepIndex Int @default(-1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum LivePhase {
|
||||
ON_DECK
|
||||
PRESENTING
|
||||
QA
|
||||
SCORING
|
||||
}
|
||||
|
||||
enum AudiencePhase {
|
||||
CLOSED
|
||||
OPEN
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TEAM MEMBERSHIP
|
||||
// =============================================================================
|
||||
@@ -1271,7 +1358,7 @@ model TeamMember {
|
||||
|
||||
model MentorAssignment {
|
||||
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
|
||||
|
||||
// Assignment tracking
|
||||
@@ -1279,6 +1366,16 @@ model MentorAssignment {
|
||||
assignedAt DateTime @default(now())
|
||||
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
|
||||
aiConfidenceScore Float?
|
||||
expertiseMatchScore Float?
|
||||
@@ -1305,11 +1402,47 @@ model MentorAssignment {
|
||||
milestoneCompletions MentorMilestoneCompletion[]
|
||||
messages MentorMessage[]
|
||||
files MentorFile[]
|
||||
changeRequests MentorChangeRequest[] @relation("MentorChangeRequestTarget")
|
||||
|
||||
@@unique([projectId, mentorId])
|
||||
@@index([projectId])
|
||||
@@index([mentorId])
|
||||
@@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
|
||||
// =============================================================================
|
||||
@@ -1600,7 +1733,7 @@ model SpecialAward {
|
||||
evaluationRoundId String?
|
||||
juryGroupId String?
|
||||
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)
|
||||
|
||||
// Eligibility job tracking
|
||||
@@ -2109,6 +2242,15 @@ model LiveProgressCursor {
|
||||
activeOrderIndex Int @default(0)
|
||||
isPaused Boolean @default(false)
|
||||
|
||||
// Per-project ceremony phase + server-stamped timer (grand finale)
|
||||
projectPhase LivePhase @default(ON_DECK)
|
||||
phaseStartedAt DateTime?
|
||||
phaseDurationSeconds Int?
|
||||
phasePausedAt DateTime?
|
||||
phasePausedAccumMs Int @default(0)
|
||||
timingLogJson Json? @db.JsonB // [{projectId, phase, startedAt, endedAt, configuredSeconds, overranSeconds}]
|
||||
overrideSlide String? // big-screen override: 'welcome' | 'break' | 'deliberation' | 'thanks'
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -2235,6 +2377,7 @@ model Round {
|
||||
notificationLogs NotificationLog[]
|
||||
cohorts Cohort[]
|
||||
liveCursor LiveProgressCursor?
|
||||
liveNotes LiveNote[]
|
||||
|
||||
@@unique([competitionId, slug])
|
||||
@@unique([competitionId, sortOrder])
|
||||
@@ -2450,7 +2593,8 @@ model AssignmentIntent {
|
||||
|
||||
model MentorFile {
|
||||
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
|
||||
|
||||
fileName String
|
||||
@@ -2469,13 +2613,15 @@ model MentorFile {
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
mentorAssignment MentorAssignment? @relation(fields: [mentorAssignmentId], references: [id], onDelete: SetNull)
|
||||
uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id])
|
||||
promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
|
||||
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
|
||||
comments MentorFileComment[]
|
||||
promotionEvents SubmissionPromotionEvent[]
|
||||
|
||||
@@index([projectId])
|
||||
@@index([mentorAssignmentId])
|
||||
@@index([uploadedByUserId])
|
||||
}
|
||||
@@ -2710,6 +2856,8 @@ model FinalistConfirmation {
|
||||
declinedAt DateTime?
|
||||
declineReason String? // optional free-text on decline
|
||||
expiredAt DateTime?
|
||||
reminderSentAt DateTime? // set when the pre-deadline reminder is sent (cron)
|
||||
finalDocsReminderSentAt DateTime? // set when the grand-final document-upload reminder is sent (cron)
|
||||
promotedFromWaitlistEntryId String? @unique // null for original finalists
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -2734,6 +2882,7 @@ model AttendingMember {
|
||||
flightDetail FlightDetail?
|
||||
visaApplication VisaApplication?
|
||||
lunchPick MemberLunchPick?
|
||||
hotelStay HotelStay?
|
||||
|
||||
@@unique([confirmationId, userId])
|
||||
@@index([userId])
|
||||
@@ -2750,7 +2899,7 @@ enum FlightDetailStatus {
|
||||
|
||||
model Hotel {
|
||||
id String @id @default(cuid())
|
||||
programId String @unique // 1:1 — one hotel per edition
|
||||
programId String // many hotels per edition
|
||||
name String
|
||||
address String? @db.Text
|
||||
link String? // external URL to hotel page / booking confirmation
|
||||
@@ -2759,6 +2908,27 @@ model Hotel {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||
stays HotelStay[]
|
||||
|
||||
@@index([programId])
|
||||
}
|
||||
|
||||
/// Per-attendee hotel/room assignment (1:1 with AttendingMember, mirrors FlightDetail).
|
||||
model HotelStay {
|
||||
id String @id @default(cuid())
|
||||
attendingMemberId String @unique
|
||||
hotelId String
|
||||
roomNumber String?
|
||||
checkInAt DateTime?
|
||||
checkOutAt DateTime?
|
||||
notes String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
|
||||
hotel Hotel @relation(fields: [hotelId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([hotelId])
|
||||
}
|
||||
|
||||
model FlightDetail {
|
||||
@@ -2909,6 +3079,7 @@ model ExternalAttendee {
|
||||
dishId String?
|
||||
allergens Allergen[] @default([])
|
||||
allergenOther String?
|
||||
inviteSentAt DateTime? // when the dish-selection email was last sent (null = never invited)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -214,6 +214,78 @@ const NOTIFICATION_EMAIL_SETTINGS = [
|
||||
sendEmail: true,
|
||||
},
|
||||
|
||||
// Logistics notifications
|
||||
{
|
||||
notificationType: 'FINALIST_CONFIRMED',
|
||||
category: 'logistics',
|
||||
label: 'Finalist Confirmed',
|
||||
description: 'Admin alert when a team confirms their grand-finale attendance',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'FINALIST_DECLINED',
|
||||
category: 'logistics',
|
||||
label: 'Finalist Declined',
|
||||
description: 'Admin alert when a team declines or an admin declines their finalist slot',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'FINALIST_EXPIRED',
|
||||
category: 'logistics',
|
||||
label: 'Finalist Confirmation Expired',
|
||||
description: 'Admin alert when a pending confirmation passes its deadline without a response',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'FINALIST_WAITLIST_PROMOTED',
|
||||
category: 'logistics',
|
||||
label: 'Waitlist Promoted',
|
||||
description: 'Admin alert when a waitlisted team is promoted to a confirmed finalist slot',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'FINALIST_REMINDER',
|
||||
category: 'logistics',
|
||||
label: 'Confirmation Reminder',
|
||||
description: 'Reminder email to the team lead when the confirmation deadline is approaching',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'FINALIST_WITHDRAWN',
|
||||
category: 'logistics',
|
||||
label: 'Finalist Slot Withdrawn',
|
||||
description: 'Notification to the team when their confirmed grand-finale slot is withdrawn by an admin',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'TRAVEL_CONFIRMED',
|
||||
category: 'logistics',
|
||||
label: 'Travel Confirmed',
|
||||
description: 'Email to the attendee when their flight and travel details are confirmed',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'VISA_STATUS_UPDATE',
|
||||
category: 'logistics',
|
||||
label: 'Visa Status Update',
|
||||
description: 'Email to the attendee when their visa application status changes',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'GRAND_FINAL_DOCS_REMINDER',
|
||||
category: 'logistics',
|
||||
label: 'Final Documents Reminder',
|
||||
description: 'Reminder to finalist teams to upload their Grand Final documents before the deadline',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'GRAND_FINAL_DOCS_SUBMITTED',
|
||||
category: 'logistics',
|
||||
label: 'Final Documents Submitted',
|
||||
description: 'Notifies the team mentor when a finalist uploads a Grand Final document',
|
||||
sendEmail: false,
|
||||
},
|
||||
|
||||
// Admin notifications (in-app only by default)
|
||||
{
|
||||
notificationType: 'FILTERING_COMPLETE',
|
||||
|
||||
@@ -317,7 +317,6 @@ async function main() {
|
||||
{ email: 'matt@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' },
|
||||
{ email: 'matt@letsbe.solutions', name: 'Matt (Applicant)', role: UserRole.APPLICANT, password: '195260Mp!' },
|
||||
{ email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' },
|
||||
{ email: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' },
|
||||
]
|
||||
|
||||
const staffUsers: Record<string, string> = {}
|
||||
|
||||
33
scripts/configure-grand-final-requirements.mjs
Normal file
33
scripts/configure-grand-final-requirements.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
// scripts/configure-grand-final-requirements.mjs
|
||||
// Usage: node scripts/configure-grand-final-requirements.mjs (dry-run, prints plan)
|
||||
// node scripts/configure-grand-final-requirements.mjs --apply (writes)
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
const p = new PrismaClient()
|
||||
const APPLY = process.argv.includes('--apply')
|
||||
|
||||
const TARGET = [
|
||||
{ name: 'Final Presentation', acceptedMimeTypes: ['application/pdf'], sortOrder: 1, renameFrom: 'PDF presentation support' },
|
||||
{ name: 'Final Business Plan', acceptedMimeTypes: ['application/pdf'], sortOrder: 2 },
|
||||
{ name: '1-minute Video', acceptedMimeTypes: ['video/*'], sortOrder: 3, renameFrom: '1 minute video' },
|
||||
{ name: 'Executive Summary', acceptedMimeTypes: ['application/pdf'], sortOrder: 4 },
|
||||
]
|
||||
|
||||
const run = async () => {
|
||||
const round = await p.round.findFirst({ where: { roundType: 'LIVE_FINAL' }, orderBy: { sortOrder: 'desc' } })
|
||||
if (!round) throw new Error('No LIVE_FINAL round')
|
||||
const existing = await p.fileRequirement.findMany({ where: { roundId: round.id } })
|
||||
console.log(`Round "${round.name}" (${round.id}); existing reqs: ${existing.map((r) => r.name).join(', ') || 'none'}`)
|
||||
|
||||
for (const t of TARGET) {
|
||||
const match = existing.find((r) => r.name === t.name || (t.renameFrom && r.name === t.renameFrom))
|
||||
if (match) {
|
||||
console.log(`UPDATE "${match.name}" -> name="${t.name}" required=true sort=${t.sortOrder} mimes=${t.acceptedMimeTypes}`)
|
||||
if (APPLY) await p.fileRequirement.update({ where: { id: match.id }, data: { name: t.name, acceptedMimeTypes: t.acceptedMimeTypes, isRequired: true, sortOrder: t.sortOrder } })
|
||||
} else {
|
||||
console.log(`CREATE "${t.name}" required=true sort=${t.sortOrder} mimes=${t.acceptedMimeTypes}`)
|
||||
if (APPLY) await p.fileRequirement.create({ data: { roundId: round.id, name: t.name, acceptedMimeTypes: t.acceptedMimeTypes, isRequired: true, sortOrder: t.sortOrder } })
|
||||
}
|
||||
}
|
||||
console.log(APPLY ? 'APPLIED.' : 'DRY-RUN (pass --apply to write).')
|
||||
}
|
||||
run().catch((e) => { console.error(e); process.exit(1) }).finally(() => p.$disconnect())
|
||||
@@ -58,7 +58,7 @@ export default function EditAwardPage({
|
||||
const [votingEndAt, setVotingEndAt] = useState('')
|
||||
const [evaluationRoundId, setEvaluationRoundId] = useState('')
|
||||
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
|
||||
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
||||
@@ -236,7 +236,6 @@ export default function EditAwardPage({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -335,20 +335,20 @@ function RoundsDndGrid({
|
||||
function ConfidenceBadge({ confidence }: { confidence: number }) {
|
||||
if (confidence > 0.8) {
|
||||
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)}%
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (confidence >= 0.5) {
|
||||
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)}%
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
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)}%
|
||||
</Badge>
|
||||
)
|
||||
@@ -408,7 +408,7 @@ export default function AwardDetailPage({
|
||||
|
||||
// Deferred queries - only load when needed
|
||||
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' }
|
||||
)
|
||||
const { data: juryGroups } = trpc.juryGroup.list.useQuery(
|
||||
@@ -897,8 +897,8 @@ export default function AwardDetailPage({
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/40">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -910,8 +910,8 @@ export default function AwardDetailPage({
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
|
||||
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -923,8 +923,8 @@ export default function AwardDetailPage({
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-100 dark:bg-violet-950/40">
|
||||
<Users className="h-5 w-5 text-violet-600 dark:text-violet-400" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -936,8 +936,8 @@ export default function AwardDetailPage({
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-950/40">
|
||||
<Vote className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -1518,7 +1518,6 @@ export default function AwardDetailPage({
|
||||
onSubmit={async (rows) => {
|
||||
await bulkInvite.mutateAsync({
|
||||
awardId,
|
||||
role: 'AWARD_MASTER',
|
||||
invitees: rows.map((r) => ({ name: r.name || undefined, email: r.email })),
|
||||
})
|
||||
}}
|
||||
@@ -1613,7 +1612,7 @@ export default function AwardDetailPage({
|
||||
{/* Rounds Tab */}
|
||||
<TabsContent value="rounds" className="space-y-4">
|
||||
{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" />
|
||||
<p className="text-sm">
|
||||
Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects.
|
||||
@@ -1621,7 +1620,7 @@ export default function AwardDetailPage({
|
||||
</div>
|
||||
)}
|
||||
{!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" />
|
||||
<p className="text-sm">
|
||||
Link this award to a competition first before creating rounds.
|
||||
@@ -1751,16 +1750,16 @@ export default function AwardDetailPage({
|
||||
return (
|
||||
<TableRow
|
||||
key={r.project.id}
|
||||
className={isWinner ? 'bg-amber-50/80 dark:bg-amber-950/20' : ''}
|
||||
className={isWinner ? 'bg-amber-50/80' : ''}
|
||||
>
|
||||
<TableCell>
|
||||
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold ${
|
||||
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
|
||||
? 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-300'
|
||||
? 'bg-slate-200 text-slate-700'
|
||||
: 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'
|
||||
}`}>
|
||||
{i + 1}
|
||||
|
||||
7
src/app/(admin)/admin/finals-documents/page.tsx
Normal file
7
src/app/(admin)/admin/finals-documents/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { FinalsDocumentsReview } from '@/components/finals/finals-documents-review'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function AdminFinalsDocumentsPage() {
|
||||
return <FinalsDocumentsReview />
|
||||
}
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { toast } from 'sonner'
|
||||
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 = {
|
||||
HARD: 'Hard Cap',
|
||||
@@ -301,10 +301,10 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Round assignments */}
|
||||
{(group as any).rounds?.length > 0 && (
|
||||
{/* Round + Special-award assignments */}
|
||||
{((group as any).rounds?.length > 0 || (group as any).awards?.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(group as any).rounds.map((r: any) => (
|
||||
{(group as any).rounds?.map((r: any) => (
|
||||
<Badge
|
||||
key={r.id}
|
||||
variant="outline"
|
||||
@@ -319,6 +319,21 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
|
||||
{r.name}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ const ROLE_OPTIONS = [
|
||||
{ value: 'MENTOR', label: 'Mentors' },
|
||||
{ value: 'OBSERVER', label: 'Observers' },
|
||||
{ value: 'APPLICANT', label: 'Applicants' },
|
||||
{ value: 'AWARD_MASTER', label: 'Award Masters' },
|
||||
]
|
||||
|
||||
type AccessRule =
|
||||
|
||||
@@ -60,7 +60,6 @@ const ROLE_OPTIONS = [
|
||||
{ value: 'MENTOR', label: 'Mentors' },
|
||||
{ value: 'OBSERVER', label: 'Observers' },
|
||||
{ value: 'APPLICANT', label: 'Applicants' },
|
||||
{ value: 'AWARD_MASTER', label: 'Award Masters' },
|
||||
]
|
||||
|
||||
type AccessRule =
|
||||
|
||||
@@ -16,6 +16,7 @@ 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'
|
||||
import { EmailTemplatesTab } from '@/components/admin/logistics/email-templates-tab'
|
||||
|
||||
export default function LogisticsPage() {
|
||||
const { currentEdition } = useEdition()
|
||||
@@ -56,9 +57,8 @@ export default function LogisticsPage() {
|
||||
<TabsTrigger value="lunch">
|
||||
<Salad className="mr-2 h-4 w-4" /> Lunch
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="email-templates" disabled>
|
||||
<TabsTrigger value="email-templates">
|
||||
<ScrollText className="mr-2 h-4 w-4" /> Email Templates
|
||||
<span className="text-muted-foreground ml-1 text-xs">(soon)</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -77,6 +77,9 @@ export default function LogisticsPage() {
|
||||
<TabsContent value="lunch">
|
||||
<LunchTab programId={programId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="email-templates">
|
||||
<EmailTemplatesTab programId={programId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -48,6 +48,22 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} 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 { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
@@ -69,6 +85,11 @@ import {
|
||||
LogIn,
|
||||
Calendar,
|
||||
Clock,
|
||||
Link as LinkIcon,
|
||||
Copy,
|
||||
Check,
|
||||
Plus,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||
import { formatRelativeTime } from '@/lib/utils'
|
||||
@@ -97,7 +118,6 @@ const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
||||
PROGRAM_ADMIN: 'default',
|
||||
SUPER_ADMIN: 'default',
|
||||
APPLICANT: 'secondary',
|
||||
AWARD_MASTER: 'outline',
|
||||
AUDIENCE: 'outline',
|
||||
}
|
||||
|
||||
@@ -112,8 +132,45 @@ export default function MemberDetailPage() {
|
||||
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
|
||||
const updateUser = trpc.user.update.useMutation()
|
||||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
||||
const generateAccessLink = trpc.user.generateAccessLink.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)
|
||||
const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery(
|
||||
{ mentorId: userId, page: 1, perPage: 50 },
|
||||
@@ -138,6 +195,10 @@ export default function MemberDetailPage() {
|
||||
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
||||
const [showSuperAdminConfirm, setShowSuperAdminConfirm] = 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[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -154,7 +215,7 @@ export default function MemberDetailPage() {
|
||||
|
||||
const handleSave = async () => {
|
||||
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({
|
||||
id: userId,
|
||||
email: email || undefined,
|
||||
@@ -283,6 +344,21 @@ export default function MemberDetailPage() {
|
||||
{user.status === 'INVITED' ? 'Resend Invite' : 'Send Invitation'}
|
||||
</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
|
||||
variant="outline"
|
||||
onClick={handleImpersonate}
|
||||
@@ -622,7 +698,6 @@ export default function MemberDetailPage() {
|
||||
<SelectItem value="MENTOR">Mentor</SelectItem>
|
||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||
<SelectItem value="APPLICANT">Applicant</SelectItem>
|
||||
<SelectItem value="AWARD_MASTER">Award Master</SelectItem>
|
||||
<SelectItem value="AUDIENCE">Audience</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -630,26 +705,72 @@ export default function MemberDetailPage() {
|
||||
<div className="space-y-2">
|
||||
<Label>Additional Roles</Label>
|
||||
<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'll be
|
||||
asked to confirm each change.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['JURY_MEMBER', 'OBSERVER', 'MENTOR', 'AWARD_MASTER'] as const)
|
||||
.filter((r) => r !== role)
|
||||
.map((r) => (
|
||||
<label key={r} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={additionalRoles.includes(r)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setAdditionalRoles((prev) => [...prev, r])
|
||||
} else {
|
||||
setAdditionalRoles((prev) => prev.filter((x) => x !== r))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{additionalRoles.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground italic">
|
||||
None — only the primary role above
|
||||
</span>
|
||||
) : (
|
||||
additionalRoles.map((r) => (
|
||||
<Badge
|
||||
key={r}
|
||||
variant={roleColors[r] || 'secondary'}
|
||||
className="gap-1.5 pl-2 pr-1 py-0.5"
|
||||
>
|
||||
{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 className="space-y-2">
|
||||
@@ -821,6 +942,81 @@ export default function MemberDetailPage() {
|
||||
</Tabs>
|
||||
|
||||
{/* 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'll be able to
|
||||
switch between dashboards from the role switcher. Click
|
||||
“Save changes” below to apply.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This will revoke the {pendingAdditionalRole?.role.replace(/_/g, ' ')}
|
||||
{' '}dashboard from <strong>{name || user?.name || 'this user'}</strong>.
|
||||
They'll keep their primary role and any other additional
|
||||
roles. Click “Save changes” 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}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -848,6 +1044,67 @@ export default function MemberDetailPage() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</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't paste this in a public channel. Anyone with the link
|
||||
can sign in as this user until it'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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ import { useSession } from 'next-auth/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
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 {
|
||||
projectId: string
|
||||
@@ -106,7 +106,6 @@ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
SUPER_ADMIN: 'Super Admin',
|
||||
PROGRAM_ADMIN: 'Program Admin',
|
||||
AWARD_MASTER: 'Award Master',
|
||||
JURY_MEMBER: 'Jury Member',
|
||||
MENTOR: 'Mentor',
|
||||
OBSERVER: 'Observer',
|
||||
@@ -289,7 +288,7 @@ export default function MemberInvitePage() {
|
||||
const availableRoles = useMemo((): Role[] => {
|
||||
const roles: Role[] = []
|
||||
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')
|
||||
return roles
|
||||
}, [isSuperAdmin, isAdmin])
|
||||
@@ -423,8 +422,6 @@ export default function MemberInvitePage() {
|
||||
? 'SUPER_ADMIN'
|
||||
: rawRole === 'PROGRAM_ADMIN'
|
||||
? 'PROGRAM_ADMIN'
|
||||
: rawRole === 'AWARD_MASTER'
|
||||
? 'AWARD_MASTER'
|
||||
: rawRole === 'MENTOR'
|
||||
? 'MENTOR'
|
||||
: rawRole === 'OBSERVER'
|
||||
@@ -910,7 +907,7 @@ export default function MemberInvitePage() {
|
||||
</div>
|
||||
|
||||
{!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" />
|
||||
<div>
|
||||
<p className="font-medium">No invitations will be sent</p>
|
||||
|
||||
@@ -15,9 +15,12 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Table,
|
||||
@@ -27,15 +30,35 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
Check,
|
||||
Inbox,
|
||||
Loader2,
|
||||
Search,
|
||||
Sparkles,
|
||||
Users,
|
||||
UserPlus,
|
||||
} from 'lucide-react'
|
||||
import { getInitials, formatEnumLabel } from '@/lib/utils'
|
||||
|
||||
@@ -48,14 +71,34 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const [search, setSearch] = useState('')
|
||||
const [pendingMentorId, setPendingMentorId] = useState<string | null>(null)
|
||||
const [unassignTarget, setUnassignTarget] = useState<{
|
||||
assignmentId: string
|
||||
mentorName: string
|
||||
} | null>(null)
|
||||
const [selectedCandidateIds, setSelectedCandidateIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
|
||||
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id: projectId })
|
||||
|
||||
const { data: candidatesData, isLoading: candidatesLoading } =
|
||||
trpc.mentor.getCandidates.useQuery(
|
||||
{ projectId },
|
||||
{ enabled: !!project && !project.mentorAssignment },
|
||||
// Already-assigned mentors (full list). Project.get spreads the underlying
|
||||
// `mentorAssignments` relation so we can read it directly.
|
||||
const assignedMentorAssignments = useMemo(() => {
|
||||
if (!project) return []
|
||||
// The Prisma relation is included via `...project` spread; type comes
|
||||
// through the tRPC client.
|
||||
type Assignment = NonNullable<typeof project>['mentorAssignments'][number]
|
||||
return ((project as unknown as { mentorAssignments?: Assignment[] }).mentorAssignments ?? []).filter(
|
||||
(a) => !a.droppedAt,
|
||||
)
|
||||
}, [project])
|
||||
const assignedMentorIds = useMemo(
|
||||
() => new Set(assignedMentorAssignments.map((a) => a.mentorId)),
|
||||
[assignedMentorAssignments],
|
||||
)
|
||||
|
||||
const { data: candidatesData, isLoading: candidatesLoading } =
|
||||
trpc.mentor.getCandidates.useQuery({ projectId }, { enabled: !!project })
|
||||
|
||||
const {
|
||||
data: suggestionsData,
|
||||
@@ -63,15 +106,16 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
refetch: refetchSuggestions,
|
||||
} = trpc.mentor.getSuggestions.useQuery(
|
||||
{ projectId, limit: 5 },
|
||||
{ enabled: !!project && !project.mentorAssignment },
|
||||
{ enabled: !!project },
|
||||
)
|
||||
|
||||
const assignMutation = trpc.mentor.assign.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Mentor assigned')
|
||||
toast.success('Mentor added')
|
||||
utils.project.get.invalidate({ id: projectId })
|
||||
utils.mentor.getCandidates.invalidate({ projectId })
|
||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||
utils.mentor.getMentorPool.invalidate()
|
||||
setPendingMentorId(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -80,27 +124,61 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
},
|
||||
})
|
||||
|
||||
const bulkAssignMutation = trpc.mentor.bulkAssign.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.totalAssigned === 0) {
|
||||
toast.info('No new assignments — every chosen mentor was already on this team.')
|
||||
} else {
|
||||
toast.success(
|
||||
`Added ${result.totalAssigned} mentor${
|
||||
result.totalAssigned === 1 ? '' : 's'
|
||||
} to this team${
|
||||
result.emailsSent > 0
|
||||
? ` · ${result.emailsSent} email${result.emailsSent === 1 ? '' : 's'} sent`
|
||||
: ' · emails will go out when the mentoring round opens'
|
||||
}`,
|
||||
)
|
||||
}
|
||||
utils.project.get.invalidate({ id: projectId })
|
||||
utils.mentor.getCandidates.invalidate({ projectId })
|
||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||
utils.mentor.getMentorPool.invalidate()
|
||||
setSelectedCandidateIds(new Set())
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const unassignMutation = trpc.mentor.unassign.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Mentor removed')
|
||||
utils.project.get.invalidate({ id: projectId })
|
||||
utils.mentor.getCandidates.invalidate({ projectId })
|
||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||
setUnassignTarget(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
setUnassignTarget(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const filteredCandidates = useMemo(() => {
|
||||
if (!candidatesData) return []
|
||||
const base = candidatesData.candidates.filter((c) => !assignedMentorIds.has(c.id))
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return candidatesData.candidates
|
||||
return candidatesData.candidates.filter((c) => {
|
||||
if (!q) return base
|
||||
return base.filter((c) => {
|
||||
const hay = [c.name ?? '', c.email, ...(c.expertiseTags ?? []), c.country ?? '']
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return hay.includes(q)
|
||||
})
|
||||
}, [candidatesData, search])
|
||||
}, [candidatesData, search, assignedMentorIds])
|
||||
|
||||
const filteredSuggestions = useMemo(() => {
|
||||
if (!suggestionsData) return []
|
||||
return suggestionsData.suggestions.filter((s) => !assignedMentorIds.has(s.mentorId))
|
||||
}, [suggestionsData, assignedMentorIds])
|
||||
|
||||
if (projectLoading) return <MentorAssignmentSkeleton />
|
||||
if (!project) {
|
||||
@@ -113,7 +191,6 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
const hasMentor = !!project.mentorAssignment
|
||||
const teamSize = project.teamMembers?.length ?? 0
|
||||
const aiSource = suggestionsData?.source ?? 'ai'
|
||||
|
||||
@@ -206,80 +283,112 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ─── Pending Change Requests ─── */}
|
||||
<PendingChangeRequestsPanel projectId={projectId} />
|
||||
|
||||
{/* ─── Currently Assigned ─── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Currently Assigned</CardTitle>
|
||||
<CardDescription>
|
||||
{assignedMentorAssignments.length === 0
|
||||
? 'No mentors assigned yet'
|
||||
: `${assignedMentorAssignments.length} mentor${
|
||||
assignedMentorAssignments.length === 1 ? '' : 's'
|
||||
} on this team`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hasMentor ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{assignedMentorAssignments.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed py-8 text-center">
|
||||
<Users className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No mentors assigned yet — add one below.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{assignedMentorAssignments.map((a) => {
|
||||
const m = a.mentor
|
||||
const tags = m.expertiseTags ?? []
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-start justify-between gap-4 py-4 first:pt-0 last:pb-0"
|
||||
>
|
||||
<div className="flex flex-1 items-start gap-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarFallback>
|
||||
{getInitials(
|
||||
project.mentorAssignment!.mentor.name ||
|
||||
project.mentorAssignment!.mentor.email,
|
||||
)}
|
||||
{getInitials(m.name || m.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/admin/mentors/${project.mentorAssignment!.mentor.id}`}
|
||||
href={`/admin/mentors/${m.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{project.mentorAssignment!.mentor.name || 'Unnamed'}
|
||||
{m.name || 'Unnamed'}
|
||||
</Link>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{project.mentorAssignment!.mentor.email}
|
||||
</p>
|
||||
{project.mentorAssignment!.mentor.expertiseTags &&
|
||||
project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
|
||||
<p className="text-muted-foreground text-sm">{m.email}</p>
|
||||
{tags.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{project.mentorAssignment!.mentor.expertiseTags
|
||||
.slice(0, 5)
|
||||
.map((tag: string) => (
|
||||
{tags.slice(0, 5).map((tag: string) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{tags.length > 5 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{tags.length - 5}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
Assigned{' '}
|
||||
{new Date(a.assignedAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.mentorAssignment!.method.replace(/_/g, ' ')}
|
||||
{a.method.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => unassignMutation.mutate({ projectId })}
|
||||
onClick={() =>
|
||||
setUnassignTarget({
|
||||
assignmentId: a.id,
|
||||
mentorName: m.name || m.email,
|
||||
})
|
||||
}
|
||||
disabled={unassignMutation.isPending}
|
||||
>
|
||||
{unassignMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Unassign'
|
||||
)}
|
||||
Unassign
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No mentor assigned yet — pick one below.
|
||||
</p>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ─── Pick a Mentor ─── */}
|
||||
{!hasMentor && (
|
||||
{/* ─── Add a Mentor ─── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Pick a Mentor</CardTitle>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
Add a Mentor
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Browse all eligible mentors or use AI to surface the best fits.
|
||||
Stack additional mentors on this team. Browse all eligible mentors or use AI to surface the best fits.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -303,6 +412,41 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{selectedCandidateIds.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">{selectedCandidateIds.size}</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
mentor{selectedCandidateIds.size === 1 ? '' : 's'} selected
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
bulkAssignMutation.mutate({
|
||||
mentorIds: Array.from(selectedCandidateIds),
|
||||
projectIds: [projectId],
|
||||
})
|
||||
}
|
||||
disabled={bulkAssignMutation.isPending}
|
||||
>
|
||||
{bulkAssignMutation.isPending && (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Add {selectedCandidateIds.size} mentor
|
||||
{selectedCandidateIds.size === 1 ? '' : 's'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSelectedCandidateIds(new Set())}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{candidatesLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
@@ -311,13 +455,37 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
) : filteredCandidates.length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
No matching mentors. Try a different search.
|
||||
{assignedMentorIds.size > 0 && search.trim() === ''
|
||||
? 'All eligible mentors are already assigned.'
|
||||
: 'No matching mentors. Try a different search.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredCandidates.length > 0 &&
|
||||
filteredCandidates.every((c) =>
|
||||
selectedCandidateIds.has(c.id),
|
||||
)
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedCandidateIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) {
|
||||
filteredCandidates.forEach((c) => next.add(c.id))
|
||||
} else {
|
||||
filteredCandidates.forEach((c) => next.delete(c.id))
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
aria-label="Select all visible mentors"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Mentor</TableHead>
|
||||
<TableHead>Expertise</TableHead>
|
||||
<TableHead>Country</TableHead>
|
||||
@@ -328,7 +496,26 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCandidates.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableRow
|
||||
key={c.id}
|
||||
data-state={
|
||||
selectedCandidateIds.has(c.id) ? 'selected' : undefined
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedCandidateIds.has(c.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
setSelectedCandidateIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(c.id)
|
||||
else next.delete(c.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
aria-label={`Select ${c.name ?? c.email}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">{c.name ?? 'Unnamed'}</div>
|
||||
<div className="text-muted-foreground text-xs">{c.email}</div>
|
||||
@@ -376,7 +563,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-1 h-3.5 w-3.5" /> Assign
|
||||
<Check className="mr-1 h-3.5 w-3.5" /> Add
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -391,7 +578,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
|
||||
<TabsContent value="ai" className="space-y-4">
|
||||
{aiSource === 'fallback' && (
|
||||
<div className="flex items-start gap-3 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm dark:border-amber-700 dark:bg-amber-950/40">
|
||||
<div className="flex items-start gap-3 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
|
||||
<div>
|
||||
<p className="font-medium">AI matching unavailable</p>
|
||||
@@ -422,13 +609,15 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !suggestionsData || suggestionsData.suggestions.length === 0 ? (
|
||||
) : filteredSuggestions.length === 0 ? (
|
||||
<p className="text-muted-foreground py-8 text-center text-sm">
|
||||
No suggestions available.
|
||||
{assignedMentorIds.size > 0
|
||||
? 'All top suggestions are already assigned.'
|
||||
: 'No suggestions available.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{suggestionsData.suggestions.map((s, i) => (
|
||||
{filteredSuggestions.map((s, i) => (
|
||||
<div
|
||||
key={s.mentorId}
|
||||
className="flex items-start justify-between rounded-md border p-4"
|
||||
@@ -503,7 +692,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-1 h-3.5 w-3.5" /> Assign
|
||||
<Check className="mr-1 h-3.5 w-3.5" /> Add
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -515,8 +704,284 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ─── Unassign confirm ─── */}
|
||||
<AlertDialog
|
||||
open={!!unassignTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setUnassignTarget(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unassign mentor?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{unassignTarget
|
||||
? `Remove ${unassignTarget.mentorName} from this team? Other co-mentors will remain.`
|
||||
: ''}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={unassignMutation.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
if (!unassignTarget) return
|
||||
unassignMutation.mutate({ assignmentId: unassignTarget.assignmentId })
|
||||
}}
|
||||
disabled={unassignMutation.isPending}
|
||||
>
|
||||
{unassignMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Unassign'
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Pending Change Requests panel
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function PendingChangeRequestsPanel({ projectId }: { projectId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: requests, isLoading } = trpc.mentor.listChangeRequests.useQuery({
|
||||
projectId,
|
||||
status: 'PENDING',
|
||||
})
|
||||
|
||||
const [resolveTarget, setResolveTarget] = useState<{
|
||||
id: string
|
||||
status: 'RESOLVED' | 'DISMISSED'
|
||||
requesterName: string
|
||||
} | null>(null)
|
||||
const [resolutionNote, setResolutionNote] = useState('')
|
||||
|
||||
const resolveMutation = trpc.mentor.resolveChangeRequest.useMutation({
|
||||
onSuccess: (_, variables) => {
|
||||
toast.success(
|
||||
`Request marked ${variables.status === 'RESOLVED' ? 'resolved' : 'dismissed'}`,
|
||||
)
|
||||
utils.mentor.listChangeRequests.invalidate()
|
||||
setResolveTarget(null)
|
||||
setResolutionNote('')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Inbox className="h-5 w-5" />
|
||||
Pending change requests
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!requests || requests.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="border-amber-300">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Inbox className="h-5 w-5 text-amber-600" />
|
||||
Pending change requests
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{requests.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Team members or mentors have asked admin to change a mentor on this team.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-3">
|
||||
{requests.map((r) => (
|
||||
<ChangeRequestRow
|
||||
key={r.id}
|
||||
request={r}
|
||||
onResolve={(status) =>
|
||||
setResolveTarget({
|
||||
id: r.id,
|
||||
status,
|
||||
requesterName:
|
||||
r.requestedBy?.name ?? r.requestedBy?.email ?? 'Unknown',
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={!!resolveTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setResolveTarget(null)
|
||||
setResolutionNote('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{resolveTarget?.status === 'RESOLVED'
|
||||
? 'Mark request resolved'
|
||||
: 'Dismiss request'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{resolveTarget?.status === 'RESOLVED'
|
||||
? `You've taken action on the request from ${resolveTarget?.requesterName}. Optionally add a note explaining what was done.`
|
||||
: `Close the request from ${resolveTarget?.requesterName} without action. Optionally add a note explaining why.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="resolution-note">Resolution note (optional)</Label>
|
||||
<Textarea
|
||||
id="resolution-note"
|
||||
value={resolutionNote}
|
||||
onChange={(e) => setResolutionNote(e.target.value)}
|
||||
placeholder="e.g. Replaced Jane with John based on expertise mismatch."
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setResolveTarget(null)
|
||||
setResolutionNote('')
|
||||
}}
|
||||
disabled={resolveMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!resolveTarget) return
|
||||
resolveMutation.mutate({
|
||||
id: resolveTarget.id,
|
||||
status: resolveTarget.status,
|
||||
resolutionNote: resolutionNote.trim() || undefined,
|
||||
})
|
||||
}}
|
||||
disabled={resolveMutation.isPending}
|
||||
>
|
||||
{resolveMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : resolveTarget?.status === 'RESOLVED' ? (
|
||||
'Mark Resolved'
|
||||
) : (
|
||||
'Dismiss'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type ChangeRequestRowProps = {
|
||||
request: {
|
||||
id: string
|
||||
reason: string
|
||||
createdAt: Date
|
||||
requestedBy: { id: string; name: string | null; email: string } | null
|
||||
targetAssignment: {
|
||||
id: string
|
||||
mentor: { id: string; name: string | null; email: string }
|
||||
} | null
|
||||
}
|
||||
onResolve: (status: 'RESOLVED' | 'DISMISSED') => void
|
||||
}
|
||||
|
||||
function ChangeRequestRow({ request, onResolve }: ChangeRequestRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const reasonIsLong = request.reason.length > 240
|
||||
return (
|
||||
<li className="rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
<span className="font-medium">
|
||||
{request.requestedBy?.name ?? request.requestedBy?.email ?? 'Unknown'}
|
||||
</span>
|
||||
{request.requestedBy?.email && request.requestedBy.name && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{request.requestedBy.email}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
·{' '}
|
||||
{new Date(request.createdAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{request.targetAssignment && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
About:{' '}
|
||||
<span className="font-medium">
|
||||
{request.targetAssignment.mentor.name ||
|
||||
request.targetAssignment.mentor.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
className={
|
||||
expanded || !reasonIsLong
|
||||
? 'text-sm whitespace-pre-wrap'
|
||||
: 'text-sm whitespace-pre-wrap line-clamp-4'
|
||||
}
|
||||
>
|
||||
{request.reason}
|
||||
</p>
|
||||
{reasonIsLong && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary text-xs hover:underline"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-2">
|
||||
<Button size="sm" onClick={() => onResolve('RESOLVED')}>
|
||||
Mark Resolved
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onResolve('DISMISSED')}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -462,7 +462,7 @@ export default function BulkUploadPage() {
|
||||
return (
|
||||
<TableRow
|
||||
key={row.project.id}
|
||||
className={row.isComplete ? 'bg-green-50/50 dark:bg-green-950/10' : ''}
|
||||
className={row.isComplete ? 'bg-green-50/50' : ''}
|
||||
>
|
||||
<TableCell>
|
||||
<Link
|
||||
|
||||
@@ -53,15 +53,15 @@ type TeamMemberEntry = {
|
||||
}
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400',
|
||||
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
LEAD: 'bg-red-100 text-red-700',
|
||||
MEMBER: 'bg-teal-100 text-teal-700',
|
||||
ADVISOR: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
const ROLE_AVATAR_COLORS: Record<string, string> = {
|
||||
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
||||
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
|
||||
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
LEAD: 'bg-red-100 text-red-700',
|
||||
MEMBER: 'bg-teal-100 text-teal-700',
|
||||
ADVISOR: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
|
||||
@@ -679,7 +679,7 @@ export default function ProjectsPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
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 ? (
|
||||
<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">
|
||||
{/* Progress Indicator (when running) */}
|
||||
{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="flex items-center gap-3">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
|
||||
<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
|
||||
</p>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
<p className="text-sm text-blue-700">
|
||||
{jobStatus?.status === 'PENDING'
|
||||
? 'Initializing...'
|
||||
: `Processing ${jobStatus?.totalProjects || 0} projects with AI...`}
|
||||
@@ -1739,12 +1739,12 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<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?.taggedCount ? ` (${jobStatus.taggedCount} tagged)` : ''}
|
||||
</span>
|
||||
{jobStatus && jobStatus.totalProjects > 0 && (
|
||||
<span className="font-medium text-blue-900 dark:text-blue-100">
|
||||
<span className="font-medium text-blue-900">
|
||||
{taggingProgressPercent}%
|
||||
</span>
|
||||
)}
|
||||
@@ -1767,9 +1767,9 @@ export default function ProjectsPage() {
|
||||
{taggingResult && !taggingInProgress && (
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
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
|
||||
? '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'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
@@ -1804,12 +1804,12 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
{taggingResult.errors.length > 0 && (
|
||||
<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:
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto rounded bg-background/50 p-2 text-xs space-y-1">
|
||||
{taggingResult.errors.map((error, i) => (
|
||||
<p key={i} className="text-amber-700 dark:text-amber-300">
|
||||
<p key={i} className="text-amber-700">
|
||||
• {error}
|
||||
</p>
|
||||
))}
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||
import {
|
||||
ScoreDistributionChart,
|
||||
EvaluationTimelineChart,
|
||||
@@ -91,6 +92,17 @@ function ReportsOverview({ scope }: { scope: string | null }) {
|
||||
{ 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) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -198,6 +210,13 @@ function ReportsOverview({ scope }: { scope: string | null }) {
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
|
||||
{/* Applicant Nationalities */}
|
||||
<ApplicantNationalitiesCard
|
||||
data={nationalityStats}
|
||||
loading={nationalityLoading}
|
||||
scopeLabel={nationalityScopeLabel}
|
||||
/>
|
||||
|
||||
{/* Score Distribution (if any evaluations exist) */}
|
||||
{dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && (
|
||||
<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
|
||||
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
||||
if (!value) return {}
|
||||
|
||||
@@ -394,7 +394,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
|
||||
</Link>
|
||||
</Button>
|
||||
<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'}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
@@ -404,8 +404,8 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
|
||||
variant="secondary"
|
||||
className={
|
||||
project.competitionCategory === 'STARTUP'
|
||||
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
||||
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
|
||||
? 'bg-violet-100 text-violet-700 border-violet-200'
|
||||
: 'bg-sky-100 text-sky-700 border-sky-200'
|
||||
}
|
||||
>
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
@@ -415,7 +415,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
|
||||
</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">
|
||||
<UserCheck className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 text-sm">
|
||||
@@ -431,7 +431,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
|
||||
</Card>
|
||||
|
||||
{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">
|
||||
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
@@ -446,7 +446,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
|
||||
)}
|
||||
|
||||
{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">
|
||||
<Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 text-sm">
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function AdminJurorProxyEvaluatePage({ params: paramsPromise }: P
|
||||
</Link>
|
||||
</Button>
|
||||
<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
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
@@ -205,8 +205,8 @@ function AssignmentRow({ roundId, userId, assignment, mode }: AssignmentRowProps
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
project.competitionCategory === 'STARTUP'
|
||||
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
||||
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300',
|
||||
? 'bg-violet-100 text-violet-700 border-violet-200'
|
||||
: 'bg-sky-100 text-sky-700 border-sky-200',
|
||||
)}
|
||||
>
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
|
||||
@@ -79,6 +79,8 @@ import {
|
||||
ListChecks,
|
||||
FileText,
|
||||
Languages,
|
||||
MonitorPlay,
|
||||
Scale,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -92,8 +94,15 @@ import { ProjectStatesTable } from '@/components/admin/round/project-states-tabl
|
||||
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
|
||||
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 { LiveControlPanel } from '@/components/admin/live/live-control-panel'
|
||||
import { DeliberationControlPanel } from '@/components/admin/deliberation/deliberation-control-panel'
|
||||
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
|
||||
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
|
||||
import { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-card'
|
||||
import { FinalDocsReminderButton } from '@/components/admin/grand-finale/final-docs-reminder-button'
|
||||
import { FinalDocsUploadsToggle } from '@/components/admin/grand-finale/final-docs-uploads-toggle'
|
||||
import { ReviewDocsPicker } from '@/components/admin/grand-finale/review-docs-picker'
|
||||
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
|
||||
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
||||
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
||||
@@ -124,6 +133,7 @@ import { ConfigSectionHeader } from '@/components/admin/rounds/config/config-sec
|
||||
import { NotifyAdvancedButton } from '@/components/admin/round/notify-advanced-button'
|
||||
import { NotifyRejectedButton } from '@/components/admin/round/notify-rejected-button'
|
||||
import { BulkInviteButton } from '@/components/admin/round/bulk-invite-button'
|
||||
import { SendMentorshipWelcomeButton } from '@/components/admin/round/send-mentorship-welcome-button'
|
||||
import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card'
|
||||
import { FinalizationTab } from '@/components/admin/round/finalization-tab'
|
||||
|
||||
@@ -168,6 +178,10 @@ function MentoringBulkAssignToolbar({
|
||||
{ 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) => {
|
||||
@@ -190,23 +204,41 @@ function MentoringBulkAssignToolbar({
|
||||
— 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">
|
||||
project{count === 1 ? '' : 's'} eligible for auto-fill ({eligibilityLabel})
|
||||
of {eligibleTotal} project{eligibleTotal === 1 ? '' : 's'} still
|
||||
needs a mentor ({eligibilityLabel})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
All eligible projects have a mentor.
|
||||
All {eligibleTotal} eligible project{eligibleTotal === 1 ? '' : 's'}{' '}
|
||||
already have a mentor.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => bulk.mutate({ roundId })}
|
||||
disabled={isAdminSelected || count === 0 || bulk.isPending}
|
||||
disabled={isAdminSelected || count === 0 || hasNoMentors || bulk.isPending}
|
||||
>
|
||||
{bulk.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Auto-fill remaining
|
||||
@@ -945,6 +977,10 @@ export default function RoundDetailPage() {
|
||||
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
|
||||
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []),
|
||||
...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []),
|
||||
...(isGrandFinale ? [{ value: 'ceremony', label: 'Ceremony', icon: MonitorPlay }] : []),
|
||||
...(round?.roundType === 'DELIBERATION'
|
||||
? [{ value: 'deliberation', label: 'Deliberation', icon: Scale }]
|
||||
: []),
|
||||
...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
|
||||
...(showFinalization ? [{ value: 'finalization', label: 'Finalization', icon: ListChecks }] : []),
|
||||
{ value: 'config', label: 'Config', icon: Settings },
|
||||
@@ -1242,6 +1278,20 @@ export default function RoundDetailPage() {
|
||||
<div>
|
||||
<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">
|
||||
{isMentoring ? (
|
||||
<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" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Assign Projects</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Open the Projects tab to add or auto-fill teams in this round
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<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" />
|
||||
@@ -1253,6 +1303,7 @@ export default function RoundDetailPage() {
|
||||
</div>
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('projects')}
|
||||
@@ -1404,6 +1455,7 @@ export default function RoundDetailPage() {
|
||||
<NotifyAdvancedButton roundId={roundId} />
|
||||
<NotifyRejectedButton roundId={roundId} />
|
||||
<BulkInviteButton roundId={roundId} />
|
||||
{isMentoring && <SendMentorshipWelcomeButton roundId={roundId} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1486,10 +1538,26 @@ export default function RoundDetailPage() {
|
||||
|
||||
{/* Grand-finale logistics \u2014 only on LIVE_FINAL rounds */}
|
||||
{isGrandFinale && programId && (
|
||||
<>
|
||||
<FinalistEnrollmentCard programId={programId} roundId={roundId} />
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<FinalDocsUploadsToggle roundId={roundId} />
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/admin/finals-documents">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Review finalist documents
|
||||
</Link>
|
||||
</Button>
|
||||
<FinalDocsReminderButton programId={programId} />
|
||||
</div>
|
||||
</div>
|
||||
<ReviewDocsPicker programId={programId} roundId={roundId} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FinalistSlotsCard programId={programId} />
|
||||
<WaitlistCard programId={programId} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Round Info + Project Breakdown */}
|
||||
@@ -1570,8 +1638,17 @@ export default function RoundDetailPage() {
|
||||
{/* ═══════════ PROJECTS TAB ═══════════ */}
|
||||
<TabsContent value="projects" className="space-y-4">
|
||||
{isMentoring && (
|
||||
<>
|
||||
<MentoringBulkAssignToolbar roundId={roundId} configJson={config} />
|
||||
<MentoringProjectsTable
|
||||
roundId={roundId}
|
||||
competitionId={competitionId}
|
||||
competitionRounds={competition?.rounds}
|
||||
currentSortOrder={round?.sortOrder}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isMentoring && (
|
||||
<ProjectStatesTable
|
||||
competitionId={competitionId}
|
||||
roundId={roundId}
|
||||
@@ -1583,6 +1660,7 @@ export default function RoundDetailPage() {
|
||||
setTimeout(() => setPreviewSheetOpen(true), 100)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ═══════════ FILTERING TAB ═══════════ */}
|
||||
@@ -1592,6 +1670,20 @@ export default function RoundDetailPage() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* ═══════════ CEREMONY TAB (LIVE_FINAL) ═══════════ */}
|
||||
{isGrandFinale && (
|
||||
<TabsContent value="ceremony" className="space-y-4">
|
||||
<LiveControlPanel roundId={roundId} competitionId={competitionId} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* ═══════════ DELIBERATION TAB (DELIBERATION rounds) ═══════════ */}
|
||||
{round?.roundType === 'DELIBERATION' && (
|
||||
<TabsContent value="deliberation" className="space-y-4">
|
||||
<DeliberationControlPanel roundId={roundId} competitionId={competitionId} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* ═══════════ JURY TAB (non-EVALUATION jury rounds: LIVE_FINAL, DELIBERATION) ═══════════ */}
|
||||
{hasJury && !isEvaluation && (
|
||||
<TabsContent value="jury" className="space-y-6">
|
||||
@@ -2074,39 +2166,39 @@ export default function RoundDetailPage() {
|
||||
</p>
|
||||
)}
|
||||
{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="h-8 w-8 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
|
||||
</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-xs text-violet-600 dark:text-violet-400">
|
||||
<p className="text-sm font-medium text-violet-800">AI is analyzing projects and jurors...</p>
|
||||
<p className="text-xs text-violet-600">
|
||||
Matching expertise, reviewing bios, and balancing workloads
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{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" />
|
||||
<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
|
||||
</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-400">
|
||||
<p className="text-xs text-red-600">
|
||||
{aiAssignmentMutation.error.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{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" />
|
||||
<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
|
||||
</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.fallbackUsed && ' (algorithm fallback)'}
|
||||
</p>
|
||||
@@ -2588,9 +2680,9 @@ export default function RoundDetailPage() {
|
||||
|
||||
{/* Autosave error bar — only shows when save fails */}
|
||||
{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="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" />
|
||||
<span>Auto-save failed</span>
|
||||
</div>
|
||||
|
||||
@@ -8,69 +8,72 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import {
|
||||
Star,
|
||||
MessageSquare,
|
||||
Trophy,
|
||||
Vote,
|
||||
TrendingUp,
|
||||
BarChart3,
|
||||
Award,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
import { Star, MessageSquare, Trophy, Vote, ShieldCheck } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type EvaluationRound = {
|
||||
roundId: string
|
||||
roundName: string
|
||||
roundType: string
|
||||
evaluationCount: number
|
||||
evaluations: Array<{
|
||||
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
|
||||
}>
|
||||
}
|
||||
|
||||
function computeRoundStats(round: EvaluationRound) {
|
||||
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100
|
||||
type EvaluationRound = {
|
||||
roundId: string
|
||||
roundName: string
|
||||
roundType: string
|
||||
evaluationCount: number
|
||||
evaluations: Evaluation[]
|
||||
}
|
||||
|
||||
const HIDDEN_CRITERION_TYPES = new Set(['boolean', 'advance'])
|
||||
|
||||
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
|
||||
.map((ev) => ev.globalScore)
|
||||
.filter((s): s is number => s !== null)
|
||||
const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null
|
||||
const highest = scores.length > 0 ? Math.max(...scores) : null
|
||||
const lowest = scores.length > 0 ? Math.min(...scores) : null
|
||||
return { maxScore, avg, highest, lowest, scores }
|
||||
}
|
||||
|
||||
function ScoreBar({ score, maxScore, color }: { score: number; maxScore: number; color: string }) {
|
||||
const pct = (score / maxScore) * 100
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="flex-1 overflow-hidden rounded-full bg-muted" style={{ height: 10 }}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold tabular-nums w-8 text-right">{score}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getScoreColor(score: number, maxScore: number): string {
|
||||
const pct = score / maxScore
|
||||
if (pct >= 0.8) return '#053d57'
|
||||
if (pct >= 0.6) return '#1e7a8a'
|
||||
if (pct >= 0.4) return '#557f8c'
|
||||
if (pct >= 0.2) return '#c4453a'
|
||||
return '#de0f1e'
|
||||
if (scores.length === 0) return null
|
||||
const max = 10
|
||||
const avg = scores.reduce((a, b) => a + b, 0) / scores.length
|
||||
const lowest = Math.min(...scores)
|
||||
const highest = Math.max(...scores)
|
||||
return { avg, lowest, highest, max }
|
||||
}
|
||||
|
||||
function RoundIcon({ roundType, className }: { roundType: string; className?: string }) {
|
||||
@@ -85,6 +88,48 @@ function roundIconBg(roundType: string) {
|
||||
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() {
|
||||
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>
|
||||
<p className="text-muted-foreground">Anonymous evaluations from jury members</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-px overflow-hidden rounded-lg border bg-border">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-card p-4">
|
||||
<Skeleton className="h-5 w-20 mb-2" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<Card key={i}>
|
||||
@@ -119,34 +156,14 @@ export default function ApplicantEvaluationsPage() {
|
||||
|
||||
const hasEvaluations = rounds && rounds.length > 0
|
||||
|
||||
// Compute global stats
|
||||
const allScores: number[] = []
|
||||
let totalEvaluations = 0
|
||||
if (rounds) {
|
||||
for (const round of rounds) {
|
||||
totalEvaluations += round.evaluationCount
|
||||
for (const ev of round.evaluations) {
|
||||
if (ev.globalScore !== null && round.roundType !== 'DELIBERATION') {
|
||||
// Normalize to 0-100 for live final scores
|
||||
const normalized = round.roundType === 'LIVE_FINAL'
|
||||
? ev.globalScore * 10
|
||||
: ev.globalScore
|
||||
allScores.push(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const globalAvg = allScores.length > 0
|
||||
? allScores.reduce((a, b) => a + b, 0) / allScores.length
|
||||
: null
|
||||
const globalHighest = allScores.length > 0 ? Math.max(...allScores) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -164,105 +181,44 @@ export default function ApplicantEvaluationsPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<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) => {
|
||||
const { maxScore, avg, highest, lowest } = computeRoundStats(round)
|
||||
const summary = globalScoreSummary(round)
|
||||
|
||||
return (
|
||||
<AnimatedCard key={round.roundId} index={roundIdx + 1}>
|
||||
<AnimatedCard key={round.roundId} index={roundIdx}>
|
||||
<Card>
|
||||
<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">
|
||||
<div className={cn('rounded-lg p-1.5', roundIconBg(round.roundType))}>
|
||||
<RoundIcon roundType={round.roundType} />
|
||||
</div>
|
||||
<div>
|
||||
<span>{round.roundName}</span>
|
||||
{avg !== null && round.roundType !== 'DELIBERATION' && (
|
||||
{summary && (
|
||||
<p className="text-sm font-normal text-muted-foreground mt-0.5">
|
||||
Average: <span className="font-semibold text-foreground">{avg.toFixed(1)}</span> / {maxScore}
|
||||
{highest !== null && lowest !== null && highest !== lowest && (
|
||||
<span className="ml-2">
|
||||
Range: {lowest}–{highest}
|
||||
</span>
|
||||
Average <span className="font-semibold text-foreground">{summary.avg.toFixed(1)}</span> / {summary.max}
|
||||
{summary.lowest !== summary.highest && (
|
||||
<span className="ml-2">· Lowest {summary.lowest} · Highest {summary.highest}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
<Badge variant="secondary">
|
||||
{round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'evaluation'}{round.evaluationCount !== 1 ? 's' : ''}
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'review'}{round.evaluationCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Score Overview Bar — visual comparison across evaluators */}
|
||||
{round.roundType !== 'DELIBERATION' && round.evaluations.some((ev) => ev.globalScore !== null) && (
|
||||
<div className="px-6 pb-3">
|
||||
<div className="rounded-lg bg-muted/40 p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Score Comparison</p>
|
||||
{round.evaluations.map((ev, idx) => {
|
||||
if (ev.globalScore === null) return null
|
||||
return (
|
||||
<div key={ev.id} className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground w-6 text-right shrink-0 tabular-nums">
|
||||
#{idx + 1}
|
||||
</span>
|
||||
<ScoreBar
|
||||
score={ev.globalScore}
|
||||
maxScore={maxScore}
|
||||
color={getScoreColor(ev.globalScore, maxScore)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{round.evaluations.map((ev, idx) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="px-6 py-4 space-y-3"
|
||||
>
|
||||
{round.evaluations.map((ev, idx) => {
|
||||
const criteria = visibleCriteria(ev.criteria)
|
||||
const scores = (ev.criterionScores ?? {}) as Record<string, unknown>
|
||||
|
||||
return (
|
||||
<div key={ev.id} className="px-6 py-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">
|
||||
{round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`}
|
||||
@@ -272,7 +228,7 @@ export default function ApplicantEvaluationsPage() {
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 text-yellow-500" />
|
||||
<span className="text-sm font-bold tabular-nums">{ev.globalScore}</span>
|
||||
<span className="text-xs text-muted-foreground">/ {maxScore}</span>
|
||||
<span className="text-xs text-muted-foreground">/ 10</span>
|
||||
</span>
|
||||
)}
|
||||
{ev.submittedAt && (
|
||||
@@ -283,37 +239,23 @@ export default function ApplicantEvaluationsPage() {
|
||||
</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) => {
|
||||
{criteria.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{criteria.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>
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -324,14 +266,15 @@ export default function ApplicantEvaluationsPage() {
|
||||
{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">
|
||||
<p className="text-sm italic text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{ev.feedbackText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -339,7 +282,6 @@ export default function ApplicantEvaluationsPage() {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Confidentiality Footer */}
|
||||
<div className="flex items-center justify-center gap-2 py-2">
|
||||
<ShieldCheck className="h-4 w-4 text-muted-foreground/60" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { format } from 'date-fns'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -9,13 +11,18 @@ import {
|
||||
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 { MentorChat } from '@/components/shared/mentor-chat'
|
||||
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
||||
import { FinalDocumentsPanel } from '@/components/applicant/final-documents-panel'
|
||||
import { RequestChangeDialog } from './request-change-dialog'
|
||||
import {
|
||||
MessageSquare,
|
||||
UserCircle,
|
||||
FileText,
|
||||
UserCog,
|
||||
} from 'lucide-react'
|
||||
|
||||
export default function ApplicantMentorPage() {
|
||||
@@ -41,6 +48,8 @@ export default function ApplicantMentorPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const [isChangeOpen, setIsChangeOpen] = useState(false)
|
||||
|
||||
if (dashLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -72,7 +81,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 (
|
||||
<div className="space-y-6">
|
||||
@@ -83,23 +105,72 @@ export default function ApplicantMentorPage() {
|
||||
Mentor Communication
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Mentor info */}
|
||||
{mentor ? (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserCircle className="h-10 w-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">{mentor.name || 'Mentor'}</p>
|
||||
<p className="text-sm text-muted-foreground">{mentor.email}</p>
|
||||
{/* Mentor list */}
|
||||
{hasMentors ? (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{teamHeading}</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{assignments.map((assignment) => {
|
||||
const mentor = assignment.mentor
|
||||
if (!mentor) return null
|
||||
const expertise = mentor.expertiseTags ?? []
|
||||
return (
|
||||
<Card key={assignment.id} className="bg-muted/50">
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<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">
|
||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||
@@ -113,12 +184,14 @@ export default function ApplicantMentorPage() {
|
||||
)}
|
||||
|
||||
{/* Chat */}
|
||||
{mentor && (
|
||||
{primaryMentor && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Messages</CardTitle>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -136,12 +209,26 @@ export default function ApplicantMentorPage() {
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{dashboardData?.project?.mentorAssignment?.id && (
|
||||
{primaryAssignment?.id && projectId && (
|
||||
<WorkspaceFilesPanel
|
||||
mentorAssignmentId={dashboardData.project.mentorAssignment.id}
|
||||
projectId={projectId}
|
||||
mentorAssignmentId={primaryAssignment.id}
|
||||
asApplicant
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Final Documents (self-hides when not a finalist) */}
|
||||
<FinalDocumentsPanel variant="team" />
|
||||
|
||||
{/* Request change dialog */}
|
||||
{projectId && (
|
||||
<RequestChangeDialog
|
||||
projectId={projectId}
|
||||
mentors={dialogMentors}
|
||||
open={isChangeOpen}
|
||||
onOpenChange={setIsChangeOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
179
src/app/(applicant)/applicant/mentor/request-change-dialog.tsx
Normal file
179
src/app/(applicant)/applicant/mentor/request-change-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { CompetitionTimelineSidebar } from '@/components/applicant/competition-t
|
||||
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 { MyLogisticsCard } from '@/components/applicant/my-logistics-card'
|
||||
import { FinalDocumentsBanner } from '@/components/applicant/final-documents-banner'
|
||||
import { LunchBanner } from '@/components/applicant/lunch-banner'
|
||||
import { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
@@ -205,6 +207,9 @@ export default function ApplicantDashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grand Final document upload banner (auto-hides for non-finalists) */}
|
||||
<FinalDocumentsBanner />
|
||||
|
||||
{/* Active round deadline banner */}
|
||||
{!isRejected && openRounds.length > 0 && (() => {
|
||||
const submissionTypes = new Set(['INTAKE', 'SUBMISSION', 'MENTORING'])
|
||||
@@ -219,12 +224,12 @@ export default function ApplicantDashboardPage() {
|
||||
key={round.id}
|
||||
className={`flex flex-col sm:flex-row items-start sm:items-center gap-3 rounded-lg border px-4 py-3 ${
|
||||
isUrgent
|
||||
? 'border-amber-500/50 bg-amber-50 dark:bg-amber-950/20'
|
||||
? 'border-amber-500/50 bg-amber-50'
|
||||
: 'border-primary/20 bg-primary/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Clock className={`h-4 w-4 shrink-0 ${isUrgent ? 'text-amber-600 dark:text-amber-400' : 'text-primary'}`} />
|
||||
<Clock className={`h-4 w-4 shrink-0 ${isUrgent ? 'text-amber-600' : 'text-primary'}`} />
|
||||
<span className="font-medium text-sm truncate">{round.name}</span>
|
||||
<Badge variant={isUrgent ? 'warning' : 'default'} className="shrink-0">
|
||||
{remaining > 0 ? formatCountdown(remaining) + ' left' : 'Closed'}
|
||||
@@ -414,6 +419,9 @@ export default function ApplicantDashboardPage() {
|
||||
{/* Grand finale attendee roster (auto-hides until confirmation status is CONFIRMED) */}
|
||||
<AttendingMembersCard />
|
||||
|
||||
{/* Grand-finale logistics: hotel, flight, visa (auto-hides when not a confirmed finalist) */}
|
||||
<MyLogisticsCard />
|
||||
|
||||
{/* Conversation with assigned mentor (auto-hides when no mentor assigned) */}
|
||||
<MentorConversationCard projectId={project.id} />
|
||||
|
||||
@@ -439,13 +447,14 @@ export default function ApplicantDashboardPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{evaluations?.map((round) => {
|
||||
const showScore = round.roundType !== 'DELIBERATION'
|
||||
const scores = round.evaluations
|
||||
.map((ev) => ev.globalScore)
|
||||
.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
|
||||
: null
|
||||
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100
|
||||
const maxScore = 10
|
||||
const pct = avgScore !== null ? (avgScore / maxScore) * 100 : 0
|
||||
const roundIcon = round.roundType === 'LIVE_FINAL'
|
||||
? <Trophy className="h-3.5 w-3.5 text-amber-500" />
|
||||
|
||||
@@ -357,15 +357,33 @@ export default function ApplicantProjectPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mentor info */}
|
||||
{project.mentorAssignment?.mentor && (
|
||||
{(() => {
|
||||
type MentorAssignment = {
|
||||
droppedAt: Date | string | null
|
||||
mentor: { name: string | null; email: string } | null
|
||||
}
|
||||
const active = (
|
||||
(project.mentorAssignments as MentorAssignment[] | undefined) ?? []
|
||||
).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">Assigned Mentor</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.mentorAssignment.mentor.name} ({project.mentorAssignment.mentor.email})
|
||||
<p className="text-sm font-medium mb-1">
|
||||
{active.length === 1 ? 'Assigned Mentor' : `Assigned Mentors (${active.length})`}
|
||||
</p>
|
||||
</div>
|
||||
<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 */}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
|
||||
@@ -160,8 +160,12 @@ function AcceptInviteContent() {
|
||||
setState('error')
|
||||
setErrorType('AUTH_FAILED')
|
||||
} else if (result?.ok) {
|
||||
// Redirect to set-password (middleware will enforce this since mustSetPassword=true)
|
||||
window.location.href = '/set-password'
|
||||
// Let app/page.tsx route by role. Middleware will detour to
|
||||
// /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 {
|
||||
setState('error')
|
||||
|
||||
@@ -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">
|
||||
“{vote.justification}”
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -13,16 +13,29 @@ import {
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 {
|
||||
ArrowLeft,
|
||||
Trophy,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
GripVertical,
|
||||
ChevronDown,
|
||||
Users,
|
||||
Tag,
|
||||
Star,
|
||||
Gavel,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
@@ -50,11 +63,19 @@ export default function JuryAwardVotingPage({
|
||||
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>(
|
||||
null
|
||||
)
|
||||
const [rankedIds, setRankedIds] = useState<string[]>([])
|
||||
const [justification, setJustification] = useState('')
|
||||
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleExpanded = (projectId: string) => {
|
||||
@@ -71,6 +92,9 @@ export default function JuryAwardVotingPage({
|
||||
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
|
||||
if (data.award.scoringMode === 'PICK_WINNER') {
|
||||
setSelectedProjectId(data.myVotes[0]?.projectId || null)
|
||||
if (data.myVotes[0]?.justification) {
|
||||
setJustification(data.myVotes[0].justification)
|
||||
}
|
||||
} else if (data.award.scoringMode === 'RANKED') {
|
||||
const sorted = [...data.myVotes]
|
||||
.sort((a, b) => (a.rank || 0) - (b.rank || 0))
|
||||
@@ -84,7 +108,10 @@ export default function JuryAwardVotingPage({
|
||||
try {
|
||||
await submitVote.mutateAsync({
|
||||
awardId,
|
||||
votes: [{ projectId: selectedProjectId }],
|
||||
votes: [{
|
||||
projectId: selectedProjectId,
|
||||
justification: justification.trim() || undefined,
|
||||
}],
|
||||
})
|
||||
toast.success('Vote submitted')
|
||||
refetch()
|
||||
@@ -136,9 +163,10 @@ export default function JuryAwardVotingPage({
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const { award, projects, myVotes } = 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'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -197,10 +225,30 @@ export default function JuryAwardVotingPage({
|
||||
isExpanded={expandedProjects.has(project.id)}
|
||||
onSelect={() => setSelectedProjectId(project.id)}
|
||||
onToggleExpand={() => toggleExpanded(project.id)}
|
||||
|
||||
/>
|
||||
))}
|
||||
</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">
|
||||
<Button
|
||||
onClick={handleSubmitPickWinner}
|
||||
@@ -214,6 +262,18 @@ export default function JuryAwardVotingPage({
|
||||
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isChair && totalJurors > 1 && (
|
||||
<ChairPanel
|
||||
award={award}
|
||||
projects={projects}
|
||||
otherVotes={otherVotes}
|
||||
totalJurors={totalJurors}
|
||||
hasVoted={hasVoted}
|
||||
onConfirm={() => confirmWinner.mutate({ awardId })}
|
||||
isPending={confirmWinner.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : award.scoringMode === 'RANKED' ? (
|
||||
/* RANKED Mode */
|
||||
@@ -332,6 +392,7 @@ type ProjectData = {
|
||||
tags: string[]
|
||||
logoKey?: string | null
|
||||
logoUrl?: string | null
|
||||
evaluationScore?: { avg: number; count: number } | null
|
||||
files: Array<{
|
||||
id: 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 }) {
|
||||
return (
|
||||
<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 && (
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line">{project.description}</p>
|
||||
)}
|
||||
@@ -435,7 +518,7 @@ function ProjectCard({
|
||||
isExpanded && 'rotate-180'
|
||||
)} />
|
||||
<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}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
@@ -469,3 +552,139 @@ function ProjectCard({
|
||||
</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's a tie, your own vote breaks it.
|
||||
Voting will close immediately and this can'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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,76 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import { use, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { LiveVotingForm } from '@/components/jury/live-voting-form'
|
||||
import { remainingSeconds, formatClock } from '@/lib/live-timer'
|
||||
import { Clock, Mic2, MessageCircleQuestion, PenLine, Sparkles } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const PHASE_META: Record<string, { label: string; icon: typeof Mic2 }> = {
|
||||
PRESENTING: { label: 'Presentation', icon: Mic2 },
|
||||
QA: { label: 'Q&A', icon: MessageCircleQuestion },
|
||||
SCORING: { label: 'Scoring open', icon: PenLine },
|
||||
}
|
||||
|
||||
function PhaseCountdown({ phase }: { phase: {
|
||||
phaseStartedAt: Date | string | null
|
||||
phaseDurationSeconds: number | null
|
||||
phasePausedAt: Date | string | null
|
||||
phasePausedAccumMs: number
|
||||
} }) {
|
||||
const [, tick] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => tick((t) => t + 1), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
const remaining = remainingSeconds(phase)
|
||||
if (remaining === null) return null
|
||||
const over = remaining < 0
|
||||
return (
|
||||
<Badge
|
||||
variant={over ? 'destructive' : 'secondary'}
|
||||
className={`gap-1 tabular-nums text-sm ${over ? 'animate-pulse' : ''}`}
|
||||
>
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{formatClock(remaining)}
|
||||
{over && <span className="font-semibold">OVER</span>}
|
||||
{phase.phasePausedAt && <span>· paused</span>}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export default function JuryLivePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
|
||||
const params = use(paramsPromise)
|
||||
const utils = trpc.useUtils()
|
||||
const [notes, setNotes] = useState('')
|
||||
const [priorDataOpen, setPriorDataOpen] = useState(false)
|
||||
|
||||
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId })
|
||||
|
||||
// Fetch live voting session data
|
||||
const { data: sessionData } = trpc.liveVoting.getSessionForVoting.useQuery(
|
||||
{ sessionId: params.roundId },
|
||||
{ enabled: !!params.roundId, refetchInterval: 2000 }
|
||||
const { data: cursor } = trpc.live.getCursor.useQuery(
|
||||
{ roundId: params.roundId },
|
||||
{ refetchInterval: 2000 }
|
||||
)
|
||||
const { data: sessionData } = trpc.liveVoting.getSessionForVotingByRound.useQuery(
|
||||
{ roundId: params.roundId },
|
||||
{ refetchInterval: 2000 }
|
||||
)
|
||||
const { data: myNotes } = trpc.live.getMyNotes.useQuery({ roundId: params.roundId })
|
||||
|
||||
// Placeholder for prior data - this would need to be implemented in evaluation router
|
||||
const priorData = null as { averageScore?: number; evaluationCount?: number; strengths?: string; weaknesses?: string } | null
|
||||
|
||||
const submitVoteMutation = trpc.liveVoting.vote.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.liveVoting.getSessionForVoting.invalidate()
|
||||
toast.success('Vote submitted successfully')
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
// ── Persisted notes (autosave, keyed per project) ────────────────────────
|
||||
const [noteDrafts, setNoteDrafts] = useState<Record<string, string>>({})
|
||||
const [noteStatus, setNoteStatus] = useState<'idle' | 'saving' | 'saved'>('idle')
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const saveNote = trpc.live.saveNote.useMutation({
|
||||
onSuccess: () => setNoteStatus('saved'),
|
||||
onError: () => setNoteStatus('idle'),
|
||||
})
|
||||
|
||||
const handleVoteSubmit = (vote: { score: number; criterionScores?: Record<string, number> }) => {
|
||||
const projectId = cursor?.activeProject?.id || sessionData?.currentProject?.id
|
||||
if (!projectId) return
|
||||
const activeProject = cursor?.activeProject ?? null
|
||||
const activeProjectId = activeProject?.id ?? null
|
||||
|
||||
const sessionId = sessionData?.session?.id || params.roundId
|
||||
const savedNoteFor = useMemo(() => {
|
||||
const map: Record<string, string> = {}
|
||||
for (const n of myNotes ?? []) map[n.projectId] = n.content
|
||||
return map
|
||||
}, [myNotes])
|
||||
|
||||
const currentDraft =
|
||||
activeProjectId != null
|
||||
? noteDrafts[activeProjectId] ?? savedNoteFor[activeProjectId] ?? ''
|
||||
: ''
|
||||
|
||||
const handleNoteChange = (value: string) => {
|
||||
if (!activeProjectId) return
|
||||
setNoteDrafts((d) => ({ ...d, [activeProjectId]: value }))
|
||||
setNoteStatus('saving')
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current)
|
||||
const projectId = activeProjectId
|
||||
saveTimer.current = setTimeout(() => {
|
||||
saveNote.mutate({ roundId: params.roundId, projectId, content: value })
|
||||
}, 800)
|
||||
}
|
||||
|
||||
// ── Voting ───────────────────────────────────────────────────────────────
|
||||
const submitVoteMutation = trpc.liveVoting.vote.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.liveVoting.getSessionForVotingByRound.invalidate()
|
||||
toast.success('Vote submitted')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleVoteSubmit = (vote: {
|
||||
score: number
|
||||
criterionScores?: Record<string, number>
|
||||
comment?: string
|
||||
}) => {
|
||||
if (!activeProjectId || !sessionData?.session?.id) return
|
||||
submitVoteMutation.mutate({
|
||||
sessionId,
|
||||
projectId,
|
||||
sessionId: sessionData.session.id,
|
||||
projectId: activeProjectId,
|
||||
score: vote.score,
|
||||
criterionScores: vote.criterionScores,
|
||||
comment: vote.comment,
|
||||
})
|
||||
}
|
||||
|
||||
// Extract voting mode and criteria from session
|
||||
const votingMode = (sessionData?.session?.votingMode ?? 'simple') as 'simple' | 'criteria'
|
||||
const criteria = (sessionData?.session?.criteriaJson as Array<{
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
scale: number
|
||||
weight: number
|
||||
}> | undefined)
|
||||
const criteria = sessionData?.session?.criteriaJson as
|
||||
| Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
|
||||
| undefined
|
||||
|
||||
const activeProject = cursor?.activeProject || sessionData?.currentProject
|
||||
const phase = cursor?.projectPhase ?? 'ON_DECK'
|
||||
const categoryLabel =
|
||||
activeProject?.competitionCategory === 'STARTUP'
|
||||
? 'Startup'
|
||||
: activeProject?.competitionCategory === 'BUSINESS_CONCEPT'
|
||||
? 'Business Concept'
|
||||
: null
|
||||
|
||||
if (!activeProject) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">Waiting for ceremony to begin...</p>
|
||||
<Sparkles className="mb-3 h-8 w-8 text-brand-teal/60" />
|
||||
<p className="font-medium">Waiting for the ceremony to begin…</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
The admin will control which project is displayed
|
||||
Projects will appear here automatically as they take the stage
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -78,105 +144,116 @@ export default function JuryLivePage({ params: paramsPromise }: { params: Promis
|
||||
)
|
||||
}
|
||||
|
||||
// ── ON_DECK: "Up next" banner, no scoring yet ───────────────────────────
|
||||
if (phase === 'ON_DECK') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Current Project Display */}
|
||||
<Card className="overflow-hidden border-0 bg-gradient-to-r from-[#053d57] to-[#0a5a7c] text-white">
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-white/70">
|
||||
Up next
|
||||
</p>
|
||||
<h1 className="mt-3 text-3xl font-bold sm:text-4xl">{activeProject.title}</h1>
|
||||
{activeProject.teamName && (
|
||||
<p className="mt-2 text-lg text-white/80">{activeProject.teamName}</p>
|
||||
)}
|
||||
{categoryLabel && (
|
||||
<Badge className="mt-4 bg-white/15 text-white hover:bg-white/15">{categoryLabel}</Badge>
|
||||
)}
|
||||
<p className="mt-6 text-sm text-white/60">Presentation starting shortly</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{activeProject.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Live project presentation
|
||||
</CardDescription>
|
||||
</div>
|
||||
{votingMode === 'criteria' && (
|
||||
<Badge variant="secondary">Criteria Voting</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-base">About this project</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeProject.description && (
|
||||
<p className="text-muted-foreground">{activeProject.description}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">{activeProject.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Prior Jury Data (Collapsible) */}
|
||||
{priorData && (
|
||||
<Collapsible open={priorDataOpen} onOpenChange={setPriorDataOpen}>
|
||||
<Card>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer hover:bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Prior Evaluation Data</CardTitle>
|
||||
{priorDataOpen ? (
|
||||
<ChevronUp className="h-5 w-5" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Average Score</p>
|
||||
<p className="mt-1 text-2xl font-bold">
|
||||
{priorData.averageScore?.toFixed(1) || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
|
||||
<p className="mt-1 text-2xl font-bold">{priorData.evaluationCount || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
{priorData.strengths && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Key Strengths</p>
|
||||
<p className="mt-1 text-sm">{priorData.strengths}</p>
|
||||
</div>
|
||||
)}
|
||||
{priorData.weaknesses && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Areas for Improvement</p>
|
||||
<p className="mt-1 text-sm">{priorData.weaknesses}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
)}
|
||||
const phaseMeta = PHASE_META[phase] ?? PHASE_META.PRESENTING
|
||||
const PhaseIcon = phaseMeta.icon
|
||||
|
||||
{/* Notes Section */}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Current Project + phase */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
{activeProject.teamName}
|
||||
{categoryLabel ? ` · ${categoryLabel}` : ''}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={phase === 'SCORING' ? 'default' : 'outline'} className="gap-1.5">
|
||||
<PhaseIcon className="h-3.5 w-3.5" />
|
||||
{phaseMeta.label}
|
||||
</Badge>
|
||||
{cursor && <PhaseCountdown phase={cursor} />}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{activeProject.description && (
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{activeProject.description}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Notes — persisted, autosaved */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Your Notes</CardTitle>
|
||||
<CardDescription>Optional notes for this project</CardDescription>
|
||||
<CardDescription>Private — resurfaced during deliberation</CardDescription>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{noteStatus === 'saving' ? 'Saving…' : noteStatus === 'saved' ? 'Saved' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add your observations and comments..."
|
||||
value={currentDraft}
|
||||
onChange={(e) => handleNoteChange(e.target.value)}
|
||||
placeholder="Observations during the presentation and Q&A…"
|
||||
rows={4}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Voting Form */}
|
||||
{/* Scoring — available from presentation start, spotlighted at SCORING.
|
||||
Keyed on vote presence: the form initializes its editing state from
|
||||
existingVote, which arrives async after mount. */}
|
||||
<LiveVotingForm
|
||||
key={`${activeProject.id}-${sessionData?.userVote?.votedAt ?? 'fresh'}`}
|
||||
projectId={activeProject.id}
|
||||
votingMode={votingMode}
|
||||
criteria={criteria}
|
||||
existingVote={sessionData?.userVote ? {
|
||||
existingVote={
|
||||
sessionData?.userVote
|
||||
? {
|
||||
score: sessionData.userVote.score,
|
||||
criterionScoresJson: sessionData.userVote.criterionScoresJson as Record<string, number> | undefined
|
||||
} : null}
|
||||
criterionScoresJson: sessionData.userVote.criterionScoresJson as
|
||||
| Record<string, number>
|
||||
| undefined,
|
||||
comment: sessionData.userVote.comment,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onVoteSubmit={handleVoteSubmit}
|
||||
disabled={submitVoteMutation.isPending}
|
||||
highlighted={phase === 'SCORING'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function JuryRoundDetailPage() {
|
||||
Back
|
||||
</Button>
|
||||
<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'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
|
||||
@@ -460,7 +460,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</div>
|
||||
<Card className="border-l-4 border-l-amber-500">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -495,7 +495,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</Link>
|
||||
</Button>
|
||||
<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
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">{project.title}</p>
|
||||
@@ -526,7 +526,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</Link>
|
||||
</Button>
|
||||
<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
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">{project.title}</p>
|
||||
@@ -534,7 +534,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</div>
|
||||
<Card className="border-l-4 border-l-amber-500">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -573,7 +573,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</Button>
|
||||
)}
|
||||
<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'}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
@@ -583,8 +583,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
variant="secondary"
|
||||
className={
|
||||
project.competitionCategory === 'STARTUP'
|
||||
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
||||
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
|
||||
? 'bg-violet-100 text-violet-700 border-violet-200'
|
||||
: 'bg-sky-100 text-sky-700 border-sky-200'
|
||||
}
|
||||
>
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
@@ -595,7 +595,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -98,8 +98,8 @@ export default function JuryProjectDetailPage() {
|
||||
variant="secondary"
|
||||
className={
|
||||
project.competitionCategory === 'STARTUP'
|
||||
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
||||
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
|
||||
? 'bg-violet-100 text-violet-700 border-violet-200'
|
||||
: 'bg-sky-100 text-sky-700 border-sky-200'
|
||||
}
|
||||
>
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
|
||||
@@ -1,150 +1,326 @@
|
||||
'use client';
|
||||
'use client'
|
||||
|
||||
import { use } from 'react';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DeliberationRankingForm } from '@/components/jury/deliberation-ranking-form';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { DeliberationRankingForm } from '@/components/jury/deliberation-ranking-form'
|
||||
import { LiveVotingForm } from '@/components/jury/live-voting-form'
|
||||
import { CheckCircle2, ChevronDown, FileText, PenLine, StickyNote } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function JuryDeliberationPage({ params: paramsPromise }: { params: Promise<{ sessionId: string }> }) {
|
||||
const params = use(paramsPromise);
|
||||
const utils = trpc.useUtils();
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
BUSINESS_CONCEPT: 'Business Concepts',
|
||||
STARTUP: 'Startups',
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-project review context during deliberation: the juror's finale scores
|
||||
* (revisable in place — "keep" is simply not touching them), their ceremony
|
||||
* notes, and a pointer to the project documents.
|
||||
*/
|
||||
function ProjectReviewCard({
|
||||
project,
|
||||
roundId,
|
||||
finaleInputs,
|
||||
votingMode,
|
||||
criteria,
|
||||
}: {
|
||||
project: { id: string; title: string; teamName?: string | null }
|
||||
roundId: string
|
||||
finaleInputs: any
|
||||
votingMode: 'simple' | 'criteria'
|
||||
criteria?: Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
|
||||
}) {
|
||||
const utils = trpc.useUtils()
|
||||
const [open, setOpen] = useState(false)
|
||||
const myVote = finaleInputs?.votes?.find((v: any) => v.projectId === project.id)
|
||||
const myNote = finaleInputs?.notes?.find((n: any) => n.projectId === project.id)
|
||||
|
||||
const voteMutation = trpc.liveVoting.vote.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.liveVoting.getMyFinaleInputs.invalidate({ roundId })
|
||||
toast.success('Score updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<Card>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer py-4 hover:bg-muted/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">{project.title}</CardTitle>
|
||||
{project.teamName && (
|
||||
<CardDescription className="mt-0.5">{project.teamName}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{myVote ? (
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
My score: {myVote.score}/10
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Not scored</Badge>
|
||||
)}
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-4 border-t pt-4">
|
||||
{myNote?.content && (
|
||||
<div className="rounded-lg bg-muted/40 p-3">
|
||||
<p className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
|
||||
<StickyNote className="h-3.5 w-3.5" />
|
||||
Your ceremony notes
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap text-sm">{myNote.content}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
|
||||
<PenLine className="h-3.5 w-3.5" />
|
||||
Your grand-finale score — edit to revise, or leave as-is to keep it
|
||||
</p>
|
||||
{finaleInputs?.session?.id ? (
|
||||
<LiveVotingForm
|
||||
key={`${project.id}-${myVote?.votedAt ?? 'fresh'}`}
|
||||
projectId={project.id}
|
||||
votingMode={votingMode}
|
||||
criteria={criteria}
|
||||
existingVote={
|
||||
myVote
|
||||
? {
|
||||
score: myVote.score,
|
||||
criterionScoresJson: myVote.criterionScoresJson as
|
||||
| Record<string, number>
|
||||
| undefined,
|
||||
comment: myVote.comment,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onVoteSubmit={(vote) =>
|
||||
voteMutation.mutate({
|
||||
sessionId: finaleInputs.session.id,
|
||||
projectId: project.id,
|
||||
score: vote.score,
|
||||
criterionScores: vote.criterionScores,
|
||||
comment: vote.comment,
|
||||
})
|
||||
}
|
||||
disabled={voteMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No finale voting session found.</p>
|
||||
)}
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/jury/finals-documents">
|
||||
<FileText className="mr-2 h-3.5 w-3.5" />
|
||||
Open project documents
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
export default function JuryDeliberationPage({
|
||||
params: paramsPromise,
|
||||
}: {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}) {
|
||||
const params = use(paramsPromise)
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: me } = trpc.user.me.useQuery()
|
||||
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
|
||||
{ sessionId: params.sessionId },
|
||||
{ refetchInterval: 10_000 },
|
||||
);
|
||||
{ refetchInterval: 10_000 }
|
||||
)
|
||||
// The deliberation session points at its round; finale inputs live on the
|
||||
// LIVE_FINAL round's voting session — resolve via my ceremony context.
|
||||
const { data: ceremony } = trpc.live.getMyCeremonyContext.useQuery()
|
||||
const finaleRoundId = ceremony?.liveRoundId ?? null
|
||||
const { data: finaleInputs } = trpc.liveVoting.getMyFinaleInputs.useQuery(
|
||||
{ roundId: finaleRoundId ?? '' },
|
||||
{ enabled: !!finaleRoundId }
|
||||
)
|
||||
|
||||
const submitVoteMutation = trpc.deliberation.submitVote.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.deliberation.getSession.invalidate();
|
||||
toast.success('Vote submitted successfully');
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
}
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const submitVoteMutation = trpc.deliberation.submitVote.useMutation()
|
||||
|
||||
const handleSubmitVote = (votes: Array<{ projectId: string; rank?: number; isWinnerPick?: boolean }>) => {
|
||||
votes.forEach((vote) => {
|
||||
submitVoteMutation.mutate({
|
||||
const handleSubmitVote = async (
|
||||
votes: Array<{ projectId: string; rank?: number; isWinnerPick?: boolean }>
|
||||
) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
for (const vote of votes) {
|
||||
await submitVoteMutation.mutateAsync({
|
||||
sessionId: params.sessionId,
|
||||
juryMemberId: '', // TODO: resolve current user's jury member ID from session participants
|
||||
projectId: vote.projectId,
|
||||
rank: vote.rank,
|
||||
isWinnerPick: vote.isWinnerPick
|
||||
});
|
||||
});
|
||||
};
|
||||
isWinnerPick: vote.isWinnerPick,
|
||||
})
|
||||
}
|
||||
toast.success('Your ranking has been submitted')
|
||||
utils.deliberation.getSession.invalidate({ sessionId: params.sessionId })
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to submit vote')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading || !me) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">Loading session...</p>
|
||||
<p className="text-muted-foreground">Loading session…</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">Session not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const hasVoted = false; // TODO: check if current user has voted in this session
|
||||
const isParticipant = (session.participants ?? []).some(
|
||||
(p: any) => p.user?.user?.id === me.id
|
||||
)
|
||||
const hasVoted = (session.votes ?? []).some(
|
||||
(v: any) => v.juryMember?.user?.id === me.id && v.runoffRound === 0
|
||||
)
|
||||
const projects = ((session as any).projects ?? []) as Array<{
|
||||
id: string
|
||||
title: string
|
||||
teamName?: string | null
|
||||
}>
|
||||
const votingMode = (finaleInputs?.session?.votingMode ?? 'simple') as 'simple' | 'criteria'
|
||||
const criteria = finaleInputs?.session?.criteriaJson as
|
||||
| Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
|
||||
| undefined
|
||||
|
||||
if (session.status !== 'VOTING') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
const header = (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Deliberation Session</CardTitle>
|
||||
<CardDescription>
|
||||
{session.round?.name} - {session.category}
|
||||
</CardDescription>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>Deliberation — {CATEGORY_LABEL[session.category] ?? session.category}</CardTitle>
|
||||
<CardDescription className="mt-1">{session.round?.name}</CardDescription>
|
||||
</div>
|
||||
<Badge>{session.status}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
</Card>
|
||||
)
|
||||
|
||||
const reviewSection = projects.length > 0 && finaleRoundId && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Review Before You Rank</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your grand-finale scores, notes and the project documents — revise a score or keep it.
|
||||
</p>
|
||||
</div>
|
||||
{projects.map((p) => (
|
||||
<ProjectReviewCard
|
||||
key={p.id}
|
||||
project={p}
|
||||
roundId={finaleRoundId}
|
||||
finaleInputs={finaleInputs}
|
||||
votingMode={votingMode}
|
||||
criteria={criteria}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (session.status !== 'VOTING' && session.status !== 'RUNOFF') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{header}
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-10">
|
||||
<p className="text-muted-foreground">
|
||||
{session.status === 'DELIB_OPEN'
|
||||
? 'Voting has not started yet. Please wait for the admin to open voting.'
|
||||
? 'Voting has not started yet — you can already review the projects below.'
|
||||
: session.status === 'TALLYING'
|
||||
? 'Voting is closed. Results are being tallied.'
|
||||
: 'This session is locked.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{session.status === 'DELIB_OPEN' && reviewSection}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (hasVoted) {
|
||||
if (!isParticipant) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{header}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>Deliberation Session</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
{session.round?.name} - {session.category}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge>{session.status}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<CheckCircle2 className="mb-4 h-12 w-12 text-green-600" />
|
||||
<p className="font-medium">Vote Submitted</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Thank you for your participation in this deliberation
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>Deliberation Session</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
{session.round?.name} - {session.category}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge>{session.status}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex flex-col items-center justify-center py-10">
|
||||
<p className="text-muted-foreground">
|
||||
{session.mode === 'SINGLE_WINNER_VOTE'
|
||||
? 'Select your top choice for this category.'
|
||||
: 'Rank all projects from best to least preferred.'}
|
||||
You are not a participant of this deliberation session.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{header}
|
||||
|
||||
{hasVoted ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-10">
|
||||
<CheckCircle2 className="mb-3 h-12 w-12 text-green-600" />
|
||||
<p className="font-medium">Ranking Submitted</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Thank you — the chair will review the collective result.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{reviewSection}
|
||||
<div>
|
||||
<h2 className="mb-2 text-lg font-semibold">
|
||||
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Pick Your Winner' : 'Your Ranking'}
|
||||
</h2>
|
||||
<DeliberationRankingForm
|
||||
projects={session.results?.map((r) => r.project) ?? []}
|
||||
projects={projects}
|
||||
mode={session.mode}
|
||||
onSubmit={handleSubmitVote}
|
||||
disabled={submitVoteMutation.isPending}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function JuryAssignmentsPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
|
||||
7
src/app/(jury)/jury/finals-documents/page.tsx
Normal file
7
src/app/(jury)/jury/finals-documents/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { FinalsDocumentsReview } from '@/components/finals/finals-documents-review'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function FinalsDocumentsPage() {
|
||||
return <FinalsDocumentsReview />
|
||||
}
|
||||
@@ -28,7 +28,9 @@ import {
|
||||
Waves,
|
||||
Send,
|
||||
Trophy,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
import { userCanReviewFinals, getOpenFinaleRound } from '@/server/services/final-documents'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
@@ -42,6 +44,70 @@ function getGreeting(): string {
|
||||
return 'Good evening'
|
||||
}
|
||||
|
||||
/**
|
||||
* Prominent entry point to the finalist documents review, shown only to
|
||||
* Grand-Final jury members (and admins). Rendered at the top of the dashboard
|
||||
* regardless of whether the juror has individual assignments, so finals jurors
|
||||
* can always find the teams' files in one obvious place.
|
||||
*/
|
||||
async function FinalsJuryBanner() {
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id
|
||||
if (!userId) return null
|
||||
|
||||
const program = await prisma.program.findFirst({
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: { year: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!program) return null
|
||||
|
||||
const canReview = await userCanReviewFinals(prisma, userId, session.user.role, program.id)
|
||||
if (!canReview) return null
|
||||
|
||||
const round = await getOpenFinaleRound(prisma, program.id)
|
||||
const teamCount = round
|
||||
? await prisma.projectRoundState.count({ where: { roundId: round.id } })
|
||||
: 0
|
||||
|
||||
return (
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="overflow-hidden border-0 shadow-lg">
|
||||
<div className="rounded-lg bg-gradient-to-r from-brand-blue to-brand-teal p-[1px]">
|
||||
<CardContent className="flex flex-col gap-4 rounded-[7px] bg-background p-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="shrink-0 rounded-xl bg-gradient-to-br from-brand-blue to-brand-teal p-3 shadow-sm">
|
||||
<Trophy className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-brand-teal">
|
||||
Grand Final
|
||||
</p>
|
||||
<h2 className="text-lg font-bold text-brand-blue">Finalist Documents</h2>
|
||||
<p className="mt-0.5 max-w-md text-sm text-muted-foreground">
|
||||
{teamCount > 0 ? `All ${teamCount} finalist teams’ ` : 'Every finalist team’s '}
|
||||
pitch decks, business plans, executive summaries and videos — in one place.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="w-full shrink-0 bg-brand-blue shadow-md hover:bg-brand-blue-light sm:w-auto"
|
||||
>
|
||||
<Link href="/jury/finals-documents">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Review Finalist Documents
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
async function JuryDashboardContent() {
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id
|
||||
@@ -262,7 +328,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" />
|
||||
<CardContent className="py-8 px-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" />
|
||||
</div>
|
||||
<p className="text-lg font-semibold">No assignments yet</p>
|
||||
@@ -273,13 +339,13 @@ async function JuryDashboardContent() {
|
||||
<div className={`grid gap-3 max-w-md mx-auto ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
|
||||
<Link
|
||||
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">
|
||||
<ClipboardList className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<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" />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -288,7 +354,7 @@ async function JuryDashboardContent() {
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
@@ -314,8 +380,8 @@ async function JuryDashboardContent() {
|
||||
<div className="rounded-[7px] bg-background">
|
||||
<CardHeader className="pb-2 pt-4 px-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/40">
|
||||
<Trophy className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
<div className="rounded-lg bg-amber-100 p-1.5">
|
||||
<Trophy className="h-4 w-4 text-amber-600" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Special Awards — Voting Open</CardTitle>
|
||||
</div>
|
||||
@@ -333,27 +399,27 @@ async function JuryDashboardContent() {
|
||||
className={cn(
|
||||
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
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
|
||||
? 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20'
|
||||
: 'border-amber-200/60 bg-amber-50/30 dark:border-amber-800/40 dark:bg-amber-950/10'
|
||||
? 'border-red-200 bg-red-50/50'
|
||||
: 'border-amber-200/60 bg-amber-50/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<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">
|
||||
{award._count.eligibilities} project{award._count.eligibilities !== 1 ? 's' : ''} to review
|
||||
{record.isChair && ' · You are the Chair'}
|
||||
</p>
|
||||
</div>
|
||||
{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" />
|
||||
Submitted
|
||||
</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
|
||||
</Badge>
|
||||
)}
|
||||
@@ -452,8 +518,8 @@ async function JuryDashboardContent() {
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-brand-blue/10 p-1.5 dark:bg-brand-blue/20">
|
||||
<ClipboardList className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
|
||||
<div className="rounded-lg bg-brand-blue/10 p-1.5">
|
||||
<ClipboardList className="h-4 w-4 text-brand-blue" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">My Assignments</CardTitle>
|
||||
</div>
|
||||
@@ -487,14 +553,14 @@ async function JuryDashboardContent() {
|
||||
href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}`}
|
||||
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}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{assignment.project.teamName}
|
||||
</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}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -506,7 +572,7 @@ async function JuryDashboardContent() {
|
||||
Done
|
||||
</Badge>
|
||||
) : 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" />
|
||||
Ready to submit
|
||||
</Badge>
|
||||
@@ -571,7 +637,7 @@ async function JuryDashboardContent() {
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<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" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
||||
@@ -581,13 +647,13 @@ async function JuryDashboardContent() {
|
||||
<div className={`grid gap-3 ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
|
||||
<Link
|
||||
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">
|
||||
<ClipboardList className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
<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" />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -596,7 +662,7 @@ async function JuryDashboardContent() {
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
@@ -620,8 +686,8 @@ async function JuryDashboardContent() {
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-brand-blue/10 p-1.5 dark:bg-brand-blue/20">
|
||||
<Waves className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
|
||||
<div className="rounded-lg bg-brand-blue/10 p-1.5">
|
||||
<Waves className="h-4 w-4 text-brand-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">Active Voting Stages</CardTitle>
|
||||
@@ -650,13 +716,13 @@ async function JuryDashboardContent() {
|
||||
className={cn(
|
||||
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
isUrgent
|
||||
? 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20'
|
||||
: 'border-border/60 bg-muted/20 dark:bg-muted/10'
|
||||
? 'border-red-200 bg-red-50/50'
|
||||
: 'border-border/60 bg-muted/20'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<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">
|
||||
{program.name} · {program.year}
|
||||
</p>
|
||||
@@ -716,7 +782,7 @@ async function JuryDashboardContent() {
|
||||
<AnimatedCard index={8}>
|
||||
<Card>
|
||||
<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" />
|
||||
</div>
|
||||
<p className="font-semibold text-sm">No active voting stages</p>
|
||||
@@ -734,7 +800,7 @@ async function JuryDashboardContent() {
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<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" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Round Summary</CardTitle>
|
||||
@@ -750,7 +816,7 @@ async function JuryDashboardContent() {
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium truncate">{round.name}</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -852,7 +918,7 @@ export default async function JuryDashboardPage() {
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<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'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
@@ -863,6 +929,11 @@ export default async function JuryDashboardPage() {
|
||||
{/* Preferences banner (shown when juror has unconfirmed preferences) */}
|
||||
<JuryPreferencesBanner />
|
||||
|
||||
{/* Grand-Final finalist documents — prominent entry for finals jurors */}
|
||||
<Suspense fallback={null}>
|
||||
<FinalsJuryBanner />
|
||||
</Suspense>
|
||||
|
||||
{/* Content */}
|
||||
<Suspense fallback={<DashboardSkeleton />}>
|
||||
<JuryDashboardContent />
|
||||
|
||||
@@ -94,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
|
||||
const trackView = trpc.mentor.trackView.useMutation()
|
||||
useEffect(() => {
|
||||
if (project?.mentorAssignment?.id) {
|
||||
trackView.mutate({ mentorAssignmentId: project.mentorAssignment.id })
|
||||
if (primaryAssignment?.id) {
|
||||
trackView.mutate({ mentorAssignmentId: primaryAssignment.id })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [project?.mentorAssignment?.id])
|
||||
}, [primaryAssignment?.id])
|
||||
|
||||
if (isLoading) {
|
||||
return <ProjectDetailSkeleton />
|
||||
@@ -135,7 +139,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
|
||||
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
|
||||
const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || []
|
||||
const mentorAssignment = project.mentorAssignment
|
||||
const mentorAssignment = primaryAssignment
|
||||
const mentorAssignmentId = mentorAssignment?.id
|
||||
const programId = project.program?.id
|
||||
const viewerIsAssignedMentor =
|
||||
@@ -340,6 +344,25 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{(() => {
|
||||
const emails = (project.teamMembers ?? [])
|
||||
.map((m) => m.user.email)
|
||||
.filter((e): e is string => !!e)
|
||||
if (emails.length === 0) return null
|
||||
const mailto = `mailto:${emails.join(',')}?subject=${encodeURIComponent(
|
||||
`MOPC Mentorship — ${project.title}`,
|
||||
)}`
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={mailto}>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Email all team members
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{/* Team Lead */}
|
||||
{teamLead && (
|
||||
<div className="p-4 rounded-lg border bg-muted/30">
|
||||
@@ -477,7 +500,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<CardContent>
|
||||
<MentorChat
|
||||
messages={mentorMessages || []}
|
||||
currentUserId={project.mentorAssignment?.mentor?.id || ''}
|
||||
currentUserId={primaryAssignment?.mentor?.id || ''}
|
||||
onSendMessage={async (message) => {
|
||||
await sendMessage.mutateAsync({ projectId, message })
|
||||
}}
|
||||
@@ -592,7 +615,7 @@ function MilestonesSection({
|
||||
<div
|
||||
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 ${
|
||||
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
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
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 { 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 { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
|
||||
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
||||
import { ArrowLeft, MessageSquare, FileText, Upload } from 'lucide-react'
|
||||
import { FinalDocumentsPanel } from '@/components/applicant/final-documents-panel'
|
||||
import { ArrowLeft, MessageSquare, FileText, Upload, Users } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function MentorWorkspaceDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { data: session } = useSession()
|
||||
const projectId = params.projectId as string
|
||||
|
||||
// Get mentor assignment for this project
|
||||
@@ -27,6 +36,22 @@ export default function MentorWorkspaceDetailPage() {
|
||||
{ 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) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -70,6 +95,37 @@ export default function MentorWorkspaceDetailPage() {
|
||||
{project.teamName && (
|
||||
<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>
|
||||
|
||||
@@ -104,7 +160,10 @@ export default function MentorWorkspaceDetailPage() {
|
||||
|
||||
<TabsContent value="files" className="mt-6">
|
||||
{assignment ? (
|
||||
<WorkspaceFilesPanel mentorAssignmentId={assignment.id} />
|
||||
<WorkspaceFilesPanel
|
||||
projectId={projectId}
|
||||
mentorAssignmentId={assignment.id}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
@@ -117,7 +176,7 @@ export default function MentorWorkspaceDetailPage() {
|
||||
|
||||
<TabsContent value="promotion" className="mt-6">
|
||||
{assignment ? (
|
||||
<FilePromotionPanel mentorAssignmentId={assignment.id} />
|
||||
<FilePromotionPanel projectId={projectId} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
@@ -128,6 +187,9 @@ export default function MentorWorkspaceDetailPage() {
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Final Documents (self-hides when not a finalist) */}
|
||||
<FinalDocumentsPanel variant="mentor" projectId={projectId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -180,8 +181,11 @@ function FinalistConfirmContent({ token }: { token: string }) {
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
We'll be in touch shortly with travel and lunch logistics. You can edit your team
|
||||
selection from your project page closer to the event.
|
||||
selection and view hotel, flight, and visa details from your dashboard.
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/applicant">Go to my dashboard</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -280,7 +284,7 @@ function FinalistConfirmContent({ token }: { token: string }) {
|
||||
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 dark:border-amber-700">
|
||||
<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>
|
||||
|
||||
535
src/app/(public)/live/ceremony/[roundId]/page.tsx
Normal file
535
src/app/(public)/live/ceremony/[roundId]/page.tsx
Normal file
@@ -0,0 +1,535 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Big-screen ceremony view — projected on stage at the grand finale.
|
||||
* Award-night broadcast aesthetic: deep layered ocean field, extreme
|
||||
* Montserrat scale contrast, red as a scalpel accent, gold reserved for the
|
||||
* winner moment. Pure derivation of server state (poll 2s), full-bleed over
|
||||
* the public layout, no interactive chrome.
|
||||
*/
|
||||
|
||||
import { use, useEffect, useMemo, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { remainingSeconds, formatClock } from '@/lib/live-timer'
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
BUSINESS_CONCEPT: 'Business Concepts',
|
||||
STARTUP: 'Startups',
|
||||
}
|
||||
const WINDOW_TITLE: Record<string, string> = {
|
||||
'CATEGORY:BUSINESS_CONCEPT': 'Vote for your favorite Business Concept',
|
||||
'CATEGORY:STARTUP': 'Vote for your favorite Startup',
|
||||
OVERALL: 'Vote for your favorite project of the night',
|
||||
}
|
||||
|
||||
function useTick() {
|
||||
const [, tick] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => tick((t) => t + 1), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
}
|
||||
|
||||
// ─── Atmosphere ──────────────────────────────────────────────────────────────
|
||||
|
||||
function OceanField({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-hidden bg-[#021f2e] font-[Montserrat,sans-serif] text-white">
|
||||
{/* Layered ocean-light gradients */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(120% 90% at 50% 110%, #0a5a7c 0%, #053d57 45%, #021f2e 100%)',
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute -inset-x-1/4 top-[-40%] h-[80%] opacity-25"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(50% 100% at 50% 0%, rgba(85,127,140,0.9) 0%, transparent 70%)',
|
||||
}}
|
||||
animate={{ x: ['-8%', '8%', '-8%'] }}
|
||||
transition={{ repeat: Infinity, duration: 18, ease: 'easeInOut' }}
|
||||
/>
|
||||
{/* Grain for projector richness */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.05] mix-blend-overlay"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")",
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBar({ programName, label }: { programName: string | null; label?: string | null }) {
|
||||
return (
|
||||
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-12 py-8">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.4em] text-white/50">
|
||||
{programName ?? 'Monaco Ocean Protection Challenge'}
|
||||
</p>
|
||||
{label && (
|
||||
<p className="flex items-center gap-3 text-sm font-semibold uppercase tracking-[0.4em] text-white/50">
|
||||
<span className="inline-block h-2.5 w-2.5 animate-pulse rounded-full bg-[#de0f1e]" />
|
||||
{label}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SignatureRule() {
|
||||
return (
|
||||
<div className="mx-auto flex w-48 items-center gap-0">
|
||||
<div className="h-px flex-1 bg-white/25" />
|
||||
<div className="h-[3px] w-10 bg-[#de0f1e]" />
|
||||
<div className="h-px flex-1 bg-white/25" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const slideIn = {
|
||||
initial: { opacity: 0, y: 36, scale: 0.985, filter: 'blur(6px)' },
|
||||
animate: { opacity: 1, y: 0, scale: 1, filter: 'blur(0px)' },
|
||||
exit: { opacity: 0, y: -24, scale: 0.99, filter: 'blur(4px)' },
|
||||
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] as const },
|
||||
}
|
||||
|
||||
// ─── Slides ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function StaticSlide({ kind, programName }: { kind: string; programName: string | null }) {
|
||||
const copy: Record<string, { eyebrow: string; title: string; sub?: string }> = {
|
||||
welcome: {
|
||||
eyebrow: programName ?? 'Monaco Ocean Protection Challenge',
|
||||
title: 'Grand Finale',
|
||||
sub: 'Welcome',
|
||||
},
|
||||
break: { eyebrow: 'Intermission', title: 'Back shortly', sub: 'Enjoy the break' },
|
||||
deliberation: {
|
||||
eyebrow: 'The jury has retired',
|
||||
title: 'Deliberation in progress',
|
||||
sub: 'Results follow shortly',
|
||||
},
|
||||
thanks: { eyebrow: programName ?? 'Grand Finale', title: 'Thank you', sub: 'See you next year' },
|
||||
}
|
||||
const c = copy[kind] ?? copy.welcome
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
|
||||
<p className="text-lg font-semibold uppercase tracking-[0.5em] text-white/50">{c.eyebrow}</p>
|
||||
<h1 className="text-[clamp(4rem,10vw,9rem)] font-extrabold leading-none tracking-tight">
|
||||
{c.title}
|
||||
</h1>
|
||||
<SignatureRule />
|
||||
{c.sub && <p className="text-2xl font-light text-white/70">{c.sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PhaseSlide({
|
||||
state,
|
||||
}: {
|
||||
state: {
|
||||
phase: {
|
||||
projectPhase: string
|
||||
phaseStartedAt: string | Date | null
|
||||
phaseDurationSeconds: number | null
|
||||
phasePausedAt: string | Date | null
|
||||
phasePausedAccumMs: number
|
||||
} | null
|
||||
activeProject: { title: string; teamName: string | null; competitionCategory: string | null } | null
|
||||
}
|
||||
}) {
|
||||
useTick()
|
||||
const phase = state.phase
|
||||
const project = state.activeProject
|
||||
if (!phase || !project) return <StaticSlide kind="welcome" programName={null} />
|
||||
|
||||
const remaining = remainingSeconds(phase)
|
||||
const over = remaining !== null && remaining < 0
|
||||
const category = project.competitionCategory
|
||||
? CATEGORY_LABEL[project.competitionCategory]
|
||||
: null
|
||||
|
||||
if (phase.projectPhase === 'ON_DECK') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-10 px-16 text-center">
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-xl font-semibold uppercase tracking-[0.5em] text-[#557f8c]"
|
||||
>
|
||||
Up next
|
||||
</motion.p>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 80, damping: 16, delay: 0.15 }}
|
||||
className="max-w-[90vw] text-[clamp(3.5rem,9vw,8rem)] font-extrabold leading-[1.02] tracking-tight"
|
||||
>
|
||||
{project.teamName ?? project.title}
|
||||
</motion.h1>
|
||||
<SignatureRule />
|
||||
<div className="space-y-2">
|
||||
{project.teamName && <p className="text-3xl font-light text-white/80">{project.title}</p>}
|
||||
{category && (
|
||||
<p className="text-lg font-semibold uppercase tracking-[0.35em] text-white/45">
|
||||
{category}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase.projectPhase === 'SCORING') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
|
||||
<p className="text-lg font-semibold uppercase tracking-[0.5em] text-white/50">
|
||||
{project.teamName ?? project.title}
|
||||
</p>
|
||||
<h1 className="text-[clamp(3rem,7vw,6rem)] font-extrabold tracking-tight">
|
||||
The jury is scoring
|
||||
</h1>
|
||||
<SignatureRule />
|
||||
<motion.div
|
||||
className="flex gap-3"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{ visible: { transition: { staggerChildren: 0.25 } } }}
|
||||
>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="h-3 w-3 rounded-full bg-[#557f8c]"
|
||||
animate={{ opacity: [0.25, 1, 0.25] }}
|
||||
transition={{ repeat: Infinity, duration: 1.6, delay: i * 0.3 }}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// PRESENTING / QA
|
||||
const phaseLabel = phase.projectPhase === 'QA' ? 'Q&A' : 'Presentation'
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
|
||||
<div className="space-y-3">
|
||||
{category && (
|
||||
<p className="text-base font-semibold uppercase tracking-[0.4em] text-white/45">
|
||||
{category}
|
||||
</p>
|
||||
)}
|
||||
<h1 className="max-w-[92vw] text-[clamp(3rem,8vw,7rem)] font-extrabold leading-[1.03] tracking-tight">
|
||||
{project.teamName ?? project.title}
|
||||
</h1>
|
||||
{project.teamName && (
|
||||
<p className="text-2xl font-light text-white/70">{project.title}</p>
|
||||
)}
|
||||
</div>
|
||||
<SignatureRule />
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-semibold uppercase tracking-[0.45em] text-[#557f8c]">
|
||||
{phaseLabel}
|
||||
</p>
|
||||
{remaining !== null && (
|
||||
<motion.p
|
||||
className={`text-[clamp(4rem,9vw,8rem)] font-bold tabular-nums leading-none ${
|
||||
over ? 'text-[#de0f1e]' : 'text-white'
|
||||
}`}
|
||||
animate={over ? { opacity: [1, 0.55, 1] } : {}}
|
||||
transition={over ? { repeat: Infinity, duration: 1.2 } : {}}
|
||||
style={over ? { textShadow: '0 0 60px rgba(222,15,30,0.55)' } : {}}
|
||||
>
|
||||
{formatClock(remaining)}
|
||||
</motion.p>
|
||||
)}
|
||||
{phase.phasePausedAt && (
|
||||
<p className="text-base font-semibold uppercase tracking-[0.35em] text-white/45">
|
||||
paused
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AudienceVoteSlide({
|
||||
windowKey,
|
||||
closesAt,
|
||||
voteCount,
|
||||
voteUrl,
|
||||
}: {
|
||||
windowKey: string | null
|
||||
closesAt: string | Date | null
|
||||
voteCount: number
|
||||
voteUrl: string
|
||||
}) {
|
||||
useTick()
|
||||
const secondsLeft = closesAt
|
||||
? Math.max(0, Math.floor((new Date(closesAt).getTime() - Date.now()) / 1000))
|
||||
: null
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center gap-24 px-20">
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.015, 1] }}
|
||||
transition={{ repeat: Infinity, duration: 4, ease: 'easeInOut' }}
|
||||
className="shrink-0 rounded-[2.5rem] bg-white p-10 shadow-[0_0_120px_rgba(85,127,140,0.45)]"
|
||||
>
|
||||
{voteUrl && <QRCodeSVG value={voteUrl} size={400} />}
|
||||
</motion.div>
|
||||
<div className="max-w-3xl space-y-8">
|
||||
<p className="text-lg font-semibold uppercase tracking-[0.45em] text-[#de0f1e]">
|
||||
Audience vote — open now
|
||||
</p>
|
||||
<h1 className="text-[clamp(2.5rem,5.5vw,4.5rem)] font-extrabold leading-tight tracking-tight">
|
||||
{WINDOW_TITLE[windowKey ?? ''] ?? 'Vote for your favorite'}
|
||||
</h1>
|
||||
<p className="text-2xl font-light text-white/70">
|
||||
Scan the code with your phone — one vote each
|
||||
</p>
|
||||
<div className="flex items-end gap-14 pt-2">
|
||||
{secondsLeft !== null && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-white/45">
|
||||
Closes in
|
||||
</p>
|
||||
<p
|
||||
className={`text-7xl font-bold tabular-nums ${
|
||||
secondsLeft <= 30 ? 'text-[#de0f1e]' : ''
|
||||
}`}
|
||||
>
|
||||
{formatClock(secondsLeft)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-white/45">
|
||||
Votes cast
|
||||
</p>
|
||||
<motion.p
|
||||
key={voteCount}
|
||||
initial={{ scale: 1.25, color: '#de0f1e' }}
|
||||
animate={{ scale: 1, color: '#ffffff' }}
|
||||
className="text-7xl font-bold tabular-nums"
|
||||
>
|
||||
{voteCount}
|
||||
</motion.p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Reveal ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function Confetti({ gold }: { gold?: boolean }) {
|
||||
const pieces = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 56 }, (_, i) => ({
|
||||
left: ((i * 137.5) % 100),
|
||||
delay: (i % 14) * 0.09,
|
||||
duration: 2.6 + ((i * 7) % 10) / 6,
|
||||
size: 7 + ((i * 13) % 9),
|
||||
rotate: (i * 73) % 360,
|
||||
color: gold
|
||||
? ['#e8c34a', '#de0f1e', '#ffffff', '#f0d98c'][i % 4]
|
||||
: ['#de0f1e', '#557f8c', '#ffffff', '#9fc3cf'][i % 4],
|
||||
})),
|
||||
[gold]
|
||||
)
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{pieces.map((p, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="absolute top-[-5%] block"
|
||||
style={{
|
||||
left: `${p.left}%`,
|
||||
width: p.size,
|
||||
height: p.size * 0.45,
|
||||
backgroundColor: p.color,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
initial={{ y: '-10vh', rotate: p.rotate, opacity: 0 }}
|
||||
animate={{ y: '115vh', rotate: p.rotate + 540, opacity: [0, 1, 1, 0.8] }}
|
||||
transition={{ duration: p.duration, delay: p.delay, ease: 'easeIn' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type RevealStep = {
|
||||
kind: string
|
||||
category?: string
|
||||
place?: number
|
||||
title?: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
function RevealSlide({ step }: { step: RevealStep }) {
|
||||
const isWinner = step.kind === 'place' && step.place === 1
|
||||
const isAudience = step.kind === 'audience-award' || step.kind === 'overall-favorite'
|
||||
|
||||
if (step.kind === 'category-intro') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
|
||||
<p className="text-lg font-semibold uppercase tracking-[0.5em] text-white/50">Results</p>
|
||||
<h1 className="text-[clamp(4rem,9vw,8rem)] font-extrabold tracking-tight">
|
||||
{step.title ?? CATEGORY_LABEL[step.category ?? ''] ?? ''}
|
||||
</h1>
|
||||
<SignatureRule />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (step.kind === 'thanks') {
|
||||
return <StaticSlide kind="thanks" programName={null} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col items-center justify-center gap-10 px-16 text-center">
|
||||
{(isWinner || isAudience) && <Confetti gold={isWinner} />}
|
||||
{isWinner && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(55% 45% at 50% 52%, rgba(232,195,74,0.16) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className={`text-xl font-semibold uppercase tracking-[0.5em] ${
|
||||
isAudience ? 'text-[#de0f1e]' : isWinner ? 'text-[#e8c34a]' : 'text-[#557f8c]'
|
||||
}`}
|
||||
>
|
||||
{step.subtitle ?? ''}
|
||||
</motion.p>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 60, scale: 0.92 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 70, damping: 14, delay: 0.35 }}
|
||||
className={`max-w-[92vw] font-extrabold leading-[1.02] tracking-tight ${
|
||||
isWinner
|
||||
? 'text-[clamp(4.5rem,11vw,10rem)]'
|
||||
: 'text-[clamp(3.5rem,8vw,7.5rem)]'
|
||||
}`}
|
||||
style={isWinner ? { textShadow: '0 0 90px rgba(232,195,74,0.35)' } : undefined}
|
||||
>
|
||||
{step.title ?? ''}
|
||||
</motion.h1>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
>
|
||||
<SignatureRule />
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultsSplash() {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
|
||||
<motion.p
|
||||
animate={{ opacity: [0.4, 1, 0.4] }}
|
||||
transition={{ repeat: Infinity, duration: 2.4 }}
|
||||
className="text-lg font-semibold uppercase tracking-[0.5em] text-[#de0f1e]"
|
||||
>
|
||||
The moment has come
|
||||
</motion.p>
|
||||
<h1 className="text-[clamp(4rem,10vw,9rem)] font-extrabold tracking-tight">Results</h1>
|
||||
<SignatureRule />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Page ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CeremonyPage({
|
||||
params: paramsPromise,
|
||||
}: {
|
||||
params: Promise<{ roundId: string }>
|
||||
}) {
|
||||
const params = use(paramsPromise)
|
||||
const { data: state } = trpc.liveVoting.getCeremonyState.useQuery(
|
||||
{ roundId: params.roundId },
|
||||
{ refetchInterval: 2000 }
|
||||
)
|
||||
const voteUrl =
|
||||
typeof window !== 'undefined'
|
||||
? `${window.location.origin}/vote/competition/${params.roundId}`
|
||||
: ''
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<OceanField>
|
||||
<div className="flex h-full items-center justify-center" />
|
||||
</OceanField>
|
||||
)
|
||||
}
|
||||
|
||||
// Display precedence: override → reveal → audience window → phase → welcome
|
||||
const reveal = state.reveal
|
||||
const revealStep =
|
||||
reveal && (reveal.status === 'REVEALING' || reveal.status === 'DONE')
|
||||
? ((reveal.steps[reveal.currentStepIndex] ?? null) as RevealStep | null)
|
||||
: null
|
||||
|
||||
let slideKey: string
|
||||
let slide: React.ReactNode
|
||||
let statusLabel: string | null = null
|
||||
|
||||
if (state.overrideSlide) {
|
||||
slideKey = `override-${state.overrideSlide}`
|
||||
slide = <StaticSlide kind={state.overrideSlide} programName={state.programName} />
|
||||
} else if (reveal && reveal.status === 'ARMED') {
|
||||
slideKey = 'reveal-armed'
|
||||
slide = <ResultsSplash />
|
||||
statusLabel = 'Results'
|
||||
} else if (revealStep) {
|
||||
slideKey = `reveal-${reveal!.currentStepIndex}`
|
||||
slide = <RevealSlide step={revealStep} />
|
||||
statusLabel = 'Results'
|
||||
} else if (state.audience.open) {
|
||||
slideKey = `audience-${state.audience.windowKey}`
|
||||
slide = (
|
||||
<AudienceVoteSlide
|
||||
windowKey={state.audience.windowKey}
|
||||
closesAt={state.audience.closesAt}
|
||||
voteCount={state.audience.voteCount}
|
||||
voteUrl={voteUrl}
|
||||
/>
|
||||
)
|
||||
statusLabel = 'Live'
|
||||
} else if (state.phase && state.activeProject) {
|
||||
slideKey = `phase-${state.phase.projectPhase}-${state.activeProject.title}`
|
||||
slide = <PhaseSlide state={state} />
|
||||
statusLabel = 'Live'
|
||||
} else {
|
||||
slideKey = 'welcome'
|
||||
slide = <StaticSlide kind="welcome" programName={state.programName} />
|
||||
}
|
||||
|
||||
return (
|
||||
<OceanField>
|
||||
<StatusBar programName={state.programName} label={statusLabel} />
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div key={slideKey} className="absolute inset-0" {...slideIn}>
|
||||
{slide}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</OceanField>
|
||||
)
|
||||
}
|
||||
327
src/app/(public)/lunch/pick/[token]/page.tsx
Normal file
327
src/app/(public)/lunch/pick/[token]/page.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useEffect, 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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { AlertCircle, CheckCircle2, Loader2, Salad, UtensilsCrossed } from 'lucide-react'
|
||||
|
||||
const ALLERGENS = [
|
||||
'GLUTEN', 'CRUSTACEANS', 'EGGS', 'FISH', 'PEANUTS', 'SOYBEANS', 'MILK',
|
||||
'TREE_NUTS', 'CELERY', 'MUSTARD', 'SESAME', 'SULPHITES', 'LUPIN', 'MOLLUSCS',
|
||||
] as const
|
||||
type Allergen = (typeof ALLERGENS)[number]
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ token: string }>
|
||||
}
|
||||
|
||||
function formatTag(t: string): string {
|
||||
return t.replace('_', ' ').toLowerCase()
|
||||
}
|
||||
|
||||
function formatWhen(d: Date): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
}).format(d)
|
||||
}
|
||||
|
||||
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">closed</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)
|
||||
return (
|
||||
<span className="font-medium tabular-nums">
|
||||
{days}d {hours % 24}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 }: { title: string; message: string }) {
|
||||
return (
|
||||
<Card className="mx-auto max-w-xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="text-muted-foreground h-5 w-5" />
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{message}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function DishPickContent({ token }: { token: string }) {
|
||||
const { data, isLoading, error } = trpc.lunch.getExternalByToken.useQuery(
|
||||
{ token },
|
||||
{ retry: false },
|
||||
)
|
||||
const setPick = trpc.lunch.setExternalPick.useMutation()
|
||||
|
||||
const [dishId, setDishId] = useState<string>('')
|
||||
const [allergens, setAllergens] = useState<Allergen[]>([])
|
||||
const [allergenOther, setAllergenOther] = useState<string>('')
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated && data) {
|
||||
setDishId(data.external.dishId ?? '')
|
||||
setAllergens((data.external.allergens as Allergen[]) ?? [])
|
||||
setAllergenOther(data.external.allergenOther ?? '')
|
||||
setHydrated(true)
|
||||
}
|
||||
}, [data, hydrated])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const msg = error.message ?? ''
|
||||
if (/expired/i.test(msg)) {
|
||||
return (
|
||||
<FriendlyError
|
||||
title="This link has expired"
|
||||
message="Please contact us at info@monaco-opc.com and we'll sort out your lunch."
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (/signature|malformed|parseable/i.test(msg)) {
|
||||
return (
|
||||
<FriendlyError
|
||||
title="This link is not valid"
|
||||
message="Please check your email or contact us at info@monaco-opc.com."
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<FriendlyError
|
||||
title="Something went wrong"
|
||||
message={msg || 'Please try again or contact us at info@monaco-opc.com.'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (!data) {
|
||||
return (
|
||||
<FriendlyError
|
||||
title="Not found"
|
||||
message="Please check your email link or contact us at info@monaco-opc.com."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const deadline = data.changeDeadline ? new Date(data.changeDeadline) : null
|
||||
const deadlinePassed = deadline ? new Date() > deadline : false
|
||||
const eventAt = data.event.eventAt ? new Date(data.event.eventAt) : null
|
||||
|
||||
const handleSave = async () => {
|
||||
setSubmitError(null)
|
||||
try {
|
||||
await setPick.mutateAsync({
|
||||
token,
|
||||
dishId: dishId || null,
|
||||
allergens,
|
||||
allergenOther: allergenOther.trim() || null,
|
||||
})
|
||||
setSaved(true)
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof Error ? err.message : 'Failed to save')
|
||||
}
|
||||
}
|
||||
|
||||
const eventCard = (
|
||||
<Card className="border-primary/40 bg-primary/5">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<UtensilsCrossed className="text-primary h-5 w-5" />
|
||||
<CardTitle>
|
||||
{data.event.venue ? `Lunch at ${data.event.venue}` : 'Lunch'}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1 text-sm">
|
||||
<p>
|
||||
Hi <strong>{data.external.name}</strong>, please choose your dish below.
|
||||
</p>
|
||||
{eventAt && (
|
||||
<p className="text-muted-foreground">
|
||||
<strong>When:</strong> {formatWhen(eventAt)}
|
||||
</p>
|
||||
)}
|
||||
{data.event.notes && (
|
||||
<p className="text-muted-foreground">{data.event.notes}</p>
|
||||
)}
|
||||
{deadline && !deadlinePassed && (
|
||||
<p className="text-muted-foreground pt-1">
|
||||
Choose by {formatWhen(deadline)} · <CountdownLabel deadline={deadline} />
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
// Past the change deadline → read-only.
|
||||
if (deadlinePassed) {
|
||||
const chosen = data.dishes.find((d) => d.id === data.external.dishId)
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-6">
|
||||
{eventCard}
|
||||
<FriendlyError
|
||||
title="Dish selection is now closed"
|
||||
message={
|
||||
chosen
|
||||
? `Your choice is "${chosen.name}". To change it, please contact us at info@monaco-opc.com.`
|
||||
: 'The deadline to choose a dish has passed. Please contact us at info@monaco-opc.com.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-6">
|
||||
{eventCard}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Salad className="h-4 w-4 text-emerald-600" /> Your dish
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<RadioGroup value={dishId} onValueChange={setDishId} className="gap-2">
|
||||
{data.dishes.map((d) => (
|
||||
<label
|
||||
key={d.id}
|
||||
htmlFor={`dish-${d.id}`}
|
||||
className="hover:bg-muted/50 flex cursor-pointer items-start gap-3 rounded-md border p-3"
|
||||
>
|
||||
<RadioGroupItem id={`dish-${d.id}`} value={d.id} className="mt-0.5" />
|
||||
<div>
|
||||
<div className="font-medium">{d.name}</div>
|
||||
{d.dietaryTags.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{d.dietaryTags.map((t) => (
|
||||
<Badge key={t} variant="secondary" className="text-xs">
|
||||
{formatTag(t)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
{data.dishes.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No dishes have been published yet. Please check back later.
|
||||
</p>
|
||||
)}
|
||||
</RadioGroup>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm">Allergens</Label>
|
||||
<div className="mt-2 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),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{formatTag(a)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm">Other allergens / dietary notes</Label>
|
||||
<Textarea
|
||||
value={allergenOther}
|
||||
onChange={(e) => {
|
||||
setAllergenOther(e.target.value)
|
||||
setSaved(false)
|
||||
}}
|
||||
rows={2}
|
||||
className="mt-1"
|
||||
placeholder="e.g. severe nut allergy, no shellfish"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{submitError && (
|
||||
<div className="border-destructive bg-destructive/10 text-destructive rounded-md border p-3 text-sm">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{saved && !setPick.isPending ? (
|
||||
<span className="flex items-center gap-2 text-sm text-emerald-600">
|
||||
<CheckCircle2 className="h-4 w-4" /> Saved — you can change it until the deadline.
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button size="lg" onClick={handleSave} disabled={setPick.isPending}>
|
||||
{setPick.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" /> Save my choice
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LunchPickPage({ params }: PageProps) {
|
||||
const { token } = use(params)
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
|
||||
<DishPickContent token={token} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,88 +1,274 @@
|
||||
'use client';
|
||||
'use client'
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { AudienceVoteCard } from '@/components/public/audience-vote-card';
|
||||
import { toast } from 'sonner';
|
||||
/**
|
||||
* Audience voting page — reached by scanning the QR code on the big screen.
|
||||
* Zero-instruction flow: scan → (auto token) → wait → tap your favorite →
|
||||
* done. Votes can be changed until the window closes. Uses ONLY public
|
||||
* procedures: attendees have no account.
|
||||
*/
|
||||
|
||||
export default function AudienceVotePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
|
||||
const params = use(paramsPromise);
|
||||
const utils = trpc.useUtils();
|
||||
const [hasVoted, setHasVoted] = useState(false);
|
||||
import { use, useEffect, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { formatClock } from '@/lib/live-timer'
|
||||
import { Check, Heart, Hourglass, Vote } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId });
|
||||
|
||||
const submitVoteMutation = trpc.liveVoting.castAudienceVote.useMutation({
|
||||
onSuccess: () => {
|
||||
setHasVoted(true);
|
||||
// Store in localStorage to prevent duplicate votes
|
||||
if (cursor?.activeProject?.id) {
|
||||
localStorage.setItem(`voted-${params.roundId}-${cursor.activeProject.id}`, 'true');
|
||||
const WINDOW_TITLE: Record<string, string> = {
|
||||
'CATEGORY:BUSINESS_CONCEPT': 'Pick your favorite Business Concept',
|
||||
'CATEGORY:STARTUP': 'Pick your favorite Startup',
|
||||
OVERALL: 'Pick your favorite project of the night',
|
||||
}
|
||||
toast.success('Vote submitted! Thank you for participating.');
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Check localStorage on mount
|
||||
function useCountdown(closesAt: string | Date | null | undefined) {
|
||||
const [, tick] = useState(0)
|
||||
useEffect(() => {
|
||||
if (cursor?.activeProject?.id) {
|
||||
const voted = localStorage.getItem(`voted-${params.roundId}-${cursor.activeProject.id}`);
|
||||
if (voted === 'true') {
|
||||
setHasVoted(true);
|
||||
const id = setInterval(() => tick((t) => t + 1), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
if (!closesAt) return null
|
||||
return Math.max(0, Math.floor((new Date(closesAt).getTime() - Date.now()) / 1000))
|
||||
}
|
||||
|
||||
export default function AudienceVotePage({
|
||||
params: paramsPromise,
|
||||
}: {
|
||||
params: Promise<{ roundId: string }>
|
||||
}) {
|
||||
const params = use(paramsPromise)
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: context, isLoading: contextLoading } =
|
||||
trpc.liveVoting.getAudienceContextByRound.useQuery({ roundId: params.roundId })
|
||||
const sessionId = context?.sessionId ?? null
|
||||
|
||||
// ── Anonymous voter token, persisted per session in this browser ─────────
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const register = trpc.liveVoting.registerAudienceVoter.useMutation({
|
||||
onSuccess: (res) => {
|
||||
if (sessionId) localStorage.setItem(`mopc-audience-${sessionId}`, res.token)
|
||||
setToken(res.token)
|
||||
},
|
||||
})
|
||||
useEffect(() => {
|
||||
if (!sessionId || !context?.allowAudienceVotes) return
|
||||
const stored = localStorage.getItem(`mopc-audience-${sessionId}`)
|
||||
if (stored) {
|
||||
setToken(stored)
|
||||
} else if (!register.isPending && !token) {
|
||||
register.mutate({ sessionId })
|
||||
}
|
||||
}, [cursor?.activeProject?.id, params.roundId]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, context?.allowAudienceVotes])
|
||||
|
||||
const handleVote = () => {
|
||||
if (!cursor?.activeProject?.id) return;
|
||||
const { data: win } = trpc.liveVoting.getAudienceWindow.useQuery(
|
||||
{ sessionId: sessionId ?? '', token: token ?? undefined },
|
||||
{ enabled: !!sessionId, refetchInterval: 3000 }
|
||||
)
|
||||
|
||||
submitVoteMutation.mutate({
|
||||
projectId: cursor.activeProject.id,
|
||||
sessionId: params.roundId,
|
||||
score: 1,
|
||||
token: `audience-${Date.now()}`
|
||||
});
|
||||
};
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const cast = trpc.liveVoting.castFavoriteVote.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.liveVoting.getAudienceWindow.invalidate()
|
||||
setSelected(null)
|
||||
toast.success('Vote recorded!')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (!cursor?.activeProject) {
|
||||
const secondsLeft = useCountdown(win?.closesAt)
|
||||
const open = !!win?.open && (secondsLeft === null || secondsLeft > 0)
|
||||
const myVote = win?.myVoteProjectId ?? null
|
||||
|
||||
// Reset local selection when a new window opens
|
||||
useEffect(() => {
|
||||
setSelected(null)
|
||||
}, [win?.windowKey])
|
||||
|
||||
if (contextLoading) {
|
||||
return <CenteredState icon={Hourglass} title="Loading…" />
|
||||
}
|
||||
if (!context) {
|
||||
return (
|
||||
<div className="container mx-auto flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-center text-lg text-muted-foreground">
|
||||
No project is currently being presented
|
||||
</p>
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
Please wait for the ceremony to begin
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex min-h-screen items-center justify-center p-4">
|
||||
<div className="w-full">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-4xl font-bold text-[#053d57]">Monaco Ocean Protection Challenge</h1>
|
||||
<p className="mt-2 text-lg text-muted-foreground">Live Audience Voting</p>
|
||||
</div>
|
||||
|
||||
<AudienceVoteCard
|
||||
project={cursor.activeProject}
|
||||
onVote={handleVote}
|
||||
hasVoted={hasVoted}
|
||||
<CenteredState
|
||||
icon={Vote}
|
||||
title="No vote here yet"
|
||||
subtitle="This voting link isn't active. Keep an eye on the big screen!"
|
||||
/>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
Live voting in progress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
if (!context.allowAudienceVotes) {
|
||||
return (
|
||||
<CenteredState
|
||||
icon={Vote}
|
||||
title="Audience voting is not open"
|
||||
subtitle="Voting will be enabled during the event."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-lg">
|
||||
{/* Gala header */}
|
||||
<div className="-mx-4 -mt-8 mb-6 bg-gradient-to-br from-[#021f2e] via-[#053d57] to-[#0a5a7c] px-6 py-8 text-center text-white sm:rounded-b-2xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.35em] text-white/60">
|
||||
{context.programName ?? 'Grand Finale'}
|
||||
</p>
|
||||
<h1 className="mt-2 text-2xl font-bold">Audience Vote</h1>
|
||||
{open && secondsLeft !== null && (
|
||||
<div className="mx-auto mt-3 inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-1.5 text-sm tabular-nums">
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[#de0f1e]" />
|
||||
closes in {formatClock(secondsLeft)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{!open ? (
|
||||
<motion.div
|
||||
key="waiting"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="py-10 text-center"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ y: [0, -6, 0] }}
|
||||
transition={{ repeat: Infinity, duration: 2.4, ease: 'easeInOut' }}
|
||||
className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#053d57]/8"
|
||||
>
|
||||
<Hourglass className="h-7 w-7 text-[#053d57]" />
|
||||
</motion.div>
|
||||
<h2 className="text-lg font-semibold text-[#053d57]">
|
||||
Voting opens after the presentations
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-xs text-sm text-muted-foreground">
|
||||
Keep this page open — the ballot appears here the moment voting starts.
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={`open-${win?.windowKey}`}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-3 pb-28"
|
||||
>
|
||||
<h2 className="text-center text-lg font-semibold text-[#053d57]">
|
||||
{WINDOW_TITLE[win?.windowKey ?? ''] ?? 'Pick your favorite'}
|
||||
</h2>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
One vote — you can change it until voting closes
|
||||
</p>
|
||||
|
||||
<div className="space-y-2.5 pt-2">
|
||||
{(win?.projects ?? []).map((project) => {
|
||||
const isPicked = (selected ?? myVote) === project.id
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() => setSelected(project.id)}
|
||||
className={`w-full rounded-2xl border-2 p-4 text-left transition-all active:scale-[0.99] ${
|
||||
isPicked
|
||||
? 'border-[#de0f1e] bg-[#053d57] text-white shadow-lg'
|
||||
: 'border-border bg-card hover:border-[#557f8c]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${
|
||||
isPicked ? 'bg-[#de0f1e]' : 'bg-[#053d57]/8'
|
||||
}`}
|
||||
>
|
||||
{isPicked ? (
|
||||
<Check className="h-5 w-5 text-white" />
|
||||
) : (
|
||||
<Heart className="h-4 w-4 text-[#557f8c]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">
|
||||
{project.teamName ?? project.title}
|
||||
</p>
|
||||
{project.teamName && (
|
||||
<p
|
||||
className={`truncate text-xs ${
|
||||
isPicked ? 'text-white/70' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{project.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pinned confirm bar */}
|
||||
<AnimatePresence>
|
||||
{selected && selected !== myVote && (
|
||||
<motion.div
|
||||
initial={{ y: 80 }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: 80 }}
|
||||
className="fixed inset-x-0 bottom-0 z-40 border-t bg-background/95 p-4 pb-[max(1rem,env(safe-area-inset-bottom))] backdrop-blur"
|
||||
>
|
||||
<div className="mx-auto max-w-lg">
|
||||
<button
|
||||
onClick={() =>
|
||||
sessionId &&
|
||||
token &&
|
||||
cast.mutate({ sessionId, token, projectId: selected })
|
||||
}
|
||||
disabled={cast.isPending || !token}
|
||||
className="w-full rounded-xl bg-[#de0f1e] py-3.5 font-semibold text-white shadow-lg transition-transform active:scale-[0.98] disabled:opacity-60"
|
||||
>
|
||||
{cast.isPending
|
||||
? 'Submitting…'
|
||||
: myVote
|
||||
? 'Change my vote'
|
||||
: 'Confirm my vote'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{myVote && (!selected || selected === myVote) && (
|
||||
<div className="pt-2 text-center">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-600/10 px-4 py-1.5 text-sm font-medium text-green-700">
|
||||
<Check className="h-4 w-4" />
|
||||
Vote recorded — tap another to change it
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CenteredState({
|
||||
icon: Icon,
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
icon: typeof Vote
|
||||
title: string
|
||||
subtitle?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#053d57]/8">
|
||||
<Icon className="h-7 w-7 text-[#053d57]" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-[#053d57]">{title}</h2>
|
||||
{subtitle && (
|
||||
<p className="mx-auto mt-2 max-w-xs text-sm text-muted-foreground">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
17
src/app/api/cron/final-document-reminders/route.ts
Normal file
17
src/app/api/cron/final-document-reminders/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sendDueFinalDocReminders } from '@/server/services/final-documents'
|
||||
|
||||
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 sendDueFinalDocReminders(prisma)
|
||||
return NextResponse.json({ ok: true, ...result })
|
||||
} catch (error) {
|
||||
console.error('[Cron] final-document-reminders failed:', error)
|
||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { expirePendingPastDeadline } from '@/server/services/finalist-confirmation'
|
||||
import {
|
||||
expirePendingPastDeadline,
|
||||
sendDueConfirmationReminders,
|
||||
} from '@/server/services/finalist-confirmation'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
@@ -8,8 +11,11 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
try {
|
||||
const result = await expirePendingPastDeadline(prisma)
|
||||
return NextResponse.json({ ok: true, ...result })
|
||||
const [expireResult, reminderResult] = await Promise.all([
|
||||
expirePendingPastDeadline(prisma),
|
||||
sendDueConfirmationReminders(prisma),
|
||||
])
|
||||
return NextResponse.json({ ok: true, ...expireResult, ...reminderResult })
|
||||
} catch (error) {
|
||||
console.error('[Cron] finalist-confirmations failed:', error)
|
||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sendLunchReminderEmail } from '@/lib/email'
|
||||
import {
|
||||
selectUnpickedAttendees,
|
||||
selectUnpickedExternals,
|
||||
} from '@/server/services/lunch-reminders'
|
||||
import { sendExternalDishInvite } from '@/server/services/lunch-external-invite'
|
||||
|
||||
/**
|
||||
* Cron: send a single reminder email per attending member who hasn't picked
|
||||
@@ -35,16 +40,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
)
|
||||
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 } } },
|
||||
})
|
||||
const ams = await selectUnpickedAttendees(prisma, event)
|
||||
for (const am of ams) {
|
||||
if (!am.user.email) continue
|
||||
try {
|
||||
@@ -61,6 +57,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
console.error('[lunch-reminders] send failed for', am.user.email, e)
|
||||
}
|
||||
}
|
||||
|
||||
// External attendees: emailed + no dish yet → their tokenized pick page.
|
||||
const externals = await selectUnpickedExternals(prisma, { id: event.id })
|
||||
for (const ext of externals) {
|
||||
if (!ext.email) continue
|
||||
try {
|
||||
await sendExternalDishInvite(prisma, ext, event)
|
||||
sent++
|
||||
} catch (e) {
|
||||
console.error('[lunch-reminders] external send failed for', ext.email, e)
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.lunchEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { reminderSentAt: new Date() },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
|
||||
import { appRouter } from '@/server/routers/_app'
|
||||
import { createContext } from '@/server/context'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { checkRateLimit, isCeremonyTraffic } from '@/lib/rate-limit'
|
||||
|
||||
// Allow long-running operations (AI filtering, bulk imports)
|
||||
// This affects Next.js serverless functions; for self-hosted, Nginx timeout also matters
|
||||
@@ -9,6 +9,9 @@ export const maxDuration = 300 // 5 minutes
|
||||
|
||||
const RATE_LIMIT = 100 // requests per window
|
||||
const RATE_WINDOW_MS = 60 * 1000 // 1 minute
|
||||
// Ceremony-day polling: whole venues share one IP (NAT) and every screen
|
||||
// polls a few cheap public reads — see CEREMONY_PROCEDURES in lib/rate-limit.
|
||||
const CEREMONY_RATE_LIMIT = 6000
|
||||
|
||||
function getClientIp(req: Request): string {
|
||||
return (
|
||||
@@ -20,7 +23,10 @@ function getClientIp(req: Request): string {
|
||||
|
||||
const handler = (req: Request) => {
|
||||
const ip = getClientIp(req)
|
||||
const { success, remaining, resetAt } = checkRateLimit(`trpc:${ip}`, RATE_LIMIT, RATE_WINDOW_MS)
|
||||
const ceremony = isCeremonyTraffic(new URL(req.url).pathname)
|
||||
const { success, remaining, resetAt } = ceremony
|
||||
? checkRateLimit(`trpc-ceremony:${ip}`, CEREMONY_RATE_LIMIT, RATE_WINDOW_MS)
|
||||
: checkRateLimit(`trpc:${ip}`, RATE_LIMIT, RATE_WINDOW_MS)
|
||||
|
||||
if (!success) {
|
||||
return new Response(JSON.stringify({ error: 'Too many requests' }), {
|
||||
|
||||
@@ -218,35 +218,6 @@
|
||||
--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 {
|
||||
@@ -345,13 +316,6 @@ div[class*="recharts-tooltip"] {
|
||||
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 */
|
||||
.recharts-tooltip-wrapper svg.recharts-surface {
|
||||
display: inline-block !important;
|
||||
|
||||
@@ -20,7 +20,6 @@ export default async function HomePage() {
|
||||
if (session?.user) {
|
||||
const roles = (session.user.roles as UserRole[] | undefined) ?? [session.user.role as UserRole]
|
||||
if (roles.includes('SUPER_ADMIN') || roles.includes('PROGRAM_ADMIN')) redirect('/admin')
|
||||
if (roles.includes('AWARD_MASTER')) redirect('/award-master')
|
||||
if (roles.includes('JURY_MEMBER')) redirect('/jury')
|
||||
if (roles.includes('MENTOR')) redirect('/mentor' as Route)
|
||||
if (roles.includes('APPLICANT')) redirect('/applicant' as Route)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { httpBatchLink } from '@trpc/client'
|
||||
import superjson from 'superjson'
|
||||
@@ -78,12 +77,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||
<SessionProvider>
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -437,7 +437,7 @@ export function AssignmentPreviewSheet({
|
||||
{mode === 'ai' && !aiResult && !isAIGenerating && (
|
||||
<Card className="border-dashed">
|
||||
<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" />
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
@@ -463,7 +463,7 @@ export function AssignmentPreviewSheet({
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{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">
|
||||
<div className="relative">
|
||||
<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 ── */}
|
||||
{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">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
{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}
|
||||
</p>
|
||||
))}
|
||||
|
||||
@@ -91,7 +91,9 @@ export function AdminOverrideDialog({
|
||||
<Label>Project Rankings</Label>
|
||||
<div className="space-y-2">
|
||||
{projectIds.map((projectId) => {
|
||||
const project = session?.results?.find((r) => r.project.id === projectId)?.project;
|
||||
const project =
|
||||
(session as any)?.projects?.find((p: any) => p.id === projectId) ??
|
||||
session?.results?.find((r) => r.project.id === projectId)?.project;
|
||||
return (
|
||||
<div key={projectId} className="flex items-center gap-3">
|
||||
<Input
|
||||
|
||||
260
src/components/admin/deliberation/deliberation-control-panel.tsx
Normal file
260
src/components/admin/deliberation/deliberation-control-panel.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
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 { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ResultsPanel } from './results-panel'
|
||||
import { AdminOverrideDialog } from './admin-override-dialog'
|
||||
import { Gavel, Lock, Play, Plus, Square, Users } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
BUSINESS_CONCEPT: 'Business Concepts',
|
||||
STARTUP: 'Startups',
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, { label: string; variant: 'secondary' | 'default' | 'destructive' | 'outline' }> = {
|
||||
DELIB_OPEN: { label: 'Open — voting not started', variant: 'secondary' },
|
||||
VOTING: { label: 'Voting', variant: 'default' },
|
||||
TALLYING: { label: 'Tallying', variant: 'outline' },
|
||||
RUNOFF: { label: 'Runoff', variant: 'destructive' },
|
||||
DELIB_LOCKED: { label: 'Locked', variant: 'secondary' },
|
||||
}
|
||||
|
||||
function SessionCard({ session, competitionId }: { session: any; competitionId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const { data: detail } = trpc.deliberation.getSession.useQuery(
|
||||
{ sessionId: session.id },
|
||||
{ refetchInterval: 10_000 }
|
||||
)
|
||||
|
||||
const invalidate = () => {
|
||||
utils.deliberation.getSession.invalidate({ sessionId: session.id })
|
||||
utils.deliberation.listSessions.invalidate({ competitionId })
|
||||
}
|
||||
const onError = (err: { message: string }) => toast.error(err.message)
|
||||
const openVoting = trpc.deliberation.openVoting.useMutation({
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
toast.success('Deliberation voting opened — jurors can now rank')
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const closeVoting = trpc.deliberation.closeVoting.useMutation({
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
toast.success('Voting closed — tallying')
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const status = detail?.status ?? session.status
|
||||
const badge = STATUS_BADGE[status] ?? { label: status, variant: 'outline' as const }
|
||||
const voteCount = detail?.votes?.length ?? session._count?.votes ?? 0
|
||||
const participantCount = detail?.participants?.length ?? session._count?.participants ?? 0
|
||||
const votedJurors = new Set(
|
||||
(detail?.votes ?? []).map((v: any) => v.juryMember?.id ?? v.juryMemberId)
|
||||
).size
|
||||
|
||||
const projectIds: string[] =
|
||||
detail?.projects?.map((p: any) => p.id) ??
|
||||
detail?.results?.map((r: any) => r.project.id) ??
|
||||
[]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{CATEGORY_LABEL[session.category] ?? session.category}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-0.5 flex items-center gap-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{votedJurors}/{participantCount} jurors voted
|
||||
</span>
|
||||
<span>{voteCount} ballots</span>
|
||||
<span>{session.mode === 'FULL_RANKING' ? 'Full ranking' : 'Single winner'}</span>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={badge.variant}>{badge.label}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{status === 'DELIB_OPEN' && (
|
||||
<Button
|
||||
onClick={() => openVoting.mutate({ sessionId: session.id })}
|
||||
disabled={openVoting.isPending}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Open voting
|
||||
</Button>
|
||||
)}
|
||||
{(status === 'VOTING' || status === 'RUNOFF') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => closeVoting.mutate({ sessionId: session.id })}
|
||||
disabled={closeVoting.isPending}
|
||||
>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
Close voting & tally
|
||||
</Button>
|
||||
)}
|
||||
{status !== 'DELIB_LOCKED' && (
|
||||
<Button variant="outline" onClick={() => setOverrideOpen(true)}>
|
||||
<Gavel className="mr-2 h-4 w-4" />
|
||||
Set rankings manually
|
||||
</Button>
|
||||
)}
|
||||
{status === 'DELIB_LOCKED' && (
|
||||
<Badge variant="secondary" className="gap-1 py-1.5">
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
Results locked
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Aggregated results + runoff/finalize controls */}
|
||||
{(status === 'TALLYING' || status === 'RUNOFF' || status === 'DELIB_LOCKED') && (
|
||||
<ResultsPanel sessionId={session.id} />
|
||||
)}
|
||||
|
||||
<AdminOverrideDialog
|
||||
sessionId={session.id}
|
||||
open={overrideOpen}
|
||||
onOpenChange={setOverrideOpen}
|
||||
projectIds={projectIds}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin deliberation console for a DELIBERATION round: create the per-category
|
||||
* sessions from the round's jury group, drive voting open/close, tally,
|
||||
* resolve ties, override manually (the "jury went analog" path) and finalize.
|
||||
*/
|
||||
export function DeliberationControlPanel({
|
||||
roundId,
|
||||
competitionId,
|
||||
}: {
|
||||
roundId: string
|
||||
competitionId: string
|
||||
}) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: round } = trpc.round.getById.useQuery({ id: roundId })
|
||||
const juryGroupId = round?.juryGroupId ?? ''
|
||||
const { data: juryGroup } = trpc.juryGroup.getById.useQuery(
|
||||
{ id: juryGroupId },
|
||||
{ enabled: !!juryGroupId }
|
||||
)
|
||||
const { data: sessions } = trpc.deliberation.listSessions.useQuery(
|
||||
{ competitionId },
|
||||
{ refetchInterval: 15_000 }
|
||||
)
|
||||
const [mode, setMode] = useState<'FULL_RANKING' | 'SINGLE_WINNER_VOTE'>('FULL_RANKING')
|
||||
|
||||
const createSession = trpc.deliberation.createSession.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.deliberation.listSessions.invalidate({ competitionId })
|
||||
toast.success('Deliberation session created')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const roundSessions = (sessions ?? []).filter((s: any) => s.roundId === roundId)
|
||||
const existingCategories = new Set(roundSessions.map((s: any) => s.category))
|
||||
const votingMembers = (juryGroup?.members ?? []).filter((m: any) => m.role !== 'OBSERVER')
|
||||
|
||||
const handleCreate = (category: 'STARTUP' | 'BUSINESS_CONCEPT') => {
|
||||
if (votingMembers.length === 0) {
|
||||
toast.error('The round has no jury group members to deliberate')
|
||||
return
|
||||
}
|
||||
createSession.mutate({
|
||||
competitionId,
|
||||
roundId,
|
||||
category,
|
||||
mode,
|
||||
tieBreakMethod: 'TIE_ADMIN_DECIDES',
|
||||
showPriorJuryData: true,
|
||||
participantUserIds: votingMembers.map((m: any) => m.id),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{(['BUSINESS_CONCEPT', 'STARTUP'] as const).some((c) => !existingCategories.has(c)) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create Deliberation Sessions</CardTitle>
|
||||
<CardDescription>
|
||||
One session per category · participants come from the round's jury group (
|
||||
{votingMembers.length} voting member{votingMembers.length === 1 ? '' : 's'})
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Mode</Label>
|
||||
<Select value={mode} onValueChange={(v) => setMode(v as typeof mode)}>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="FULL_RANKING">Full ranking (Borda)</SelectItem>
|
||||
<SelectItem value="SINGLE_WINNER_VOTE">Single winner pick</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(['BUSINESS_CONCEPT', 'STARTUP'] as const)
|
||||
.filter((c) => !existingCategories.has(c))
|
||||
.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant="outline"
|
||||
onClick={() => handleCreate(category)}
|
||||
disabled={createSession.isPending || !juryGroupId}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{CATEGORY_LABEL[category]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!juryGroupId && (
|
||||
<p className="text-xs text-destructive">
|
||||
Assign a jury group to this round first (Config tab).
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{roundSessions.map((s: any) => (
|
||||
<SessionCard key={s.id} session={s} competitionId={competitionId} />
|
||||
))}
|
||||
|
||||
{roundSessions.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
No deliberation sessions yet — create one per category above.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
159
src/components/admin/grand-finale/enroll-attendees-dialog.tsx
Normal file
159
src/components/admin/grand-finale/enroll-attendees-dialog.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
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,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export type AttendeeSelection = {
|
||||
attendingUserIds: string[]
|
||||
visaFlags: Record<string, boolean>
|
||||
}
|
||||
|
||||
type Member = {
|
||||
userId: string
|
||||
name: string | null
|
||||
role: string
|
||||
email: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
members: Member[]
|
||||
cap: number
|
||||
onConfirm: (attendingUserIds: string[], visaFlags: Record<string, boolean>) => void
|
||||
initial?: AttendeeSelection
|
||||
isPending?: boolean
|
||||
}
|
||||
|
||||
export function EnrollAttendeesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
members,
|
||||
cap,
|
||||
onConfirm,
|
||||
initial,
|
||||
isPending = false,
|
||||
}: Props) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [visa, setVisa] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Seed from initial or default to first member (the lead)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (initial) {
|
||||
setSelected(new Set(initial.attendingUserIds))
|
||||
setVisa(initial.visaFlags)
|
||||
} else {
|
||||
const defaultSelected = members.slice(0, 1).map((m) => m.userId)
|
||||
setSelected(new Set(defaultSelected))
|
||||
setVisa({})
|
||||
}
|
||||
}, [open, initial, members])
|
||||
|
||||
const overCap = selected.size > cap
|
||||
const noneSelected = selected.size === 0
|
||||
|
||||
const toggleMember = (userId: string, checked: boolean) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(userId)
|
||||
else next.delete(userId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
const ids = Array.from(selected)
|
||||
onConfirm(
|
||||
ids,
|
||||
Object.fromEntries(ids.map((id) => [id, !!visa[id]])),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!isPending) onOpenChange(next)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select attendees</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose up to {cap} team member{cap === 1 ? '' : 's'} who will attend. Toggle
|
||||
"Visa?" for anyone who needs a visa letter.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ul className="max-h-[50vh] space-y-2 overflow-y-auto pr-1">
|
||||
{members.map((m) => {
|
||||
const checked = selected.has(m.userId)
|
||||
const atCap = !checked && selected.size >= cap
|
||||
return (
|
||||
<li
|
||||
key={m.userId}
|
||||
className="flex items-start justify-between gap-4 rounded-md border px-3 py-2"
|
||||
>
|
||||
<label className="flex flex-1 cursor-pointer items-start gap-3">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={atCap}
|
||||
onCheckedChange={(c) => toggleMember(m.userId, c === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{m.name ?? m.email}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{m.email}
|
||||
{m.role && m.role !== 'MEMBER' ? ` · ${m.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[m.userId]}
|
||||
onCheckedChange={(c) => setVisa((prev) => ({ ...prev, [m.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={() => onOpenChange(false)} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={overCap || noneSelected || isPending}
|
||||
>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Confirm attendees
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { EmailPreviewDialog } from '@/components/admin/round/email-preview-dialog'
|
||||
import { Eye, Mail, Send } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const REMINDER_TYPE = 'GRAND_FINAL_DOCS_REMINDER'
|
||||
|
||||
export function FinalDocsReminderButton({ programId }: { programId: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
|
||||
const preview = trpc.notification.previewEmailTemplate.useQuery(
|
||||
{ notificationType: REMINDER_TYPE },
|
||||
{ enabled: previewOpen },
|
||||
)
|
||||
|
||||
const send = trpc.finalist.sendDocumentReminders.useMutation({
|
||||
onSuccess: (r) => {
|
||||
toast.success(`Reminder sent to ${r.sent} team${r.sent === 1 ? '' : 's'}`)
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Mail className="mr-2 h-4 w-4" /> Remind teams to upload final documents
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remind finalist teams</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sends an in-app + email reminder to every finalist team with missing required
|
||||
documents.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => setPreviewOpen(true)}>
|
||||
<Eye className="mr-2 h-4 w-4" /> Preview email
|
||||
</Button>
|
||||
<Button onClick={() => send.mutate({ programId })} disabled={send.isPending}>
|
||||
<Send className="mr-2 h-4 w-4" /> {send.isPending ? 'Sending…' : 'Send reminders'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Self-contained preview dialog — rendered as a sibling so it is not nested
|
||||
inside the confirm dialog's content. */}
|
||||
<EmailPreviewDialog
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
title="Final Documents Reminder"
|
||||
description="Preview of the email finalist teams receive."
|
||||
recipientCount={0}
|
||||
previewHtml={preview.data?.html}
|
||||
isPreviewLoading={preview.isLoading}
|
||||
onSend={() => {}}
|
||||
isSending={false}
|
||||
previewOnly
|
||||
showCustomMessage={false}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
/**
|
||||
* Admin toggle: whether finalist teams may upload *revised* grand-final documents.
|
||||
* Off by default — judges always see the teams' existing prior-round submissions
|
||||
* regardless; this only controls whether teams are prompted/allowed to upload new
|
||||
* revised versions (and whether the upload reminder cron runs).
|
||||
*/
|
||||
export function FinalDocsUploadsToggle({ roundId }: { roundId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data } = trpc.finalist.getRevisedUploadSetting.useQuery({ roundId })
|
||||
const set = trpc.finalist.setRevisedUploadSetting.useMutation({
|
||||
onSuccess: (r) => {
|
||||
toast.success(r.enabled ? 'Finalist revised uploads enabled' : 'Finalist revised uploads disabled')
|
||||
utils.finalist.getRevisedUploadSetting.invalidate({ roundId })
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="finalist-revised-uploads"
|
||||
checked={!!data?.enabled}
|
||||
disabled={set.isPending}
|
||||
onCheckedChange={(v) => set.mutate({ roundId, enabled: v })}
|
||||
/>
|
||||
<Label htmlFor="finalist-revised-uploads" className="text-sm text-muted-foreground cursor-pointer">
|
||||
Allow finalists to upload revised documents
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
456
src/components/admin/grand-finale/finalist-enrollment-card.tsx
Normal file
456
src/components/admin/grand-finale/finalist-enrollment-card.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
'use client'
|
||||
|
||||
import { 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 { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Loader2, UserCheck } from 'lucide-react'
|
||||
import { formatEnumLabel } from '@/lib/utils'
|
||||
import {
|
||||
EnrollAttendeesDialog,
|
||||
type AttendeeSelection,
|
||||
} from './enroll-attendees-dialog'
|
||||
|
||||
interface Props {
|
||||
programId: string
|
||||
roundId: string
|
||||
}
|
||||
|
||||
type EnrollMode = 'EMAIL' | 'ADMIN_CONFIRM'
|
||||
|
||||
type RowState = {
|
||||
mode: EnrollMode
|
||||
attendees?: AttendeeSelection
|
||||
}
|
||||
|
||||
type Candidate = {
|
||||
projectId: string
|
||||
title: string
|
||||
teamName: string | null
|
||||
country: string | null
|
||||
inLiveFinal: boolean
|
||||
confirmationStatus: string | null
|
||||
teamMembers: Array<{ userId: string; name: string | null; role: string; email: string }>
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }
|
||||
> = {
|
||||
PENDING: { label: 'Pending', variant: 'secondary' },
|
||||
CONFIRMED: { label: 'Confirmed', variant: 'default' },
|
||||
DECLINED: { label: 'Declined', variant: 'destructive' },
|
||||
EXPIRED: { label: 'Expired', variant: 'outline' },
|
||||
}
|
||||
|
||||
function deriveStatus(candidate: Candidate): string {
|
||||
if (candidate.confirmationStatus) return candidate.confirmationStatus
|
||||
if (candidate.inLiveFinal) return 'IN_ROUND'
|
||||
return 'NOT_ENROLLED'
|
||||
}
|
||||
|
||||
function StatusBadge({ candidate }: { candidate: Candidate }) {
|
||||
const status = deriveStatus(candidate)
|
||||
if (status === 'NOT_ENROLLED') {
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Not enrolled
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (status === 'IN_ROUND') {
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
In round
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
const cfg = STATUS_CONFIG[status] ?? { label: status, variant: 'outline' as const }
|
||||
return (
|
||||
<Badge variant={cfg.variant} className="text-xs">
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function FinalistEnrollmentCard({ programId, roundId }: Props) {
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data, isLoading } = trpc.finalist.listEnrollmentCandidates.useQuery({ programId })
|
||||
|
||||
// Per-row selection + mode state
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [rowState, setRowState] = useState<Record<string, RowState>>({})
|
||||
|
||||
// Dialog state for "Set attendees now" picker
|
||||
const [attendeesDialog, setAttendeesDialog] = useState<{
|
||||
open: boolean
|
||||
projectId: string
|
||||
members: Candidate['teamMembers']
|
||||
} | null>(null)
|
||||
|
||||
// Un-enroll state
|
||||
const [unenrolling, setUnenrolling] = useState<string | null>(null)
|
||||
|
||||
const invalidateQueries = () => {
|
||||
utils.finalist.listEnrollmentCandidates.invalidate({ programId })
|
||||
utils.logistics.listConfirmations.invalidate({ programId })
|
||||
}
|
||||
|
||||
const enrollMutation = trpc.finalist.enrollFinalists.useMutation({
|
||||
onSuccess: (result) => {
|
||||
const parts: string[] = []
|
||||
if (result.enrolled > 0) parts.push(`${result.enrolled} enrolled`)
|
||||
if (result.emailed > 0) parts.push(`${result.emailed} emailed`)
|
||||
if (result.adminConfirmed > 0) parts.push(`${result.adminConfirmed} admin-confirmed`)
|
||||
if (result.skipped.length > 0) parts.push(`${result.skipped.length} skipped`)
|
||||
toast.success(parts.join(' · ') || 'Done')
|
||||
setSelected(new Set())
|
||||
setRowState({})
|
||||
invalidateQueries()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const unenrollMutation = trpc.finalist.unenroll.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Team removed from the Grand Final round')
|
||||
setUnenrolling(null)
|
||||
invalidateQueries()
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
setUnenrolling(null)
|
||||
},
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const toggleRow = (projectId: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(projectId)) {
|
||||
next.delete(projectId)
|
||||
} else {
|
||||
next.add(projectId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
setRowState((prev) => {
|
||||
if (prev[projectId]) return prev
|
||||
return { ...prev, [projectId]: { mode: 'EMAIL' } }
|
||||
})
|
||||
}
|
||||
|
||||
const setMode = (projectId: string, mode: EnrollMode, candidate: Candidate) => {
|
||||
if (mode === 'ADMIN_CONFIRM') {
|
||||
setAttendeesDialog({
|
||||
open: true,
|
||||
projectId,
|
||||
members: candidate.teamMembers,
|
||||
})
|
||||
} else {
|
||||
setRowState((prev) => ({
|
||||
...prev,
|
||||
[projectId]: { mode: 'EMAIL' },
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAttendeesConfirm = (
|
||||
projectId: string,
|
||||
attendingUserIds: string[],
|
||||
visaFlags: Record<string, boolean>,
|
||||
) => {
|
||||
setRowState((prev) => ({
|
||||
...prev,
|
||||
[projectId]: {
|
||||
mode: 'ADMIN_CONFIRM',
|
||||
attendees: { attendingUserIds, visaFlags },
|
||||
},
|
||||
}))
|
||||
setAttendeesDialog(null)
|
||||
}
|
||||
|
||||
const buildEnrollments = (projectIds: string[]) => {
|
||||
return projectIds.map((projectId) => {
|
||||
const rs = rowState[projectId] ?? { mode: 'EMAIL' as EnrollMode }
|
||||
if (rs.mode === 'ADMIN_CONFIRM' && rs.attendees) {
|
||||
return {
|
||||
projectId,
|
||||
mode: 'ADMIN_CONFIRM' as const,
|
||||
attendingUserIds: rs.attendees.attendingUserIds,
|
||||
visaFlags: rs.attendees.visaFlags,
|
||||
}
|
||||
}
|
||||
return { projectId, mode: 'EMAIL' as const }
|
||||
})
|
||||
}
|
||||
|
||||
const handleEnrollSelected = () => {
|
||||
if (!data?.liveFinalRoundId) {
|
||||
toast.error('No LIVE_FINAL round found for this program')
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selected)
|
||||
if (ids.length === 0) return
|
||||
enrollMutation.mutate({
|
||||
programId,
|
||||
roundId: data.liveFinalRoundId,
|
||||
enrollments: buildEnrollments(ids),
|
||||
})
|
||||
}
|
||||
|
||||
const handleEnrollAllEligible = () => {
|
||||
if (!data?.liveFinalRoundId) {
|
||||
toast.error('No LIVE_FINAL round found for this program')
|
||||
return
|
||||
}
|
||||
const allCandidates = data.categories.flatMap((c) => c.candidates)
|
||||
const eligible = allCandidates.filter((c) => c.confirmationStatus !== 'CONFIRMED')
|
||||
if (eligible.length === 0) {
|
||||
toast.info('No eligible teams to enroll')
|
||||
return
|
||||
}
|
||||
enrollMutation.mutate({
|
||||
programId,
|
||||
roundId: data.liveFinalRoundId,
|
||||
enrollments: eligible.map((c) => ({
|
||||
projectId: c.projectId,
|
||||
mode: 'EMAIL' as const,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-56 w-full rounded-md" />
|
||||
}
|
||||
|
||||
const noMentoringTeams = !data || data.categories.length === 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCheck className="text-muted-foreground h-4 w-4" />
|
||||
<CardTitle className="text-base">Enroll finalists</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Select mentoring-round teams to advance into the Grand Final. Each enrolled team
|
||||
immediately appears on the Finals jury's project list and receives an attendance
|
||||
confirmation request (or can be admin-confirmed on the spot).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{noMentoringTeams ? (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
No mentoring-round teams to enroll yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{data.categories.map((cat) => (
|
||||
<div key={cat.category}>
|
||||
{/* Category header */}
|
||||
<div className="text-muted-foreground mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide">
|
||||
<span>{formatEnumLabel(cat.category)}</span>
|
||||
<span className="font-normal">
|
||||
— {cat.confirmedCount}/{cat.quota ?? '?'} confirmed
|
||||
{cat.pendingCount > 0 ? `, ${cat.pendingCount} pending` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{cat.candidates.map((candidate) => {
|
||||
const status = deriveStatus(candidate)
|
||||
const isEnrolled =
|
||||
status === 'CONFIRMED' || status === 'DECLINED' || status === 'EXPIRED'
|
||||
const isChecked = selected.has(candidate.projectId)
|
||||
const rs = rowState[candidate.projectId]
|
||||
const isUnenrolling =
|
||||
unenrollMutation.isPending && unenrolling === candidate.projectId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={candidate.projectId}
|
||||
className="flex flex-wrap items-start gap-3 rounded-md border p-3"
|
||||
>
|
||||
{/* Left: checkbox (or spacer for enrolled rows) */}
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
{isEnrolled ? (
|
||||
<div className="h-4 w-4" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleRow(candidate.projectId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Middle: project info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{candidate.title}</span>
|
||||
<StatusBadge candidate={candidate} />
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||
{[candidate.teamName, candidate.country]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || '—'}
|
||||
</div>
|
||||
|
||||
{/* Mode toggle for selected rows */}
|
||||
{isChecked && !isEnrolled && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={rs?.mode === 'EMAIL' ? 'default' : 'outline'}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setMode(candidate.projectId, 'EMAIL', candidate)}
|
||||
>
|
||||
Email team
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={rs?.mode === 'ADMIN_CONFIRM' ? 'default' : 'outline'}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() =>
|
||||
setMode(candidate.projectId, 'ADMIN_CONFIRM', candidate)
|
||||
}
|
||||
>
|
||||
Set attendees now
|
||||
{rs?.mode === 'ADMIN_CONFIRM' && rs.attendees && (
|
||||
<span className="ml-1 opacity-70">
|
||||
({rs.attendees.attendingUserIds.length})
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: un-enroll button for CONFIRMED/DECLINED rows */}
|
||||
{(status === 'CONFIRMED' || status === 'DECLINED') && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isUnenrolling}
|
||||
onClick={() => setUnenrolling(candidate.projectId)}
|
||||
>
|
||||
{isUnenrolling ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Un-enroll'
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove from Grand Final?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes <strong>{candidate.title}</strong> from the Grand
|
||||
Final round and deletes their attendance record. Continue?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setUnenrolling(null)}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
unenrollMutation.mutate({
|
||||
projectId: candidate.projectId,
|
||||
roundId,
|
||||
})
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t pt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={selected.size === 0 || enrollMutation.isPending}
|
||||
onClick={handleEnrollSelected}
|
||||
>
|
||||
{enrollMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Enroll selected ({selected.size})
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={enrollMutation.isPending}
|
||||
onClick={handleEnrollAllEligible}
|
||||
>
|
||||
Enroll all eligible
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Attendees picker dialog */}
|
||||
{attendeesDialog && (
|
||||
<EnrollAttendeesDialog
|
||||
open={attendeesDialog.open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// If user closes without confirming, revert mode to EMAIL
|
||||
setRowState((prev) => ({
|
||||
...prev,
|
||||
[attendeesDialog.projectId]: {
|
||||
mode: 'EMAIL',
|
||||
},
|
||||
}))
|
||||
setAttendeesDialog(null)
|
||||
}
|
||||
}}
|
||||
members={attendeesDialog.members}
|
||||
cap={data?.attendeeCap ?? 3}
|
||||
initial={rowState[attendeesDialog.projectId]?.attendees}
|
||||
onConfirm={(ids, flags) =>
|
||||
handleAttendeesConfirm(attendeesDialog.projectId, ids, flags)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
79
src/components/admin/grand-finale/review-docs-picker.tsx
Normal file
79
src/components/admin/grand-finale/review-docs-picker.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { toast } from 'sonner'
|
||||
import { Eye } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Admin picker: which previously-submitted documents finale judges see on the
|
||||
* review page. Default (switch off) shows everything; switching to curated
|
||||
* mode starts with all slots ticked, and the admin unticks what to hide.
|
||||
* Grand Final round uploads are always visible regardless.
|
||||
*/
|
||||
export function ReviewDocsPicker({ programId, roundId }: { programId: string; roundId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data } = trpc.finalist.getReviewDocSettings.useQuery({ programId, roundId })
|
||||
const set = trpc.finalist.setReviewVisibleRequirements.useMutation({
|
||||
onSuccess: () => utils.finalist.getReviewDocSettings.invalidate({ programId, roundId }),
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
if (!data || data.options.length === 0) return null
|
||||
|
||||
const curated = data.selectedIds !== null
|
||||
const selected = new Set(data.selectedIds ?? data.options.map((o) => o.requirementId))
|
||||
const toggleSlot = (id: string, on: boolean) => {
|
||||
const next = new Set(selected)
|
||||
if (on) next.add(id)
|
||||
else next.delete(id)
|
||||
set.mutate({ roundId, requirementIds: [...next] })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Eye className="h-5 w-5" /> Documents shown to judges
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Choose which previously submitted documents judges see on the finalist review page.
|
||||
Documents uploaded directly to this Grand Final round are always visible.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="curate-review-docs"
|
||||
checked={curated}
|
||||
disabled={set.isPending}
|
||||
onCheckedChange={(v) =>
|
||||
set.mutate({ roundId, requirementIds: v ? data.options.map((o) => o.requirementId) : null })}
|
||||
/>
|
||||
<Label htmlFor="curate-review-docs" className="text-sm text-muted-foreground cursor-pointer">
|
||||
{curated ? 'Curated — judges see only the checked documents' : 'Showing all submitted documents'}
|
||||
</Label>
|
||||
</div>
|
||||
{curated && (
|
||||
<div className="space-y-2">
|
||||
{data.options.map((o) => (
|
||||
<label key={o.requirementId} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={selected.has(o.requirementId)}
|
||||
disabled={set.isPending}
|
||||
onCheckedChange={(v) => toggleSlot(o.requirementId, v === true)}
|
||||
/>
|
||||
<span>{o.name} — {o.roundName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({o.fileCount} file{o.fileCount === 1 ? '' : 's'})
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -17,7 +18,14 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { ListOrdered, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ListOrdered, Loader2, PlusCircle } from 'lucide-react'
|
||||
import { formatEnumLabel } from '@/lib/utils'
|
||||
import type { CompetitionCategory } from '@prisma/client'
|
||||
|
||||
@@ -25,6 +33,145 @@ interface Props {
|
||||
programId: string
|
||||
}
|
||||
|
||||
function AddToWaitlistForm({ programId }: { programId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const [category, setCategory] = useState<string>('')
|
||||
const [projectId, setProjectId] = useState<string>('')
|
||||
|
||||
const { data: candidatesData, isLoading: loadingCandidates } =
|
||||
trpc.finalist.listEnrollmentCandidates.useQuery({ programId })
|
||||
|
||||
const { data: waitlistData } = trpc.finalist.listWaitlist.useQuery({ programId })
|
||||
|
||||
const addMutation = trpc.finalist.addToWaitlist.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Project added to waitlist')
|
||||
utils.finalist.listWaitlist.invalidate({ programId })
|
||||
utils.finalist.listEnrollmentCandidates.invalidate({ programId })
|
||||
setProjectId('')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Build set of project IDs already on the waitlist
|
||||
const waitlistedProjectIds = new Set(
|
||||
(waitlistData ?? [])
|
||||
.filter((e) => e.status === 'WAITING' || e.status === 'PROMOTED')
|
||||
.map((e) => e.projectId),
|
||||
)
|
||||
|
||||
// Candidates per selected category — exclude confirmed/waitlisted
|
||||
const categoryData = candidatesData?.categories.find((c) => c.category === category)
|
||||
const availableCandidates = (categoryData?.candidates ?? []).filter(
|
||||
(c) =>
|
||||
!waitlistedProjectIds.has(c.projectId) &&
|
||||
c.confirmationStatus !== 'CONFIRMED',
|
||||
)
|
||||
|
||||
// Category options (only categories that have candidates)
|
||||
const categoryOptions = (candidatesData?.categories ?? []).filter(
|
||||
(c) =>
|
||||
c.candidates.some(
|
||||
(p) =>
|
||||
!waitlistedProjectIds.has(p.projectId) &&
|
||||
p.confirmationStatus !== 'CONFIRMED',
|
||||
),
|
||||
)
|
||||
|
||||
// Derive the next rank for the selected category
|
||||
const currentMaxRank = Math.max(
|
||||
0,
|
||||
...(waitlistData ?? [])
|
||||
.filter((e) => e.category === category)
|
||||
.map((e) => e.rank),
|
||||
)
|
||||
const nextRank = currentMaxRank + 1
|
||||
|
||||
const canSubmit = !!category && !!projectId && !addMutation.isPending
|
||||
|
||||
if (loadingCandidates) return <Skeleton className="h-10 w-full" />
|
||||
|
||||
return (
|
||||
<div className="border-t pt-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<PlusCircle className="text-muted-foreground h-4 w-4" />
|
||||
Add to waitlist
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="min-w-[160px]">
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={(v) => {
|
||||
setCategory(v)
|
||||
setProjectId('')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryOptions.length === 0 ? (
|
||||
<SelectItem value="__none__" disabled>
|
||||
No eligible categories
|
||||
</SelectItem>
|
||||
) : (
|
||||
categoryOptions.map((c) => (
|
||||
<SelectItem key={c.category} value={c.category}>
|
||||
{formatEnumLabel(c.category)}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<Select
|
||||
value={projectId}
|
||||
onValueChange={setProjectId}
|
||||
disabled={!category || availableCandidates.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder={category ? 'Select project' : 'Choose category first'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableCandidates.length === 0 ? (
|
||||
<SelectItem value="__none__" disabled>
|
||||
No eligible projects
|
||||
</SelectItem>
|
||||
) : (
|
||||
availableCandidates.map((c) => (
|
||||
<SelectItem key={c.projectId} value={c.projectId}>
|
||||
{c.title}
|
||||
{c.country ? ` · ${c.country}` : ''}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canSubmit}
|
||||
onClick={() =>
|
||||
addMutation.mutate({
|
||||
programId,
|
||||
category: category as CompetitionCategory,
|
||||
projectId,
|
||||
rank: nextRank,
|
||||
})
|
||||
}
|
||||
>
|
||||
{addMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Add at end'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }> = {
|
||||
WAITING: { label: 'Waiting', variant: 'outline' },
|
||||
PROMOTED: { label: 'Promoted', variant: 'default' },
|
||||
@@ -65,10 +212,11 @@ export function WaitlistCard({ programId }: Props) {
|
||||
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">
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
No waitlist entries yet.
|
||||
</p>
|
||||
<AddToWaitlistForm programId={programId} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -161,6 +309,7 @@ export function WaitlistCard({ programId }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<AddToWaitlistForm programId={programId} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
278
src/components/admin/live/audience-window-panel.tsx
Normal file
278
src/components/admin/live/audience-window-panel.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
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 { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { formatClock } from '@/lib/live-timer'
|
||||
import { ChevronDown, QrCode, Users, Vote, XCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const WINDOW_LABEL: Record<string, string> = {
|
||||
'CATEGORY:BUSINESS_CONCEPT': 'Business Concepts',
|
||||
'CATEGORY:STARTUP': 'Startups',
|
||||
OVERALL: 'Overall favorite',
|
||||
}
|
||||
|
||||
/**
|
||||
* Audience favorite-vote control: open a per-category (or overall) window for
|
||||
* N minutes, watch the live vote count, close early, and project the QR code.
|
||||
*/
|
||||
export function AudienceWindowPanel({ roundId }: { roundId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: session } = trpc.liveVoting.getSession.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 3000 }
|
||||
)
|
||||
const { data: tallies } = trpc.liveVoting.getFavoriteTallies.useQuery(
|
||||
{ sessionId: session?.id ?? '' },
|
||||
{ enabled: !!session?.id, refetchInterval: 3000 }
|
||||
)
|
||||
const [durationMin, setDurationMin] = useState('5')
|
||||
const [talliesOpen, setTalliesOpen] = useState(false)
|
||||
const [, tick] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => tick((t) => t + 1), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const invalidate = () => {
|
||||
utils.liveVoting.getSession.invalidate({ roundId })
|
||||
if (session?.id) utils.liveVoting.getFavoriteTallies.invalidate({ sessionId: session.id })
|
||||
}
|
||||
const onError = (err: { message: string }) => toast.error(err.message)
|
||||
const openWindow = trpc.liveVoting.openAudienceWindow.useMutation({
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
const closeWindow = trpc.liveVoting.closeAudienceWindow.useMutation({
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
toast.success('Audience voting closed')
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const updateConfig = trpc.liveVoting.updateSessionConfig.useMutation({
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
if (!session) return null
|
||||
|
||||
const closesAt = session.audienceWindowClosesAt ? new Date(session.audienceWindowClosesAt) : null
|
||||
const secondsLeft = closesAt ? Math.floor((closesAt.getTime() - Date.now()) / 1000) : null
|
||||
const isOpen = session.audiencePhase === 'OPEN' && secondsLeft !== null && secondsLeft > 0
|
||||
const openKey = isOpen ? session.audienceWindowKey : null
|
||||
|
||||
const currentWindow = tallies?.windows.find((w) => w.windowKey === openKey)
|
||||
const voteUrl =
|
||||
typeof window !== 'undefined' ? `${window.location.origin}/vote/competition/${roundId}` : ''
|
||||
|
||||
const duration = Math.max(1, parseInt(durationMin, 10) || 5)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Vote className="h-5 w-5" />
|
||||
Audience Vote
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{session.allowAudienceVotes
|
||||
? 'Favorite-pick windows, one vote per phone per window'
|
||||
: 'Audience voting is disabled in session config'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<QrCode className="mr-2 h-4 w-4" />
|
||||
Show QR
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">Scan to vote</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<div className="rounded-3xl bg-white p-6 shadow-lg">
|
||||
{voteUrl && <QRCodeSVG value={voteUrl} size={420} />}
|
||||
</div>
|
||||
<p className="select-all break-all text-center text-sm text-muted-foreground">
|
||||
{voteUrl}
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isOpen ? (
|
||||
<div className="space-y-3 rounded-lg border border-[#de0f1e]/30 bg-[#de0f1e]/5 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge className="bg-[#de0f1e] hover:bg-[#de0f1e]">
|
||||
OPEN — {WINDOW_LABEL[openKey ?? ''] ?? openKey}
|
||||
</Badge>
|
||||
<span className="text-2xl font-bold tabular-nums">
|
||||
{formatClock(Math.max(0, secondsLeft ?? 0))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
<span className="font-semibold text-foreground">
|
||||
{currentWindow?.totalVotes ?? 0}
|
||||
</span>
|
||||
votes cast
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={() => closeWindow.mutate({ sessionId: session.id })}
|
||||
disabled={closeWindow.isPending}
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Close voting now
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Duration (min)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="120"
|
||||
value={durationMin}
|
||||
onChange={(e) => setDurationMin(e.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
<p className="pb-2 text-xs text-muted-foreground">
|
||||
Voting closes automatically — server-enforced
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={openWindow.isPending || !session.allowAudienceVotes}
|
||||
onClick={() =>
|
||||
openWindow.mutate({
|
||||
sessionId: session.id,
|
||||
windowKey: 'CATEGORY:BUSINESS_CONCEPT',
|
||||
durationMinutes: duration,
|
||||
})
|
||||
}
|
||||
>
|
||||
Open vote — Business Concepts
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={openWindow.isPending || !session.allowAudienceVotes}
|
||||
onClick={() =>
|
||||
openWindow.mutate({
|
||||
sessionId: session.id,
|
||||
windowKey: 'CATEGORY:STARTUP',
|
||||
durationMinutes: duration,
|
||||
})
|
||||
}
|
||||
>
|
||||
Open vote — Startups
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Overall favorite (across both categories)</p>
|
||||
<p className="text-xs text-muted-foreground">Decide day-of — off by default</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={!!session.allowOverallFavorite}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig.mutate({ sessionId: session.id, allowOverallFavorite: checked })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={
|
||||
openWindow.isPending || !session.allowOverallFavorite || !session.allowAudienceVotes
|
||||
}
|
||||
onClick={() =>
|
||||
openWindow.mutate({
|
||||
sessionId: session.id,
|
||||
windowKey: 'OVERALL',
|
||||
durationMinutes: duration,
|
||||
})
|
||||
}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!session.allowAudienceVotes && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
updateConfig.mutate({ sessionId: session.id, allowAudienceVotes: true })
|
||||
}
|
||||
disabled={updateConfig.isPending}
|
||||
>
|
||||
Enable audience voting for this session
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tallies — admin eyes only */}
|
||||
{tallies && tallies.windows.length > 0 && (
|
||||
<Collapsible open={talliesOpen} onOpenChange={setTalliesOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-between">
|
||||
Tallies (admin only)
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${talliesOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 pt-2">
|
||||
{tallies.windows.map((w) => (
|
||||
<div key={w.windowKey} className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">
|
||||
{WINDOW_LABEL[w.windowKey] ?? w.windowKey}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground">{w.totalVotes} votes</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{w.projects.map((p) => (
|
||||
<div key={p.projectId} className="flex items-center justify-between text-sm">
|
||||
<span className="truncate">{p.teamName ?? p.title}</span>
|
||||
<span className="font-semibold tabular-nums">{p.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,238 +1,159 @@
|
||||
'use client';
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
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 { ChevronLeft, ChevronRight, Play, Square, Pause, Timer } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
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 { PhaseControls } from './phase-controls'
|
||||
import { RunOrderList } from './run-order-list'
|
||||
import { AudienceWindowPanel } from './audience-window-panel'
|
||||
import { TimingLogCard } from './timing-log-card'
|
||||
import { RevealPanel } from './reveal-panel'
|
||||
import { Coffee, ExternalLink, Hand, MonitorPlay, PartyPopper, Play, Scale, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface LiveControlPanelProps {
|
||||
roundId: string;
|
||||
competitionId: string;
|
||||
roundId: string
|
||||
competitionId: string
|
||||
}
|
||||
|
||||
const OVERRIDE_SLIDES = [
|
||||
{ value: 'welcome', label: 'Welcome', icon: Hand },
|
||||
{ value: 'break', label: 'Break', icon: Coffee },
|
||||
{ value: 'deliberation', label: 'Deliberation', icon: Scale },
|
||||
{ value: 'thanks', label: 'Thank you', icon: PartyPopper },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Grand-finale ceremony console. Everything an admin touches during the
|
||||
* event lives here: run order, phase driver with real timers, audience vote
|
||||
* windows + QR, big-screen override slides, timing log, and the results
|
||||
* reveal stepper.
|
||||
*/
|
||||
export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const [timerSeconds, setTimerSeconds] = useState(300);
|
||||
const [isTimerRunning, setIsTimerRunning] = useState(false);
|
||||
|
||||
const { data: cursor } = trpc.live.getCursor.useQuery(
|
||||
const utils = trpc.useUtils()
|
||||
const { data: cursor, isLoading } = trpc.live.getCursor.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 5000 }
|
||||
);
|
||||
{ refetchInterval: 2000 }
|
||||
)
|
||||
const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: !cursor && !isLoading }
|
||||
)
|
||||
const [starting, setStarting] = useState(false)
|
||||
|
||||
const jumpMutation = trpc.live.jump.useMutation({
|
||||
const startMutation = trpc.live.start.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.live.getCursor.invalidate({ roundId });
|
||||
utils.live.getCursor.invalidate({ roundId })
|
||||
toast.success('Ceremony session started')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const pauseMutation = trpc.live.pause.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.live.getCursor.invalidate({ roundId });
|
||||
toast.success('Live session paused');
|
||||
},
|
||||
onSettled: () => setStarting(false),
|
||||
})
|
||||
const overrideMutation = trpc.live.setOverrideSlide.useMutation({
|
||||
onSuccess: () => utils.live.getCursor.invalidate({ roundId }),
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
})
|
||||
|
||||
const resumeMutation = trpc.live.resume.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.live.getCursor.invalidate({ roundId });
|
||||
toast.success('Live session resumed');
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTimerRunning) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setTimerSeconds((prev) => {
|
||||
if (prev <= 1) {
|
||||
setIsTimerRunning(false);
|
||||
return 0;
|
||||
const handleStart = () => {
|
||||
// Default run order: Business Concepts block first, then Startups
|
||||
const projects = (projectStates ?? [])
|
||||
.map((ps: any) => ps.project)
|
||||
.filter(Boolean)
|
||||
const order = [
|
||||
...projects.filter((p: any) => p.competitionCategory === 'BUSINESS_CONCEPT'),
|
||||
...projects.filter((p: any) => p.competitionCategory === 'STARTUP'),
|
||||
...projects.filter(
|
||||
(p: any) => p.competitionCategory !== 'BUSINESS_CONCEPT' && p.competitionCategory !== 'STARTUP'
|
||||
),
|
||||
].map((p: any) => p.id)
|
||||
if (order.length === 0) {
|
||||
toast.error('No projects in this round yet')
|
||||
return
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isTimerRunning]);
|
||||
|
||||
const currentIndex = cursor?.activeOrderIndex ?? 0;
|
||||
const totalProjects = cursor?.totalProjects ?? 0;
|
||||
const isNavigating = jumpMutation.isPending;
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentIndex <= 0) {
|
||||
toast.info('Already at the first project');
|
||||
return;
|
||||
setStarting(true)
|
||||
startMutation.mutate({ roundId, projectOrder: order })
|
||||
}
|
||||
jumpMutation.mutate({ roundId, index: currentIndex - 1 });
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentIndex >= totalProjects - 1) {
|
||||
toast.info('Already at the last project');
|
||||
return;
|
||||
}
|
||||
jumpMutation.mutate({ roundId, index: currentIndex + 1 });
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// ── Not started yet ───────────────────────────────────────────────────────
|
||||
if (!cursor) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Current Project Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Current Project</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{cursor && (
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{currentIndex + 1} / {totalProjects}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handlePrevious}
|
||||
disabled={isNavigating || currentIndex <= 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleNext}
|
||||
disabled={isNavigating || currentIndex >= totalProjects - 1}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{cursor?.activeProject ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold">{cursor.activeProject.title}</h3>
|
||||
{cursor.activeProject.teamName && (
|
||||
<p className="text-muted-foreground">{cursor.activeProject.teamName}</p>
|
||||
)}
|
||||
</div>
|
||||
{cursor.activeProject.tags && (cursor.activeProject.tags as string[]).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(cursor.activeProject.tags as string[]).map((tag: string) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{cursor ? 'No project selected' : 'No live session active for this round'}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Timer Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Timer className="h-5 w-5" />
|
||||
Timer
|
||||
<MonitorPlay className="h-5 w-5" />
|
||||
Ceremony Console
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Start the live session when the event begins — it creates the presentation cursor
|
||||
every screen follows. The set start time is indicative; nothing moves until you click.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-bold tabular-nums">{formatTime(timerSeconds)}</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
{!isTimerRunning ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => setIsTimerRunning(true)}
|
||||
disabled={timerSeconds === 0}
|
||||
>
|
||||
<CardContent className="space-y-3">
|
||||
<Button size="lg" className="w-full" onClick={handleStart} disabled={isLoading || starting}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Timer
|
||||
{starting ? 'Starting…' : 'Start ceremony session'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="flex-1" onClick={() => setIsTimerRunning(false)} variant="destructive">
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
Stop Timer
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Run order defaults to Business Concepts → Startups; reorder anytime after starting.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Big-screen quick bar */}
|
||||
<Card>
|
||||
<CardContent className="flex flex-wrap items-center gap-2 py-3">
|
||||
<span className="mr-1 text-sm font-medium">Big screen:</span>
|
||||
{OVERRIDE_SLIDES.map((slide) => {
|
||||
const active = cursor.overrideSlide === slide.value
|
||||
const SlideIcon = slide.icon
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTimerSeconds(300);
|
||||
setIsTimerRunning(false);
|
||||
}}
|
||||
key={slide.value}
|
||||
variant={active ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
overrideMutation.mutate({ roundId, slide: active ? null : slide.value })
|
||||
}
|
||||
disabled={overrideMutation.isPending}
|
||||
>
|
||||
Reset (5:00)
|
||||
<SlideIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{slide.label}
|
||||
{active && <X className="ml-1.5 h-3 w-3" />}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
{cursor.overrideSlide && (
|
||||
<Badge variant="destructive" className="ml-auto">
|
||||
Override active — live content hidden
|
||||
</Badge>
|
||||
)}
|
||||
<Button asChild variant="ghost" size="sm" className={cursor.overrideSlide ? '' : 'ml-auto'}>
|
||||
<Link href={`/live/ceremony/${roundId}`} target="_blank">
|
||||
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
||||
Open big screen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Session Controls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Session Controls</CardTitle>
|
||||
<CardDescription>Pause or resume the live presentation</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{cursor?.isPaused ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => resumeMutation.mutate({ roundId })}
|
||||
disabled={resumeMutation.isPending}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{resumeMutation.isPending ? 'Resuming...' : 'Resume Session'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
onClick={() => pauseMutation.mutate({ roundId })}
|
||||
disabled={pauseMutation.isPending || !cursor}
|
||||
>
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
{pauseMutation.isPending ? 'Pausing...' : 'Pause Session'}
|
||||
</Button>
|
||||
)}
|
||||
{cursor?.isPaused && (
|
||||
<Badge variant="destructive" className="w-full justify-center py-1">
|
||||
Session Paused
|
||||
</Badge>
|
||||
)}
|
||||
{cursor?.openCohorts && cursor.openCohorts.length > 0 && (
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-sm font-medium mb-2">Open Voting Windows</p>
|
||||
{cursor.openCohorts.map((cohort: any) => (
|
||||
<div key={cohort.id} className="flex items-center justify-between text-sm">
|
||||
<span>{cohort.name}</span>
|
||||
<Badge variant="outline">{cohort.votingMode}</Badge>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<PhaseControls roundId={roundId} />
|
||||
<RunOrderList roundId={roundId} />
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-4">
|
||||
<AudienceWindowPanel roundId={roundId} />
|
||||
<RevealPanel roundId={roundId} competitionId={competitionId} />
|
||||
<TimingLogCard roundId={roundId} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
222
src/components/admin/live/phase-controls.tsx
Normal file
222
src/components/admin/live/phase-controls.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
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 { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { remainingSeconds, formatClock, parseClock } from '@/lib/live-timer'
|
||||
import {
|
||||
Mic2,
|
||||
MessageCircleQuestion,
|
||||
PenLine,
|
||||
Pause,
|
||||
Play,
|
||||
SkipForward,
|
||||
MonitorUp,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const PHASE_LABEL: Record<string, string> = {
|
||||
ON_DECK: 'On deck',
|
||||
PRESENTING: 'Presenting',
|
||||
QA: 'Q&A',
|
||||
SCORING: 'Scoring',
|
||||
}
|
||||
|
||||
/**
|
||||
* The ceremony driver: one primary button for the next phase transition, a
|
||||
* server-derived countdown that goes red past zero, pause/resume, and
|
||||
* per-run duration overrides.
|
||||
*/
|
||||
export function PhaseControls({ roundId }: { roundId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId }, { refetchInterval: 2000 })
|
||||
const [presentationMin, setPresentationMin] = useState<string>('')
|
||||
const [qaMin, setQaMin] = useState<string>('')
|
||||
const [, tick] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => tick((t) => t + 1), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const invalidate = () => utils.live.getCursor.invalidate({ roundId })
|
||||
const onError = (err: { message: string }) => toast.error(err.message)
|
||||
const startPresentation = trpc.live.startPresentation.useMutation({ onSuccess: invalidate, onError })
|
||||
const startQA = trpc.live.startQA.useMutation({ onSuccess: invalidate, onError })
|
||||
const openScoring = trpc.live.openScoring.useMutation({ onSuccess: invalidate, onError })
|
||||
const sendToScreens = trpc.live.sendToScreens.useMutation({ onSuccess: invalidate, onError })
|
||||
const pausePhase = trpc.live.pausePhase.useMutation({ onSuccess: invalidate, onError })
|
||||
const resumePhase = trpc.live.resumePhase.useMutation({ onSuccess: invalidate, onError })
|
||||
|
||||
if (!cursor) {
|
||||
return null
|
||||
}
|
||||
|
||||
const phase = cursor.projectPhase
|
||||
const remaining = remainingSeconds(cursor)
|
||||
const over = remaining !== null && remaining < 0
|
||||
const paused = !!cursor.phasePausedAt
|
||||
const busy =
|
||||
startPresentation.isPending ||
|
||||
startQA.isPending ||
|
||||
openScoring.isPending ||
|
||||
sendToScreens.isPending
|
||||
|
||||
const durationSeconds = (raw: string) => parseClock(raw) ?? undefined
|
||||
|
||||
const nextProject = (() => {
|
||||
const order = cursor.orderedProjects ?? []
|
||||
const idx = order.findIndex((p) => p.id === cursor.activeProjectId)
|
||||
return idx >= 0 && idx < order.length - 1 ? order[idx + 1] : null
|
||||
})()
|
||||
|
||||
const primaryAction = (() => {
|
||||
switch (phase) {
|
||||
case 'ON_DECK':
|
||||
return {
|
||||
label: 'Start presentation',
|
||||
icon: Mic2,
|
||||
run: () =>
|
||||
startPresentation.mutate({
|
||||
roundId,
|
||||
durationSeconds: durationSeconds(presentationMin),
|
||||
}),
|
||||
disabled: !cursor.activeProjectId,
|
||||
}
|
||||
case 'PRESENTING':
|
||||
return {
|
||||
label: 'Start Q&A',
|
||||
icon: MessageCircleQuestion,
|
||||
run: () => startQA.mutate({ roundId, durationSeconds: durationSeconds(qaMin) }),
|
||||
disabled: false,
|
||||
}
|
||||
case 'QA':
|
||||
return {
|
||||
label: 'Open scoring',
|
||||
icon: PenLine,
|
||||
run: () => openScoring.mutate({ roundId }),
|
||||
disabled: false,
|
||||
}
|
||||
case 'SCORING':
|
||||
default:
|
||||
return nextProject
|
||||
? {
|
||||
label: `Send next: ${nextProject.teamName ?? nextProject.title}`,
|
||||
icon: MonitorUp,
|
||||
run: () => sendToScreens.mutate({ roundId, projectId: nextProject.id }),
|
||||
disabled: false,
|
||||
}
|
||||
: {
|
||||
label: 'End of run order',
|
||||
icon: SkipForward,
|
||||
run: () => undefined,
|
||||
disabled: true,
|
||||
}
|
||||
}
|
||||
})()
|
||||
const PrimaryIcon = primaryAction.icon
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Ceremony Control</CardTitle>
|
||||
<CardDescription>
|
||||
{cursor.activeProject
|
||||
? `${cursor.activeProject.title}${cursor.activeProject.teamName ? ` — ${cursor.activeProject.teamName}` : ''}`
|
||||
: 'No project on screens yet'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={phase === 'SCORING' ? 'default' : 'secondary'}>
|
||||
{PHASE_LABEL[phase] ?? phase}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{/* Server-derived countdown */}
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`text-6xl font-bold tabular-nums ${
|
||||
over ? 'animate-pulse text-[#de0f1e]' : remaining === null ? 'text-muted-foreground/40' : ''
|
||||
}`}
|
||||
>
|
||||
{remaining === null ? '–:––' : formatClock(remaining)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{remaining === null
|
||||
? 'No timer running'
|
||||
: over
|
||||
? `Over time${paused ? ' · paused' : ''} — noted, not penalized`
|
||||
: paused
|
||||
? 'Paused'
|
||||
: phase === 'PRESENTING'
|
||||
? 'Presentation time remaining'
|
||||
: 'Q&A time remaining'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Primary transition + pause */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="lg"
|
||||
onClick={primaryAction.run}
|
||||
disabled={primaryAction.disabled || busy}
|
||||
>
|
||||
<PrimaryIcon className="mr-2 h-4 w-4" />
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
{remaining !== null &&
|
||||
(paused ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => resumePhase.mutate({ roundId })}
|
||||
disabled={resumePhase.isPending}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Resume
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => pausePhase.mutate({ roundId })}
|
||||
disabled={pausePhase.isPending}
|
||||
>
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
Pause
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* One-off duration overrides for the NEXT start only (m:ss).
|
||||
Per-project durations live in the Run Order list. */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Presentation override (m:ss, next start only)</Label>
|
||||
<Input
|
||||
placeholder="e.g. 7:30"
|
||||
className="tabular-nums"
|
||||
value={presentationMin}
|
||||
onChange={(e) => setPresentationMin(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Q&A override (m:ss, next start only)</Label>
|
||||
<Input
|
||||
placeholder="e.g. 2:00"
|
||||
className="tabular-nums"
|
||||
value={qaMin}
|
||||
onChange={(e) => setQaMin(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
342
src/components/admin/live/reveal-panel.tsx
Normal file
342
src/components/admin/live/reveal-panel.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { ArrowDown, ArrowUp, PartyPopper, Play, RotateCcw, Sparkles, Trash2, Wand2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type RevealStep = {
|
||||
kind: 'category-intro' | 'place' | 'audience-award' | 'overall-favorite' | 'thanks'
|
||||
category?: 'STARTUP' | 'BUSINESS_CONCEPT'
|
||||
place?: number
|
||||
projectId?: string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
BUSINESS_CONCEPT: 'Business Concepts',
|
||||
STARTUP: 'Startups',
|
||||
}
|
||||
const PLACE_LABEL: Record<number, string> = { 1: 'Winner', 2: '2nd place', 3: '3rd place' }
|
||||
|
||||
function describeStep(step: RevealStep): string {
|
||||
switch (step.kind) {
|
||||
case 'category-intro':
|
||||
return `— ${CATEGORY_LABEL[step.category ?? ''] ?? 'Category'} —`
|
||||
case 'place':
|
||||
return `${PLACE_LABEL[step.place ?? 0] ?? `${step.place}th`} · ${step.title ?? '?'} (${CATEGORY_LABEL[step.category ?? ''] ?? ''})`
|
||||
case 'audience-award':
|
||||
return `Audience Choice (${CATEGORY_LABEL[step.category ?? ''] ?? ''}) · ${step.title ?? '?'}`
|
||||
case 'overall-favorite':
|
||||
return `Audience Favorite Overall · ${step.title ?? '?'}`
|
||||
case 'thanks':
|
||||
return 'Thank-you slide'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Results reveal builder + stepper. Compose privately from deliberation
|
||||
* results / jury scores / audience tallies, preview every step, then arm the
|
||||
* big screen and fire one step at a time. Nothing reaches the projector
|
||||
* before "Arm".
|
||||
*/
|
||||
export function RevealPanel({ roundId, competitionId }: { roundId: string; competitionId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: session } = trpc.liveVoting.getSession.useQuery({ roundId })
|
||||
const sessionId = session?.id ?? ''
|
||||
const { data: reveal } = trpc.liveVoting.getRevealAdmin.useQuery(
|
||||
{ sessionId },
|
||||
{ enabled: !!sessionId, refetchInterval: 3000 }
|
||||
)
|
||||
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId })
|
||||
const { data: results } = trpc.liveVoting.getResults.useQuery(
|
||||
{ sessionId },
|
||||
{ enabled: !!sessionId }
|
||||
)
|
||||
const { data: tallies } = trpc.liveVoting.getFavoriteTallies.useQuery(
|
||||
{ sessionId },
|
||||
{ enabled: !!sessionId }
|
||||
)
|
||||
const { data: delibSessions } = trpc.deliberation.listSessions.useQuery({ competitionId })
|
||||
|
||||
const [draftSteps, setDraftSteps] = useState<RevealStep[] | null>(null)
|
||||
|
||||
const invalidate = () => utils.liveVoting.getRevealAdmin.invalidate({ sessionId })
|
||||
const onError = (err: { message: string }) => toast.error(err.message)
|
||||
const saveReveal = trpc.liveVoting.saveReveal.useMutation({
|
||||
onSuccess: () => {
|
||||
setDraftSteps(null) // the saved copy is now canonical — unlocks Arm
|
||||
invalidate()
|
||||
toast.success('Reveal draft saved')
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const armReveal = trpc.liveVoting.armReveal.useMutation({ onSuccess: invalidate, onError })
|
||||
const revealNext = trpc.liveVoting.revealNext.useMutation({ onSuccess: invalidate, onError })
|
||||
const resetReveal = trpc.liveVoting.resetReveal.useMutation({ onSuccess: invalidate, onError })
|
||||
|
||||
if (!session) return null
|
||||
|
||||
const savedSteps = (reveal?.stepsJson as RevealStep[] | undefined) ?? []
|
||||
const steps = draftSteps ?? savedSteps
|
||||
const status = reveal?.status ?? 'DRAFT'
|
||||
const currentIndex = reveal?.currentStepIndex ?? -1
|
||||
|
||||
const categoryOf = (projectId: string) =>
|
||||
cursor?.orderedProjects?.find((p) => p.id === projectId)?.competitionCategory ?? null
|
||||
const displayName = (projectId: string) => {
|
||||
const p = cursor?.orderedProjects?.find((p) => p.id === projectId)
|
||||
return p?.teamName ?? p?.title ?? 'Unknown'
|
||||
}
|
||||
|
||||
const compose = () => {
|
||||
const composed: RevealStep[] = []
|
||||
const categories: Array<'BUSINESS_CONCEPT' | 'STARTUP'> = ['BUSINESS_CONCEPT', 'STARTUP']
|
||||
|
||||
let usedDeliberation = false
|
||||
for (const category of categories) {
|
||||
// Locked deliberation results take precedence; jury score order is the fallback
|
||||
const delib = (delibSessions ?? []).find(
|
||||
(s: any) => s.category === category && s.status === 'DELIB_LOCKED' && s.results?.length > 0
|
||||
)
|
||||
let rankedProjectIds: string[]
|
||||
if (delib) {
|
||||
rankedProjectIds = delib.results.map((r: any) => r.projectId)
|
||||
usedDeliberation = true
|
||||
} else {
|
||||
rankedProjectIds = (results?.results ?? [])
|
||||
.filter((r: any) => r.project?.id && categoryOf(r.project.id) === category)
|
||||
.map((r: any) => r.project.id)
|
||||
}
|
||||
|
||||
if (rankedProjectIds.length === 0) continue
|
||||
composed.push({
|
||||
kind: 'category-intro',
|
||||
category,
|
||||
title: CATEGORY_LABEL[category],
|
||||
})
|
||||
const top = rankedProjectIds.slice(0, 3)
|
||||
// Reverse order: 3rd → 2nd → 1st
|
||||
top
|
||||
.map((projectId, idx) => ({ projectId, place: idx + 1 }))
|
||||
.reverse()
|
||||
.forEach(({ projectId, place }) => {
|
||||
composed.push({
|
||||
kind: 'place',
|
||||
category,
|
||||
place,
|
||||
projectId,
|
||||
title: displayName(projectId),
|
||||
subtitle: `${PLACE_LABEL[place] ?? `${place}th place`} — ${CATEGORY_LABEL[category]}`,
|
||||
})
|
||||
})
|
||||
const audienceWindow = tallies?.windows.find((w) => w.windowKey === `CATEGORY:${category}`)
|
||||
const audienceTop = audienceWindow?.projects[0]
|
||||
if (audienceTop) {
|
||||
composed.push({
|
||||
kind: 'audience-award',
|
||||
category,
|
||||
projectId: audienceTop.projectId,
|
||||
title: audienceTop.teamName ?? audienceTop.title,
|
||||
subtitle: `Audience Choice — ${CATEGORY_LABEL[category]}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const overallWindow = tallies?.windows.find((w) => w.windowKey === 'OVERALL')
|
||||
const overallTop = overallWindow?.projects[0]
|
||||
if (overallTop) {
|
||||
composed.push({
|
||||
kind: 'overall-favorite',
|
||||
projectId: overallTop.projectId,
|
||||
title: overallTop.teamName ?? overallTop.title,
|
||||
subtitle: 'Audience Favorite — Overall',
|
||||
})
|
||||
}
|
||||
composed.push({ kind: 'thanks', title: 'Thank you' })
|
||||
|
||||
if (composed.length <= 1) {
|
||||
toast.info('No results to compose from yet — scores and votes are still empty')
|
||||
return
|
||||
}
|
||||
toast.success(
|
||||
usedDeliberation
|
||||
? 'Composed from locked deliberation results + audience tallies'
|
||||
: 'Composed from jury scores + audience tallies (no locked deliberation yet)'
|
||||
)
|
||||
setDraftSteps(composed)
|
||||
}
|
||||
|
||||
const moveStep = (index: number, delta: -1 | 1) => {
|
||||
const next = [...steps]
|
||||
const target = index + delta
|
||||
if (target < 0 || target >= next.length) return
|
||||
;[next[index], next[target]] = [next[target], next[index]]
|
||||
setDraftSteps(next)
|
||||
}
|
||||
const removeStep = (index: number) => {
|
||||
setDraftSteps(steps.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const isDraft = status === 'DRAFT'
|
||||
const isLive = status === 'REVEALING' || status === 'DONE'
|
||||
const nextStep = steps[currentIndex + 1]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<PartyPopper className="h-5 w-5" />
|
||||
Results Reveal
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Compose privately, arm the big screen, reveal step by step
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
variant={isDraft ? 'secondary' : 'default'}
|
||||
className={isLive ? 'bg-[#de0f1e] hover:bg-[#de0f1e]' : undefined}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isDraft && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={compose}>
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
Compose from results
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!draftSteps || saveReveal.isPending}
|
||||
onClick={() => sessionId && saveReveal.mutate({ sessionId, steps })}
|
||||
>
|
||||
Save draft
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Locked deliberation results take precedence; otherwise jury scores (top 3 per
|
||||
category, revealed 3rd → 1st), plus audience tallies. Adjust the steps below
|
||||
before saving if needed.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{steps.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{steps.map((step, i) => {
|
||||
const revealed = i <= currentIndex
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center gap-2 rounded-lg border p-2 text-sm ${
|
||||
revealed
|
||||
? 'border-green-600/30 bg-green-600/5'
|
||||
: i === currentIndex + 1 && !isDraft
|
||||
? 'border-[#de0f1e]/40 bg-[#de0f1e]/5'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span className="w-5 text-center text-xs tabular-nums text-muted-foreground">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{describeStep(step)}</span>
|
||||
{revealed && <Badge variant="outline" className="text-xs">revealed</Badge>}
|
||||
{isDraft && (
|
||||
<div className="flex shrink-0 gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={i === 0} onClick={() => moveStep(i, -1)}>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={i === steps.length - 1} onClick={() => moveStep(i, 1)}>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeStep(i)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDraft && savedSteps.length > 0 && !draftSteps && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button className="w-full" size="lg">
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Arm reveal
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Arm the results reveal?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The big screen switches to the Results splash immediately. Nothing is revealed
|
||||
until you press “Reveal next”.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => sessionId && armReveal.mutate({ sessionId })}>
|
||||
Arm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
{!isDraft && (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
className="w-full bg-[#de0f1e] hover:bg-[#c00d1a]"
|
||||
size="lg"
|
||||
disabled={revealNext.isPending || status === 'DONE'}
|
||||
onClick={() => sessionId && revealNext.mutate({ sessionId })}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{status === 'DONE'
|
||||
? 'All revealed'
|
||||
: `Reveal next (${currentIndex + 2}/${steps.length})`}
|
||||
</Button>
|
||||
{nextStep && status !== 'DONE' && (
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Next on screen: <span className="font-medium">{describeStep(nextStep)}</span>
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => sessionId && resetReveal.mutate({ sessionId })}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||
Reset to draft (leaves reveal mode)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
219
src/components/admin/live/run-order-list.tsx
Normal file
219
src/components/admin/live/run-order-list.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
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 { Input } from '@/components/ui/input'
|
||||
import { ArrowDown, ArrowUp, MonitorUp, Timer } from 'lucide-react'
|
||||
import { formatClock, parseClock } from '@/lib/live-timer'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
BUSINESS_CONCEPT: 'Business Concepts',
|
||||
STARTUP: 'Startups',
|
||||
}
|
||||
|
||||
/**
|
||||
* The ceremony run order, grouped by category, with quick reorder (▲▼) and a
|
||||
* "Send to screens" action per project — built for last-minute schedule
|
||||
* shuffles without leaving the console.
|
||||
*/
|
||||
export function RunOrderList({ roundId }: { roundId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId }, { refetchInterval: 5000 })
|
||||
// Local drafts for the per-project minute inputs (committed on blur)
|
||||
const [timingDrafts, setTimingDrafts] = useState<Record<string, string>>({})
|
||||
|
||||
const reorderMutation = trpc.live.reorder.useMutation({
|
||||
onSuccess: () => utils.live.getCursor.invalidate({ roundId }),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
const timingMutation = trpc.live.setProjectTiming.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.live.getCursor.invalidate({ roundId })
|
||||
toast.success('Project timing saved')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const commitTiming = (projectId: string, field: 'presentationSeconds' | 'qaSeconds', raw: string) => {
|
||||
const trimmed = raw.trim()
|
||||
const seconds = trimmed === '' ? null : parseClock(trimmed)
|
||||
if (trimmed !== '' && seconds === null) {
|
||||
toast.error('Use minutes:seconds, e.g. 7:30')
|
||||
return
|
||||
}
|
||||
const current = cursor?.projectTimingOverrides?.[projectId]?.[field] ?? null
|
||||
if (seconds === current) return
|
||||
timingMutation.mutate({ roundId, projectId, [field]: seconds })
|
||||
}
|
||||
const sendMutation = trpc.live.sendToScreens.useMutation({
|
||||
onSuccess: (_d, vars) => {
|
||||
utils.live.getCursor.invalidate({ roundId })
|
||||
const p = cursor?.orderedProjects?.find((p) => p.id === vars.projectId)
|
||||
toast.success(`${p?.teamName ?? p?.title ?? 'Project'} is now on screens (up next)`)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const projects = cursor?.orderedProjects ?? []
|
||||
if (!cursor || projects.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const move = (index: number, delta: -1 | 1) => {
|
||||
const order = projects.map((p) => p.id)
|
||||
const target = index + delta
|
||||
if (target < 0 || target >= order.length) return
|
||||
;[order[index], order[target]] = [order[target], order[index]]
|
||||
reorderMutation.mutate({ roundId, projectOrder: order })
|
||||
}
|
||||
|
||||
// Group rows under category headings while preserving the global order
|
||||
const rows: Array<{ type: 'heading'; label: string } | { type: 'project'; index: number }> = []
|
||||
let lastCategory: string | null = null
|
||||
projects.forEach((p, index) => {
|
||||
const cat = p.competitionCategory ?? 'OTHER'
|
||||
if (cat !== lastCategory) {
|
||||
rows.push({ type: 'heading', label: CATEGORY_LABEL[cat] ?? 'Other' })
|
||||
lastCategory = cat
|
||||
}
|
||||
rows.push({ type: 'project', index })
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Run Order</CardTitle>
|
||||
<CardDescription>
|
||||
Reorder presentations on the fly · “Send to screens” puts a team up next everywhere
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
{rows.map((row, i) => {
|
||||
if (row.type === 'heading') {
|
||||
return (
|
||||
<p
|
||||
key={`h-${i}`}
|
||||
className="pt-3 pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground first:pt-0"
|
||||
>
|
||||
{row.label}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
const project = projects[row.index]
|
||||
const isActive = project.id === cursor.activeProjectId
|
||||
const override = cursor.projectTimingOverrides?.[project.id]
|
||||
const presKey = `${project.id}:pres`
|
||||
const qaKey = `${project.id}:qa`
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
className={`flex items-center gap-2 rounded-lg border p-2.5 ${
|
||||
isActive ? 'border-[#de0f1e]/40 bg-[#de0f1e]/5' : 'border-transparent hover:bg-muted/40'
|
||||
}`}
|
||||
>
|
||||
<span className="w-6 text-center text-sm tabular-nums text-muted-foreground">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{project.title}</p>
|
||||
{project.teamName && (
|
||||
<p className="truncate text-xs text-muted-foreground">{project.teamName}</p>
|
||||
)}
|
||||
{/* Per-project durations (m:ss) — empty = round default */}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Timer className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<label className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
Pres
|
||||
<Input
|
||||
className="h-6 w-16 px-1.5 text-center text-xs tabular-nums"
|
||||
placeholder="default"
|
||||
value={
|
||||
timingDrafts[presKey] ??
|
||||
(override?.presentationSeconds != null
|
||||
? formatClock(override.presentationSeconds)
|
||||
: '')
|
||||
}
|
||||
onChange={(e) =>
|
||||
setTimingDrafts((d) => ({ ...d, [presKey]: e.target.value }))
|
||||
}
|
||||
onBlur={(e) => {
|
||||
commitTiming(project.id, 'presentationSeconds', e.target.value)
|
||||
setTimingDrafts((d) => {
|
||||
const next = { ...d }
|
||||
delete next[presKey]
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
Q&A
|
||||
<Input
|
||||
className="h-6 w-16 px-1.5 text-center text-xs tabular-nums"
|
||||
placeholder="default"
|
||||
value={
|
||||
timingDrafts[qaKey] ??
|
||||
(override?.qaSeconds != null ? formatClock(override.qaSeconds) : '')
|
||||
}
|
||||
onChange={(e) =>
|
||||
setTimingDrafts((d) => ({ ...d, [qaKey]: e.target.value }))
|
||||
}
|
||||
onBlur={(e) => {
|
||||
commitTiming(project.id, 'qaSeconds', e.target.value)
|
||||
setTimingDrafts((d) => {
|
||||
const next = { ...d }
|
||||
delete next[qaKey]
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground/70">m:ss</span>
|
||||
</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<Badge className="shrink-0 bg-[#de0f1e] hover:bg-[#de0f1e]">
|
||||
{cursor.projectPhase === 'ON_DECK' ? 'on deck' : 'live'}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={reorderMutation.isPending || row.index === 0}
|
||||
onClick={() => move(row.index, -1)}
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={reorderMutation.isPending || row.index === projects.length - 1}
|
||||
onClick={() => move(row.index, 1)}
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
disabled={sendMutation.isPending || isActive}
|
||||
onClick={() => sendMutation.mutate({ roundId, projectId: project.id })}
|
||||
>
|
||||
<MonitorUp className="h-3.5 w-3.5" />
|
||||
Send to screens
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
73
src/components/admin/live/timing-log-card.tsx
Normal file
73
src/components/admin/live/timing-log-card.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'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 { formatClock } from '@/lib/live-timer'
|
||||
import { Timer } from 'lucide-react'
|
||||
|
||||
type TimingEntry = {
|
||||
projectId: string
|
||||
phase: 'PRESENTING' | 'QA'
|
||||
startedAt: string
|
||||
endedAt: string
|
||||
configuredSeconds: number | null
|
||||
elapsedSeconds: number
|
||||
overranSeconds: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Factual per-project timing record: configured vs actual, with overruns
|
||||
* highlighted (noted, never penalized).
|
||||
*/
|
||||
export function TimingLogCard({ roundId }: { roundId: string }) {
|
||||
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId }, { refetchInterval: 5000 })
|
||||
|
||||
const log = (cursor?.timingLogJson as TimingEntry[] | null) ?? []
|
||||
if (!cursor || log.length === 0) return null
|
||||
|
||||
const titleFor = (projectId: string) => {
|
||||
const p = cursor.orderedProjects?.find((p) => p.id === projectId)
|
||||
return p?.teamName ?? p?.title ?? 'Unknown'
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Timer className="h-5 w-5" />
|
||||
Timing Log
|
||||
</CardTitle>
|
||||
<CardDescription>Configured vs actual — overruns are noted, not penalized</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1.5">
|
||||
{log.map((entry, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-lg border p-2.5 text-sm">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{titleFor(entry.projectId)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{entry.phase === 'PRESENTING' ? 'Presentation' : 'Q&A'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{entry.configuredSeconds != null ? formatClock(entry.configuredSeconds) : '–'} planned
|
||||
{' · '}
|
||||
{formatClock(entry.elapsedSeconds ?? 0)} actual
|
||||
</span>
|
||||
{entry.overranSeconds > 0 ? (
|
||||
<Badge variant="destructive" className="shrink-0 tabular-nums">
|
||||
+{formatClock(entry.overranSeconds).replace('+', '')} over
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
on time
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
@@ -14,6 +15,19 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { formatEnumLabel } from '@/lib/utils'
|
||||
import type { FinalistConfirmationStatus } from '@prisma/client'
|
||||
import { AdminAttendanceDialog, type AttendanceMode } from './admin-attendance-dialog'
|
||||
@@ -52,17 +66,52 @@ function relativeFromNow(d: Date): string {
|
||||
}
|
||||
|
||||
export function ConfirmationsTab({ programId }: Props) {
|
||||
const utils = trpc.useUtils()
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
const [dialogState, setDialogState] = useState<{
|
||||
open: boolean
|
||||
mode: AttendanceMode
|
||||
confirmationId: string | null
|
||||
}>({ open: false, mode: 'confirm', confirmationId: null })
|
||||
const [unconfirmState, setUnconfirmState] = useState<{
|
||||
open: boolean
|
||||
confirmationId: string | null
|
||||
projectTitle: string
|
||||
reason: string
|
||||
}>({ open: false, confirmationId: null, projectTitle: '', reason: '' })
|
||||
|
||||
const { data, isLoading } = trpc.logistics.listConfirmations.useQuery(
|
||||
{ programId },
|
||||
{ refetchInterval: 60_000 },
|
||||
)
|
||||
|
||||
// Get liveFinalRoundId for re-invite action
|
||||
const { data: candidatesData } = trpc.finalist.listEnrollmentCandidates.useQuery({ programId })
|
||||
const liveFinalRoundId = candidatesData?.liveFinalRoundId ?? null
|
||||
|
||||
const unconfirmMutation = trpc.finalist.unconfirm.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Finalist un-confirmed')
|
||||
utils.logistics.listConfirmations.invalidate({ programId })
|
||||
utils.finalist.listEnrollmentCandidates.invalidate({ programId })
|
||||
setUnconfirmState((prev) => ({ ...prev, open: false, confirmationId: null, reason: '' }))
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const reinviteMutation = trpc.finalist.enrollFinalists.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.skipped.length > 0) {
|
||||
toast.info('Re-invite skipped — team is already confirmed')
|
||||
} else {
|
||||
toast.success('Re-invite sent')
|
||||
}
|
||||
utils.logistics.listConfirmations.invalidate({ programId })
|
||||
utils.finalist.listEnrollmentCandidates.invalidate({ programId })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data) return []
|
||||
return statusFilter === 'all' ? data : data.filter((r) => r.status === statusFilter)
|
||||
@@ -124,7 +173,7 @@ export function ConfirmationsTab({ programId }: Props) {
|
||||
) : 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 finalists have been selected yet. Enroll finalists from the Grand Final round\'s Overview tab to start confirmations.'
|
||||
: 'No confirmations match this filter.'}
|
||||
</p>
|
||||
) : (
|
||||
@@ -218,6 +267,43 @@ export function ConfirmationsTab({ programId }: Props) {
|
||||
Decline
|
||||
</Button>
|
||||
</div>
|
||||
) : r.status === 'CONFIRMED' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setUnconfirmState({
|
||||
open: true,
|
||||
confirmationId: r.id,
|
||||
projectTitle: r.project.title,
|
||||
reason: '',
|
||||
})
|
||||
}
|
||||
>
|
||||
Un-confirm
|
||||
</Button>
|
||||
) : r.status === 'DECLINED' || r.status === 'EXPIRED' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!liveFinalRoundId || reinviteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!liveFinalRoundId) return
|
||||
reinviteMutation.mutate({
|
||||
programId,
|
||||
roundId: liveFinalRoundId,
|
||||
enrollments: [{ projectId: r.project.id, mode: 'EMAIL' }],
|
||||
})
|
||||
}}
|
||||
>
|
||||
{reinviteMutation.isPending &&
|
||||
reinviteMutation.variables?.enrollments?.[0]?.projectId ===
|
||||
r.project.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Re-invite'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
@@ -241,6 +327,60 @@ export function ConfirmationsTab({ programId }: Props) {
|
||||
setDialogState((prev) => ({ ...prev, open: next }))
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Un-confirm AlertDialog (needs a reason — min 5 chars per the server) */}
|
||||
<AlertDialog
|
||||
open={unconfirmState.open}
|
||||
onOpenChange={(next) => {
|
||||
if (!unconfirmMutation.isPending)
|
||||
setUnconfirmState((prev) => ({ ...prev, open: next }))
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Un-confirm this finalist?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{unconfirmState.projectTitle} will be moved back to Superseded. Any active mentor
|
||||
assignment will be dropped and the mentor notified. This action is audit-logged.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="px-1 pb-2">
|
||||
<label
|
||||
htmlFor="unconfirm-reason"
|
||||
className="text-muted-foreground mb-1 block text-sm"
|
||||
>
|
||||
Reason <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
id="unconfirm-reason"
|
||||
value={unconfirmState.reason}
|
||||
onChange={(e) =>
|
||||
setUnconfirmState((prev) => ({ ...prev, reason: e.target.value }))
|
||||
}
|
||||
placeholder="e.g. team withdrew, scheduling conflict, administrative correction"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={unconfirmMutation.isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={unconfirmState.reason.trim().length < 5 || unconfirmMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!unconfirmState.confirmationId) return
|
||||
unconfirmMutation.mutate({
|
||||
confirmationId: unconfirmState.confirmationId,
|
||||
reason: unconfirmState.reason.trim(),
|
||||
})
|
||||
}}
|
||||
>
|
||||
{unconfirmMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Un-confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
222
src/components/admin/logistics/email-templates-tab.tsx
Normal file
222
src/components/admin/logistics/email-templates-tab.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { toast } from 'sonner'
|
||||
import { Plane, Mail, Eye, Loader2 } from 'lucide-react'
|
||||
import { EmailPreviewDialog } from '@/components/admin/round/email-preview-dialog'
|
||||
|
||||
export function EmailTemplatesTab({ programId }: { programId?: string }) {
|
||||
const [previewType, setPreviewType] = useState<string | null>(null)
|
||||
const [testingType, setTestingType] = useState<string | null>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: allSettings, isLoading } = trpc.notification.getEmailSettings.useQuery()
|
||||
|
||||
const settings = (allSettings ?? []).filter((s) => s.category === 'logistics')
|
||||
|
||||
const updateMutation = trpc.notification.updateEmailSetting.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Setting updated')
|
||||
void utils.notification.getEmailSettings.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to update: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const testMutation = trpc.notification.sendTestEmail.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
toast.success(data.message, {
|
||||
description: data.hasStyledTemplate ? 'Using styled template' : 'Using generic template',
|
||||
})
|
||||
} else {
|
||||
toast.error('Failed to send test email', { description: data.message })
|
||||
}
|
||||
setTestingType(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to send: ${error.message}`)
|
||||
setTestingType(null)
|
||||
},
|
||||
})
|
||||
|
||||
const preview = trpc.notification.previewEmailTemplate.useQuery(
|
||||
{ notificationType: previewType! },
|
||||
{ enabled: !!previewType },
|
||||
)
|
||||
|
||||
const previewSetting = settings.find((s) => s.notificationType === previewType)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (settings.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
No logistics email types found — run the notification settings seed.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-3 text-base">
|
||||
<Plane className="h-5 w-5 text-muted-foreground" />
|
||||
Logistics Emails
|
||||
<span className="ml-auto text-xs font-normal text-muted-foreground">
|
||||
{settings.filter((s) => s.sendEmail).length}/{settings.length} enabled
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{settings.map((setting) => (
|
||||
<EmailTemplateRow
|
||||
key={setting.id}
|
||||
setting={setting}
|
||||
isTesting={testingType === setting.notificationType}
|
||||
isUpdating={updateMutation.isPending}
|
||||
onTest={() => {
|
||||
setTestingType(setting.notificationType)
|
||||
testMutation.mutate({ notificationType: setting.notificationType })
|
||||
}}
|
||||
onPreview={() => setPreviewType(setting.notificationType)}
|
||||
onToggle={(checked) =>
|
||||
updateMutation.mutate({
|
||||
notificationType: setting.notificationType,
|
||||
sendEmail: checked,
|
||||
emailSubject: setting.emailSubject ?? undefined,
|
||||
})
|
||||
}
|
||||
onSubjectBlur={(subject) => {
|
||||
if (subject !== (setting.emailSubject ?? '')) {
|
||||
updateMutation.mutate({
|
||||
notificationType: setting.notificationType,
|
||||
sendEmail: setting.sendEmail,
|
||||
emailSubject: subject || undefined,
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<EmailPreviewDialog
|
||||
open={!!previewType}
|
||||
onOpenChange={(o) => { if (!o) setPreviewType(null) }}
|
||||
title={previewSetting?.label ?? 'Email Preview'}
|
||||
description={previewSetting?.description ?? ''}
|
||||
recipientCount={0}
|
||||
previewHtml={preview.data?.html}
|
||||
isPreviewLoading={preview.isLoading}
|
||||
onSend={() => {}}
|
||||
isSending={false}
|
||||
previewOnly
|
||||
showCustomMessage={false}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type RowSetting = {
|
||||
id: string
|
||||
notificationType: string
|
||||
category: string
|
||||
label: string
|
||||
description: string | null
|
||||
sendEmail: boolean
|
||||
emailSubject: string | null
|
||||
}
|
||||
|
||||
function EmailTemplateRow({
|
||||
setting,
|
||||
isTesting,
|
||||
isUpdating,
|
||||
onTest,
|
||||
onPreview,
|
||||
onToggle,
|
||||
onSubjectBlur,
|
||||
}: {
|
||||
setting: RowSetting
|
||||
isTesting: boolean
|
||||
isUpdating: boolean
|
||||
onTest: () => void
|
||||
onPreview: () => void
|
||||
onToggle: (checked: boolean) => void
|
||||
onSubjectBlur: (value: string) => void
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-0.5 flex-1 min-w-0">
|
||||
<Label className="text-sm font-medium">{setting.label}</Label>
|
||||
{setting.description && (
|
||||
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={onPreview}
|
||||
title="Preview email"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="ml-1.5 text-xs">Preview</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={onTest}
|
||||
disabled={isTesting}
|
||||
title="Send test email to yourself"
|
||||
>
|
||||
{isTesting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-1.5 text-xs">Test</span>
|
||||
</Button>
|
||||
<Switch
|
||||
checked={setting.sendEmail}
|
||||
onCheckedChange={onToggle}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground w-16 shrink-0">Subject</Label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="h-7 text-xs"
|
||||
defaultValue={setting.emailSubject ?? ''}
|
||||
placeholder="(default subject)"
|
||||
onBlur={(e) => onSubjectBlur(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,83 +1,203 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
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 { ExternalLink, Hotel as HotelIcon, Loader2, Save } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Download, ExternalLink, Hotel as HotelIcon, Loader2, Plus, Trash2, Pencil } from 'lucide-react'
|
||||
|
||||
// Radix <SelectItem> forbids an empty-string value, so the "unassigned" option
|
||||
// uses this sentinel; handleHotelChange maps it back to an unassign.
|
||||
const UNASSIGN_VALUE = '__unassign__'
|
||||
|
||||
interface Props {
|
||||
programId: string
|
||||
}
|
||||
|
||||
export function HotelsTab({ programId }: Props) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: hotel, isLoading } = trpc.logistics.getHotel.useQuery({ programId })
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type HotelRow = {
|
||||
id: string
|
||||
name: string
|
||||
address: string | null
|
||||
link: string | null
|
||||
notes: string | null
|
||||
_count: { stays: number }
|
||||
}
|
||||
|
||||
type RoomingRow = {
|
||||
attendingMemberId: string
|
||||
confirmationId: string
|
||||
projectId: string
|
||||
projectTitle: string
|
||||
user: { id: string; name: string | null; email: string }
|
||||
stay: { hotelId: string; roomNumber: string | null; checkInAt: Date | null; checkOutAt: Date | null } | null
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function toDateInputValue(d: Date | null | undefined): string {
|
||||
if (!d) return ''
|
||||
const dt = new Date(d)
|
||||
if (Number.isNaN(dt.getTime())) return ''
|
||||
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
|
||||
}
|
||||
|
||||
function csvEscape(value: string | null | undefined): string {
|
||||
const str = value ?? ''
|
||||
if (str.includes('"') || str.includes(',') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
function buildRoomingCsv(rows: RoomingRow[], hotels: HotelRow[]): string {
|
||||
const hotelMap = new Map(hotels.map((h) => [h.id, h.name]))
|
||||
const header = ['Team', 'Member', 'Email', 'Hotel', 'Room', 'Check-in', 'Check-out'].join(',')
|
||||
const lines = rows.map((r) => {
|
||||
const s = r.stay
|
||||
return [
|
||||
csvEscape(r.projectTitle),
|
||||
csvEscape(r.user.name ?? r.user.email),
|
||||
csvEscape(r.user.email),
|
||||
csvEscape(s ? (hotelMap.get(s.hotelId) ?? '') : ''),
|
||||
csvEscape(s?.roomNumber ?? ''),
|
||||
csvEscape(s?.checkInAt ? toDateInputValue(s.checkInAt) : ''),
|
||||
csvEscape(s?.checkOutAt ? toDateInputValue(s.checkOutAt) : ''),
|
||||
].join(',')
|
||||
})
|
||||
return [header, ...lines].join('\r\n')
|
||||
}
|
||||
|
||||
// ─── Hotel Form Dialog ────────────────────────────────────────────────────────
|
||||
|
||||
type HotelFormMode = { type: 'create' } | { type: 'edit'; hotel: HotelRow }
|
||||
|
||||
function HotelFormDialog({
|
||||
open,
|
||||
mode,
|
||||
programId,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
mode: HotelFormMode
|
||||
programId: string
|
||||
onOpenChange: (next: boolean) => void
|
||||
}) {
|
||||
const utils = trpc.useUtils()
|
||||
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 ?? '')
|
||||
if (!open) return
|
||||
if (mode.type === 'edit') {
|
||||
setName(mode.hotel.name)
|
||||
setAddress(mode.hotel.address ?? '')
|
||||
setLink(mode.hotel.link ?? '')
|
||||
setNotes(mode.hotel.notes ?? '')
|
||||
} else {
|
||||
setName('')
|
||||
setAddress('')
|
||||
setLink('')
|
||||
setNotes('')
|
||||
}
|
||||
}, [hotel])
|
||||
}, [open, mode])
|
||||
|
||||
const upsertMutation = trpc.logistics.upsertHotel.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Hotel saved')
|
||||
utils.logistics.getHotel.invalidate({ programId })
|
||||
},
|
||||
const onSuccess = () => {
|
||||
toast.success(mode.type === 'create' ? 'Hotel added' : 'Hotel updated')
|
||||
utils.logistics.listHotels.invalidate({ programId })
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const createMutation = trpc.logistics.createHotel.useMutation({
|
||||
onSuccess,
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateMutation = trpc.logistics.updateHotel.useMutation({
|
||||
onSuccess,
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) {
|
||||
toast.error('Hotel name is required')
|
||||
return
|
||||
}
|
||||
upsertMutation.mutate({
|
||||
if (mode.type === 'create') {
|
||||
createMutation.mutate({
|
||||
programId,
|
||||
name: name.trim(),
|
||||
address: address.trim() || undefined,
|
||||
link: link.trim() || '',
|
||||
link: link.trim() || undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
})
|
||||
} else {
|
||||
updateMutation.mutate({
|
||||
id: mode.hotel.id,
|
||||
name: name.trim(),
|
||||
address: address.trim() || null,
|
||||
link: link.trim() || null,
|
||||
notes: notes.trim() || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<Dialog open={open} onOpenChange={(next) => { if (!isPending) onOpenChange(next) }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode.type === 'create' ? 'Add hotel' : 'Edit hotel'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode.type === 'create'
|
||||
? 'Add a hotel that finalists can be assigned to.'
|
||||
: 'Update hotel details.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hotel-name">Name *</Label>
|
||||
<Input
|
||||
@@ -99,7 +219,7 @@ export function HotelsTab({ programId }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hotel-link">Hotel website / booking link</Label>
|
||||
<Label htmlFor="hotel-link">Website / booking link</Label>
|
||||
<Input
|
||||
id="hotel-link"
|
||||
type="url"
|
||||
@@ -118,58 +238,463 @@ export function HotelsTab({ programId }: Props) {
|
||||
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">
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{mode.type === 'create' ? 'Add hotel' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Hotels Section ───────────────────────────────────────────────────────────
|
||||
|
||||
function HotelsSection({ programId }: { programId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: hotels, isLoading } = trpc.logistics.listHotels.useQuery({ programId })
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [dialogMode, setDialogMode] = useState<HotelFormMode>({ type: 'create' })
|
||||
|
||||
const deleteMutation = trpc.logistics.deleteHotel.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Hotel removed')
|
||||
utils.logistics.listHotels.invalidate({ programId })
|
||||
utils.logistics.listRooming.invalidate({ programId })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const openCreate = () => {
|
||||
setDialogMode({ type: 'create' })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (hotel: HotelRow) => {
|
||||
setDialogMode({ type: 'edit', hotel })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Email preview</CardTitle>
|
||||
<CardDescription>What teams will see in confirmation emails.</CardDescription>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<HotelIcon className="text-muted-foreground h-4 w-4" />
|
||||
<CardTitle className="text-base">Hotels</CardTitle>
|
||||
</div>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add hotel
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!name.trim() ? (
|
||||
<p className="text-muted-foreground text-sm">Save a hotel to see the preview.</p>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !hotels || hotels.length === 0 ? (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
No hotels yet. Add one above.
|
||||
</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 className="space-y-3">
|
||||
{hotels.map((hotel) => (
|
||||
<div
|
||||
key={hotel.id}
|
||||
className="flex items-start justify-between gap-4 rounded-md border p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{hotel.name}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{hotel._count.stays} guest{hotel._count.stays !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
{hotel.address && (
|
||||
<p className="text-muted-foreground text-xs whitespace-pre-line">{hotel.address}</p>
|
||||
)}
|
||||
{link.trim() && (
|
||||
{hotel.link && (
|
||||
<a
|
||||
href={link}
|
||||
href={hotel.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
|
||||
className="text-primary inline-flex items-center gap-1 text-xs hover:underline"
|
||||
>
|
||||
Visit hotel website <ExternalLink className="h-3 w-3" />
|
||||
Visit website <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
{hotel.notes && (
|
||||
<p className="text-muted-foreground text-xs italic">{hotel.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => openEdit(hotel)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive h-8 w-8"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete hotel?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently remove <strong>{hotel.name}</strong>.
|
||||
{hotel._count.stays > 0
|
||||
? ` Reassign the ${hotel._count.stays} guest(s) before deleting.`
|
||||
: ' This action cannot be undone.'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => deleteMutation.mutate({ id: hotel.id })}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<HotelFormDialog
|
||||
open={dialogOpen}
|
||||
mode={dialogMode}
|
||||
programId={programId}
|
||||
onOpenChange={setDialogOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Attendee Row ─────────────────────────────────────────────────────────────
|
||||
|
||||
function AttendeeRoomRow({
|
||||
row,
|
||||
hotels,
|
||||
programId,
|
||||
}: {
|
||||
row: RoomingRow
|
||||
hotels: HotelRow[]
|
||||
programId: string
|
||||
}) {
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const [roomNumber, setRoomNumber] = useState(row.stay?.roomNumber ?? '')
|
||||
const [checkIn, setCheckIn] = useState(toDateInputValue(row.stay?.checkInAt ?? null))
|
||||
const [checkOut, setCheckOut] = useState(toDateInputValue(row.stay?.checkOutAt ?? null))
|
||||
|
||||
// Keep local state in sync when server data updates
|
||||
const prevStayRef = useRef(row.stay)
|
||||
useEffect(() => {
|
||||
const prev = prevStayRef.current
|
||||
const cur = row.stay
|
||||
// Only sync if the stay changed from outside (different hotelId or null/non-null)
|
||||
if (prev?.hotelId !== cur?.hotelId || (prev === null) !== (cur === null)) {
|
||||
setRoomNumber(cur?.roomNumber ?? '')
|
||||
setCheckIn(toDateInputValue(cur?.checkInAt ?? null))
|
||||
setCheckOut(toDateInputValue(cur?.checkOutAt ?? null))
|
||||
}
|
||||
prevStayRef.current = cur
|
||||
}, [row.stay])
|
||||
|
||||
const assignMutation = trpc.logistics.assignStay.useMutation({
|
||||
onSuccess: () => utils.logistics.listRooming.invalidate({ programId }),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const unassignMutation = trpc.logistics.unassignStay.useMutation({
|
||||
onSuccess: () => utils.logistics.listRooming.invalidate({ programId }),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const currentHotelId = row.stay?.hotelId ?? ''
|
||||
|
||||
const handleHotelChange = (value: string) => {
|
||||
if (!value || value === UNASSIGN_VALUE) {
|
||||
unassignMutation.mutate({ attendingMemberId: row.attendingMemberId })
|
||||
} else {
|
||||
assignMutation.mutate({
|
||||
attendingMemberId: row.attendingMemberId,
|
||||
hotelId: value,
|
||||
roomNumber: roomNumber.trim() || null,
|
||||
checkInAt: fromDateInputValue(checkIn),
|
||||
checkOutAt: fromDateInputValue(checkOut),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const commitRoomNumber = () => {
|
||||
if (!currentHotelId) return
|
||||
const trimmed = roomNumber.trim()
|
||||
if (trimmed === (row.stay?.roomNumber ?? '')) return
|
||||
assignMutation.mutate({
|
||||
attendingMemberId: row.attendingMemberId,
|
||||
hotelId: currentHotelId,
|
||||
roomNumber: trimmed || null,
|
||||
checkInAt: fromDateInputValue(checkIn),
|
||||
checkOutAt: fromDateInputValue(checkOut),
|
||||
})
|
||||
}
|
||||
|
||||
const commitCheckIn = () => {
|
||||
if (!currentHotelId) return
|
||||
if (checkIn === toDateInputValue(row.stay?.checkInAt ?? null)) return
|
||||
assignMutation.mutate({
|
||||
attendingMemberId: row.attendingMemberId,
|
||||
hotelId: currentHotelId,
|
||||
roomNumber: roomNumber.trim() || null,
|
||||
checkInAt: fromDateInputValue(checkIn),
|
||||
checkOutAt: fromDateInputValue(checkOut),
|
||||
})
|
||||
}
|
||||
|
||||
const commitCheckOut = () => {
|
||||
if (!currentHotelId) return
|
||||
if (checkOut === toDateInputValue(row.stay?.checkOutAt ?? null)) return
|
||||
assignMutation.mutate({
|
||||
attendingMemberId: row.attendingMemberId,
|
||||
hotelId: currentHotelId,
|
||||
roomNumber: roomNumber.trim() || null,
|
||||
checkInAt: fromDateInputValue(checkIn),
|
||||
checkOutAt: fromDateInputValue(checkOut),
|
||||
})
|
||||
}
|
||||
|
||||
const isBusy = assignMutation.isPending || unassignMutation.isPending
|
||||
const hasHotel = !!currentHotelId
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-2 py-2 pl-4 sm:grid-cols-[2fr_2fr_1fr_1fr_1fr]">
|
||||
{/* Member */}
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<div className="text-sm font-medium">{row.user.name ?? row.user.email}</div>
|
||||
<div className="text-muted-foreground text-xs">{row.user.email}</div>
|
||||
</div>
|
||||
|
||||
{/* Hotel select */}
|
||||
<Select value={currentHotelId} onValueChange={handleHotelChange} disabled={isBusy}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="— Unassigned —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNASSIGN_VALUE}>— Unassigned —</SelectItem>
|
||||
{hotels.map((h) => (
|
||||
<SelectItem key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Room # */}
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
placeholder="Room #"
|
||||
value={roomNumber}
|
||||
onChange={(e) => setRoomNumber(e.target.value)}
|
||||
onBlur={commitRoomNumber}
|
||||
disabled={!hasHotel || isBusy}
|
||||
/>
|
||||
|
||||
{/* Check-in */}
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
type="date"
|
||||
value={checkIn}
|
||||
onChange={(e) => setCheckIn(e.target.value)}
|
||||
onBlur={commitCheckIn}
|
||||
disabled={!hasHotel || isBusy}
|
||||
/>
|
||||
|
||||
{/* Check-out */}
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
type="date"
|
||||
value={checkOut}
|
||||
onChange={(e) => setCheckOut(e.target.value)}
|
||||
onBlur={commitCheckOut}
|
||||
disabled={!hasHotel || isBusy}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Rooming Section ──────────────────────────────────────────────────────────
|
||||
|
||||
function RoomingSection({ programId }: { programId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: rooming, isLoading: roomingLoading } = trpc.logistics.listRooming.useQuery({ programId })
|
||||
const { data: hotels } = trpc.logistics.listHotels.useQuery({ programId })
|
||||
|
||||
const assignTeamMutation = trpc.logistics.assignTeamToHotel.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Team assigned')
|
||||
utils.logistics.listRooming.invalidate({ programId })
|
||||
utils.logistics.listHotels.invalidate({ programId })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Group rows by projectTitle
|
||||
const grouped = useMemo(() => {
|
||||
if (!rooming) return []
|
||||
const map = new Map<string, { confirmationId: string; projectTitle: string; rows: RoomingRow[] }>()
|
||||
for (const row of rooming) {
|
||||
if (!map.has(row.projectId)) {
|
||||
map.set(row.projectId, {
|
||||
confirmationId: row.confirmationId,
|
||||
projectTitle: row.projectTitle,
|
||||
rows: [],
|
||||
})
|
||||
}
|
||||
map.get(row.projectId)!.rows.push(row)
|
||||
}
|
||||
return Array.from(map.values())
|
||||
}, [rooming])
|
||||
|
||||
const downloadCsv = () => {
|
||||
if (!rooming || !hotels) return
|
||||
const csv = buildRoomingCsv(rooming, hotels)
|
||||
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 = 'rooming-manifest.csv'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<CardTitle className="text-base">Rooming</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!rooming || rooming.length === 0}
|
||||
onClick={downloadCsv}
|
||||
>
|
||||
<Download className="mr-1 h-4 w-4" /> Download CSV
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{roomingLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : grouped.length === 0 ? (
|
||||
<p className="text-muted-foreground py-12 text-center text-sm">
|
||||
No confirmed attendees yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{grouped.map((group) => (
|
||||
<div key={group.confirmationId}>
|
||||
{/* Team header */}
|
||||
<div className="bg-muted/40 flex items-center justify-between gap-4 rounded-t-md border px-3 py-2">
|
||||
<span className="text-sm font-semibold">{group.projectTitle}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs shrink-0">Assign whole team to</span>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(hotelId) => {
|
||||
if (!hotelId) return
|
||||
assignTeamMutation.mutate({
|
||||
confirmationId: group.confirmationId,
|
||||
hotelId,
|
||||
})
|
||||
}}
|
||||
disabled={assignTeamMutation.isPending || !hotels || hotels.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-40 text-xs">
|
||||
<SelectValue placeholder="Select hotel…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(hotels ?? []).map((h) => (
|
||||
<SelectItem key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column headers */}
|
||||
<div className="hidden grid-cols-[2fr_2fr_1fr_1fr_1fr] gap-2 border-x border-b bg-white px-4 py-1 sm:grid">
|
||||
<span className="text-muted-foreground text-xs font-medium">Member</span>
|
||||
<span className="text-muted-foreground text-xs font-medium">Hotel</span>
|
||||
<span className="text-muted-foreground text-xs font-medium">Room #</span>
|
||||
<span className="text-muted-foreground text-xs font-medium">Check-in</span>
|
||||
<span className="text-muted-foreground text-xs font-medium">Check-out</span>
|
||||
</div>
|
||||
|
||||
{/* Attendee rows */}
|
||||
<div className="divide-y rounded-b-md border-x border-b">
|
||||
{group.rows.map((row) => (
|
||||
<AttendeeRoomRow
|
||||
key={row.attendingMemberId}
|
||||
row={row}
|
||||
hotels={hotels ?? []}
|
||||
programId={programId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main export ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function HotelsTab({ programId }: Props) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<HotelsSection programId={programId} />
|
||||
<RoomingSection programId={programId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Plus, Pencil, Trash2, Mail, MailCheck, Utensils } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const ALLERGENS = [
|
||||
@@ -90,6 +91,13 @@ export const LunchExternals = forwardRef<
|
||||
onSuccess: invalidateAll,
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
const sendInvite = trpc.lunch.sendExternalInvite.useMutation({
|
||||
onSuccess: () => {
|
||||
invalidateAll()
|
||||
toast.success('Dish invite sent')
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const editingRow =
|
||||
editing?.mode === 'edit'
|
||||
@@ -123,7 +131,40 @@ export const LunchExternals = forwardRef<
|
||||
{e.project?.title ?? 'Standalone'}
|
||||
</td>
|
||||
<td className="text-muted-foreground">{e.roleNote ?? ''}</td>
|
||||
<td>
|
||||
{e.dishId ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Utensils className="h-3 w-3" /> Picked
|
||||
</Badge>
|
||||
) : !e.email ? (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
No email
|
||||
</Badge>
|
||||
) : e.inviteSentAt ? (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<MailCheck className="h-3 w-3" /> Invited
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
Not invited
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-right">
|
||||
{e.email && !e.dishId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
title={e.inviteSentAt ? 'Resend dish invite' : 'Send dish invite'}
|
||||
disabled={
|
||||
sendInvite.isPending &&
|
||||
sendInvite.variables?.externalId === e.id
|
||||
}
|
||||
onClick={() => sendInvite.mutate({ externalId: e.id })}
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -11,15 +11,28 @@ import {
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Send, Eye } from 'lucide-react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Send, Eye, Bell } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function LunchRecapActions({
|
||||
programId,
|
||||
lunchEventId,
|
||||
recapSentAt,
|
||||
extraRecipientCount,
|
||||
}: {
|
||||
programId: string
|
||||
lunchEventId: string
|
||||
recapSentAt: Date | null
|
||||
extraRecipientCount: number
|
||||
}) {
|
||||
@@ -46,6 +59,15 @@ export function LunchRecapActions({
|
||||
},
|
||||
})
|
||||
|
||||
const sendReminders = trpc.lunch.sendReminders.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Reminders sent to ${data.sent} attendee${data.sent === 1 ? '' : 's'}`)
|
||||
},
|
||||
onError: (e) => {
|
||||
toast.error(`Failed to send reminders: ${e.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const { data: preview, isLoading: loadingPreview } =
|
||||
trpc.lunch.getRecapPreview.useQuery(
|
||||
{ programId },
|
||||
@@ -68,6 +90,31 @@ export function LunchRecapActions({
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" /> Send recap now
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" disabled={sendReminders.isPending}>
|
||||
<Bell className="mr-2 h-4 w-4" /> Send reminders now
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Send lunch pick reminders?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will send a reminder email to all confirmed attendees who
|
||||
haven't picked a lunch dish yet. You can do this multiple
|
||||
times — it won't affect the automatic reminder window.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => sendReminders.mutate({ lunchEventId })}
|
||||
>
|
||||
Send reminders
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{recapSentAt
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user