Compare commits
260 Commits
9cb3b9de13
...
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 | ||
|
|
6e36704bb1 | ||
|
|
7d72ee271f | ||
|
|
fbc42f11fd | ||
|
|
9d0beed02f | ||
|
|
89e637843a | ||
|
|
a1c293028a | ||
|
|
765bdf9f9e | ||
|
|
48d29d4a6b | ||
|
|
90dcb47c25 | ||
|
|
35f46c3e34 | ||
|
|
e0f6b7e741 | ||
|
|
31b98f6f1e | ||
|
|
df95867465 | ||
|
|
ec24d404c5 | ||
|
|
618def6174 | ||
|
|
bbfe2d8097 | ||
|
|
051dea4d0e | ||
|
|
939a13c0e8 | ||
|
|
ec00942620 | ||
|
|
6fcabc89d7 | ||
|
|
d4e5d54de2 | ||
|
|
829a7e457a | ||
|
|
05b0412534 | ||
|
|
a671bb853c | ||
|
|
d779959e54 | ||
|
|
9e14775f08 | ||
|
|
06b171b0d4 | ||
|
|
1f24f5539c | ||
|
|
7da4200e72 | ||
|
|
1a0afd8c6e | ||
|
|
cdb18cc3d1 | ||
|
|
e16039142e | ||
|
|
1a58b3db1a | ||
|
|
eb19cb11a1 | ||
|
|
2f59b87e4f | ||
|
|
78992a493a | ||
|
|
62ab27a05a | ||
|
|
030db533e1 | ||
|
|
7824b00ff4 | ||
|
|
46a78c3a74 | ||
|
|
fe630e0e2d | ||
|
|
7c86e42413 | ||
|
|
0e104e0b6f | ||
|
|
bdfd99874a | ||
|
|
289903c8bd | ||
|
|
6e5f607425 | ||
|
|
ff355ee10e | ||
|
|
903ec2401f | ||
|
|
a6284e5c66 | ||
|
|
5b642c3d50 | ||
|
|
3d8aab46f1 | ||
|
|
3bc1cc14c7 | ||
|
|
5bdb65181d | ||
|
|
e706913a57 | ||
|
|
6487f4b209 | ||
|
|
57ec28edad | ||
|
|
d1f29a149a | ||
|
|
b1e6eb81eb | ||
|
|
497145b983 | ||
|
|
88548cbea3 | ||
|
|
95055e0dae | ||
|
|
437bed2326 | ||
|
|
14a81cd6ec | ||
|
|
19ef364c71 | ||
|
|
895be93678 | ||
|
|
3ea36296b9 | ||
|
|
53a1e62614 | ||
|
|
dff18b17f7 | ||
|
|
d0058b46ed | ||
|
|
11ab0943f6 | ||
|
|
e37f3a5874 | ||
|
|
26ff8ed111 | ||
|
|
70a9752d73 | ||
|
|
6475d5c418 | ||
|
|
432470083c | ||
|
|
0c2b2d1f96 | ||
|
|
cedd188328 | ||
|
|
75c8829c3f | ||
|
|
08829df54d | ||
|
|
34bd267c32 | ||
|
|
a0a2c5f06a | ||
|
|
f9bffabf05 | ||
|
|
64668b047e | ||
|
|
2b07c12c18 | ||
|
|
ddae34c8f5 | ||
|
|
4874491b18 | ||
|
|
c29410fd4e | ||
|
|
b867c45114 | ||
|
|
16156111a6 | ||
|
|
2e7b545a1b | ||
|
|
dd48db5eea | ||
|
|
0222da79e0 | ||
|
|
6ef0e50081 | ||
|
|
0c35531b87 | ||
|
|
305b35f3a8 | ||
|
|
67f6fc3aba | ||
|
|
bfa9fb5c83 | ||
|
|
900700f6ae | ||
|
|
e0103fa956 | ||
|
|
70f1f64ea3 | ||
|
|
aed5e078b3 | ||
|
|
90c53ef49f | ||
|
|
d0e7bfd60a | ||
|
|
9db8312b96 | ||
|
|
3b12078e04 | ||
|
|
b4f5189a8e | ||
|
|
ee68f8af41 | ||
|
|
664a682585 | ||
|
|
e12f26092a | ||
|
|
387f84c338 | ||
|
|
0680a5d601 | ||
|
|
6f3e8885e0 | ||
|
|
cfd9dc6afe | ||
|
|
9a2c10a6f8 | ||
|
|
97d1f2a3af | ||
|
|
7147115918 | ||
|
|
260baf3a41 | ||
|
|
64e7be2418 | ||
|
|
901d9ba982 | ||
|
|
2e080a5d09 | ||
|
|
982d5193c5 | ||
|
|
07dd7a0692 | ||
|
|
f36f68bbf9 | ||
|
|
be4449e4ef | ||
|
|
f37a9b49b5 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -62,3 +62,9 @@ build-output.txt
|
|||||||
# Private keys and secrets
|
# Private keys and secrets
|
||||||
private/
|
private/
|
||||||
public/build-id.json
|
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
|
ATTEMPT=1
|
||||||
|
|
||||||
# Auto-resolve any previously failed migrations so deploy can proceed.
|
# Auto-resolve any previously failed migrations so deploy can proceed.
|
||||||
# This handles the case where a migration partially applied and was fixed
|
# This handles the case where a migration failed mid-flight and was then
|
||||||
# in a subsequent deploy — without this, Prisma refuses to run anything.
|
# fixed in a subsequent deploy — without this, Prisma refuses to run
|
||||||
|
# anything else (P3009).
|
||||||
|
#
|
||||||
|
# We query `_prisma_migrations` directly rather than parsing the output of
|
||||||
|
# `prisma migrate status`, because that output's wording has shifted between
|
||||||
|
# Prisma versions and any drift means failed migrations slip through and
|
||||||
|
# the container crash-loops. Truth lives in the table: a row with
|
||||||
|
# `finished_at IS NULL AND rolled_back_at IS NULL` is an unresolved failure.
|
||||||
echo "==> Checking for failed migrations..."
|
echo "==> Checking for failed migrations..."
|
||||||
MIGRATE_STATUS=$(npx prisma migrate status 2>&1 || true)
|
RESOLVE_ATTEMPTS=0
|
||||||
FAILED=$(echo "$MIGRATE_STATUS" | sed -n 's/.*The `\([^`]*\)` migration.*failed.*/\1/p' | head -1)
|
while [ "$RESOLVE_ATTEMPTS" -lt 5 ]; do
|
||||||
if [ -n "$FAILED" ]; then
|
FAILED=$(node -e "
|
||||||
echo "==> Found failed migration: $FAILED — marking as rolled back..."
|
const { PrismaClient } = require('@prisma/client');
|
||||||
npx prisma migrate resolve --rolled-back "$FAILED"
|
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
|
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)..."
|
echo "==> Running database migrations (with retry)..."
|
||||||
until npx prisma migrate deploy; do
|
until npx prisma migrate deploy; do
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
399
docs/superpowers/plans/2026-04-28-pr1-jury-preferences-filter.md
Normal file
399
docs/superpowers/plans/2026-04-28-pr1-jury-preferences-filter.md
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
# PR 1 — Jury Preferences Filter (§E)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Filter the juror "Confirm Your Evaluation Preferences" banner so it only shows jury group memberships whose linked rounds include at least one review-type round (INTAKE/FILTERING/EVALUATION/SUBMISSION/MENTORING). Memberships in groups whose only rounds are LIVE_FINAL or DELIBERATION must be hidden — those ceremonies don't use cap+category preferences.
|
||||||
|
|
||||||
|
**Architecture:** Single-procedure change. `getOnboardingContext` in `src/server/routers/user.ts` adds a Prisma `juryGroup.rounds: { some: { roundType: { in: [...] } } }` filter to the `juryGroupMember.findMany` query. No schema migration. No frontend change (the banner consumes the same return shape).
|
||||||
|
|
||||||
|
**Tech Stack:** Prisma 6, tRPC 11, Vitest 4. Tests use `prisma` directly + `createCaller(userRouter, user)` from `tests/setup.ts`.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md` §E.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `src/server/routers/user.ts` (`getOnboardingContext`, lines 1395-1422) | Modify | Add `juryGroup.rounds.some` filter to membership query |
|
||||||
|
| `tests/unit/jury-preferences-filter.test.ts` | Create | Three test cases covering the filter behavior |
|
||||||
|
|
||||||
|
No new files beyond the test. No schema changes. No client change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Orient on the current implementation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Read: `src/server/routers/user.ts:1395-1422`
|
||||||
|
- Read: `src/components/jury/preferences-banner.tsx:17-62`
|
||||||
|
- Read: `prisma/schema.prisma` (lines 2249-2280 for `JuryGroup`, lines 2149-2200 for `Round`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current procedure**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -n '1395,1425p' /Users/matt/Repos/MOPC/src/server/routers/user.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: see the `getOnboardingContext: protectedProcedure.query(...)` definition that calls `prisma.juryGroupMember.findMany({ where: { userId: ctx.user.id }, include: { juryGroup: { select: ... } } })`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Confirm the JuryGroup ↔ Round relation field**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -n '2249,2280p' /Users/matt/Repos/MOPC/prisma/schema.prisma
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: see `model JuryGroup { ... rounds Round[] ... }`. The relation field name is **`rounds`** (plural). This is the field name we'll use in the Prisma `where` filter.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Inspect the consumer to confirm return shape stays identical**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -n '17,62p' /Users/matt/Repos/MOPC/src/components/jury/preferences-banner.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: see that the banner reads `(ctx?.memberships ?? []).filter(m => m.selfServiceCap === null)`. We are only narrowing the rows returned — the row shape is unchanged — so the banner needs no edit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Write the failing tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/unit/jury-preferences-filter.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the test file**
|
||||||
|
|
||||||
|
Write the file at `tests/unit/jury-preferences-filter.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { prisma, createCaller } from '../setup'
|
||||||
|
import {
|
||||||
|
createTestUser, createTestProgram, createTestCompetition, createTestRound,
|
||||||
|
cleanupTestData, uid,
|
||||||
|
} from '../helpers'
|
||||||
|
import { userRouter } from '../../src/server/routers/user'
|
||||||
|
|
||||||
|
describe('user.getOnboardingContext — preferences filter excludes LIVE_FINAL/DELIBERATION-only groups', () => {
|
||||||
|
let programId: string
|
||||||
|
let competitionId: string
|
||||||
|
let juror: { id: string; email: string; role: 'JURY_MEMBER' }
|
||||||
|
let observerOnlyGroupId: string
|
||||||
|
let reviewGroupId: string
|
||||||
|
let mixedGroupId: string
|
||||||
|
const userIds: string[] = []
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const program = await createTestProgram({ name: `prefs-filter-${uid()}` })
|
||||||
|
programId = program.id
|
||||||
|
const competition = await createTestCompetition(programId)
|
||||||
|
competitionId = competition.id
|
||||||
|
|
||||||
|
const reviewRound = await createTestRound(competitionId, {
|
||||||
|
name: 'Review Round', slug: `review-${uid()}`, roundType: 'EVALUATION', sortOrder: 0,
|
||||||
|
})
|
||||||
|
const liveFinalRound = await createTestRound(competitionId, {
|
||||||
|
name: 'Final Round', slug: `final-${uid()}`, roundType: 'LIVE_FINAL', sortOrder: 1,
|
||||||
|
})
|
||||||
|
const deliberationRound = await createTestRound(competitionId, {
|
||||||
|
name: 'Delib Round', slug: `delib-${uid()}`, roundType: 'DELIBERATION', sortOrder: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
const reviewOnlyGroup = await prisma.juryGroup.create({
|
||||||
|
data: {
|
||||||
|
id: uid('jg-rev'), competitionId, name: 'Review Only Group',
|
||||||
|
slug: uid('rev'), defaultMaxAssignments: 30,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reviewGroupId = reviewOnlyGroup.id
|
||||||
|
const liveFinalOnlyGroup = await prisma.juryGroup.create({
|
||||||
|
data: {
|
||||||
|
id: uid('jg-fin'), competitionId, name: 'Finals Only Group',
|
||||||
|
slug: uid('fin'), defaultMaxAssignments: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
observerOnlyGroupId = liveFinalOnlyGroup.id
|
||||||
|
const mixedGroup = await prisma.juryGroup.create({
|
||||||
|
data: {
|
||||||
|
id: uid('jg-mix'), competitionId, name: 'Mixed Group',
|
||||||
|
slug: uid('mix'), defaultMaxAssignments: 20,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
mixedGroupId = mixedGroup.id
|
||||||
|
|
||||||
|
await prisma.round.update({ where: { id: reviewRound.id }, data: { juryGroupId: reviewOnlyGroup.id } })
|
||||||
|
await prisma.round.update({ where: { id: liveFinalRound.id }, data: { juryGroupId: liveFinalOnlyGroup.id } })
|
||||||
|
const mixedReview = await createTestRound(competitionId, {
|
||||||
|
name: 'Mixed Review', slug: `mixed-rev-${uid()}`, roundType: 'EVALUATION', sortOrder: 3,
|
||||||
|
})
|
||||||
|
const mixedFinal = await createTestRound(competitionId, {
|
||||||
|
name: 'Mixed Final', slug: `mixed-fin-${uid()}`, roundType: 'LIVE_FINAL', sortOrder: 4,
|
||||||
|
})
|
||||||
|
await prisma.round.update({ where: { id: mixedReview.id }, data: { juryGroupId: mixedGroup.id } })
|
||||||
|
await prisma.round.update({ where: { id: mixedFinal.id }, data: { juryGroupId: mixedGroup.id } })
|
||||||
|
|
||||||
|
void deliberationRound // referenced for cleanup; not attached to a group in these scenarios
|
||||||
|
|
||||||
|
const u = await createTestUser('JURY_MEMBER')
|
||||||
|
userIds.push(u.id)
|
||||||
|
juror = { id: u.id, email: u.email, role: 'JURY_MEMBER' }
|
||||||
|
|
||||||
|
await prisma.juryGroupMember.createMany({
|
||||||
|
data: [
|
||||||
|
{ id: uid('jgm-rev'), juryGroupId: reviewGroupId, userId: u.id, role: 'MEMBER' },
|
||||||
|
{ id: uid('jgm-fin'), juryGroupId: observerOnlyGroupId, userId: u.id, role: 'MEMBER' },
|
||||||
|
{ id: uid('jgm-mix'), juryGroupId: mixedGroupId, userId: u.id, role: 'MEMBER' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupTestData(programId, userIds)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the review-only group membership', async () => {
|
||||||
|
const caller = createCaller(userRouter, juror)
|
||||||
|
const ctx = await caller.getOnboardingContext()
|
||||||
|
const names = ctx.memberships.map((m) => m.juryGroupName).sort()
|
||||||
|
expect(names).toContain('Review Only Group')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits the LIVE_FINAL-only group membership', async () => {
|
||||||
|
const caller = createCaller(userRouter, juror)
|
||||||
|
const ctx = await caller.getOnboardingContext()
|
||||||
|
const names = ctx.memberships.map((m) => m.juryGroupName)
|
||||||
|
expect(names).not.toContain('Finals Only Group')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the mixed group (has at least one review round)', async () => {
|
||||||
|
const caller = createCaller(userRouter, juror)
|
||||||
|
const ctx = await caller.getOnboardingContext()
|
||||||
|
const names = ctx.memberships.map((m) => m.juryGroupName)
|
||||||
|
expect(names).toContain('Mixed Group')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns hasSelfServiceOptions=true when at least one membership remains', async () => {
|
||||||
|
const caller = createCaller(userRouter, juror)
|
||||||
|
const ctx = await caller.getOnboardingContext()
|
||||||
|
expect(ctx.hasSelfServiceOptions).toBe(true)
|
||||||
|
expect(ctx.memberships.length).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('user.getOnboardingContext — juror with only LIVE_FINAL membership', () => {
|
||||||
|
let programId: string
|
||||||
|
let juror: { id: string; email: string; role: 'JURY_MEMBER' }
|
||||||
|
const userIds: string[] = []
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const program = await createTestProgram({ name: `prefs-only-fin-${uid()}` })
|
||||||
|
programId = program.id
|
||||||
|
const competition = await createTestCompetition(programId)
|
||||||
|
const liveFinalRound = await createTestRound(competition.id, {
|
||||||
|
name: 'Solo Final', slug: `solo-fin-${uid()}`, roundType: 'LIVE_FINAL', sortOrder: 0,
|
||||||
|
})
|
||||||
|
const liveFinalOnlyGroup = await prisma.juryGroup.create({
|
||||||
|
data: {
|
||||||
|
id: uid('jg-only-fin'), competitionId: competition.id, name: 'Solo Finals Group',
|
||||||
|
slug: uid('solo-fin'), defaultMaxAssignments: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await prisma.round.update({ where: { id: liveFinalRound.id }, data: { juryGroupId: liveFinalOnlyGroup.id } })
|
||||||
|
|
||||||
|
const u = await createTestUser('JURY_MEMBER')
|
||||||
|
userIds.push(u.id)
|
||||||
|
juror = { id: u.id, email: u.email, role: 'JURY_MEMBER' }
|
||||||
|
await prisma.juryGroupMember.create({
|
||||||
|
data: { id: uid('jgm-only-fin'), juryGroupId: liveFinalOnlyGroup.id, userId: u.id, role: 'MEMBER' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupTestData(programId, userIds)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns no memberships and hasSelfServiceOptions=false', async () => {
|
||||||
|
const caller = createCaller(userRouter, juror)
|
||||||
|
const ctx = await caller.getOnboardingContext()
|
||||||
|
expect(ctx.memberships).toEqual([])
|
||||||
|
expect(ctx.hasSelfServiceOptions).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the new tests and confirm they FAIL**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/matt/Repos/MOPC && npx vitest run tests/unit/jury-preferences-filter.test.ts 2>&1 | tail -30
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: at least one of these failures:
|
||||||
|
- "omits the LIVE_FINAL-only group membership" → `expected [...] not to contain 'Finals Only Group'` (today the procedure returns ALL memberships, so it WILL contain that name).
|
||||||
|
- "returns no memberships and hasSelfServiceOptions=false" → `expected [{ ... 'Solo Finals Group' ... }] to equal []` (today returns the lone Finals membership).
|
||||||
|
|
||||||
|
If all four tests pass with no code change, STOP — that means the filter is already in place or the test fixtures aren't exercising the procedure correctly. Re-read Task 1 outputs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Apply the Prisma filter
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/routers/user.ts` (the `findMany` call inside `getOnboardingContext`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current procedure to anchor the edit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -n '1397,1410p' /Users/matt/Repos/MOPC/src/server/routers/user.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: lines look like
|
||||||
|
|
||||||
|
```ts
|
||||||
|
getOnboardingContext: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const memberships = await ctx.prisma.juryGroupMember.findMany({
|
||||||
|
where: { userId: ctx.user.id },
|
||||||
|
include: {
|
||||||
|
juryGroup: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
defaultMaxAssignments: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the round-type filter to the `where` clause**
|
||||||
|
|
||||||
|
Edit `src/server/routers/user.ts`. Replace the `findMany` call's `where` clause:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// before
|
||||||
|
where: { userId: ctx.user.id },
|
||||||
|
|
||||||
|
// after
|
||||||
|
where: {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
juryGroup: {
|
||||||
|
rounds: {
|
||||||
|
some: {
|
||||||
|
roundType: {
|
||||||
|
in: ['INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
(The `include` block stays unchanged. The `return` block stays unchanged.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Re-run the tests and confirm they all PASS**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/matt/Repos/MOPC && npx vitest run tests/unit/jury-preferences-filter.test.ts 2>&1 | tail -30
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 5 passing, 0 failing across the two `describe` blocks.
|
||||||
|
|
||||||
|
If any test fails:
|
||||||
|
- Re-read the procedure: did the edit save? `sed -n '1397,1415p' src/server/routers/user.ts`
|
||||||
|
- Did the relation field name change? Re-confirm via `grep "rounds " prisma/schema.prisma`
|
||||||
|
- Did the test cleanup run from a previous failed test leave stale data? Try `npx vitest run -t 'returns the review-only group membership'` in isolation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Run the full unit suite to check for regressions
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run all unit tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/matt/Repos/MOPC && npx vitest run tests/unit 2>&1 | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all unit tests pass. The new file should appear in the output as `tests/unit/jury-preferences-filter.test.ts ... ✓`. No previously-passing test should now fail.
|
||||||
|
|
||||||
|
If any other test fails: read the failure. The most likely cause is that the Prisma filter unintentionally hides memberships from a test fixture that happens to use a jury group with no attached rounds. If so, the test fixture (not our change) is the problem — flag it and fix the fixture to attach a review-type round.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Run typecheck
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the project typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/matt/Repos/MOPC && npm run typecheck 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `tsc --noEmit` exits with code 0, no output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Commit
|
||||||
|
|
||||||
|
- [ ] **Step 1: Stage the changes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/matt/Repos/MOPC && git add src/server/routers/user.ts tests/unit/jury-preferences-filter.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify staged diff is what we expect**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/matt/Repos/MOPC && git diff --cached --stat
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
```
|
||||||
|
src/server/routers/user.ts | ~10 +-
|
||||||
|
tests/unit/jury-preferences-filter.test.ts | ~140 ++++
|
||||||
|
2 files changed, ~150 insertions(+), ~3 deletions(-)
|
||||||
|
```
|
||||||
|
|
||||||
|
(Numbers approximate. If anything else is staged, unstage it: `git restore --staged <unwanted-file>`.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/matt/Repos/MOPC && git commit -m "$(cat <<'EOF'
|
||||||
|
fix: filter juror preferences banner to review-round groups
|
||||||
|
|
||||||
|
The "Confirm Your Evaluation Preferences" banner was including jury
|
||||||
|
group memberships whose only rounds are LIVE_FINAL or DELIBERATION.
|
||||||
|
Those ceremonies don't use cap+category preferences, so the sliders
|
||||||
|
were meaningless. Filter getOnboardingContext to memberships in
|
||||||
|
groups with at least one INTAKE/FILTERING/EVALUATION/SUBMISSION/
|
||||||
|
MENTORING round.
|
||||||
|
|
||||||
|
Spec: docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md §E
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify clean status**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/matt/Repos/MOPC && git status --short && git log -1 --oneline
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: empty status, latest commit is the one just created.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `npx vitest run tests/unit/jury-preferences-filter.test.ts` → 5 pass
|
||||||
|
- [ ] `npx vitest run tests/unit` → no regressions
|
||||||
|
- [ ] `npm run typecheck` → no errors
|
||||||
|
- [ ] Commit message references §E of the spec
|
||||||
|
- [ ] No frontend changes
|
||||||
|
- [ ] No Prisma migration files changed
|
||||||
|
|
||||||
|
## Out of scope (verified)
|
||||||
|
|
||||||
|
- The `preferences-banner.tsx` component is NOT modified — the return shape from `getOnboardingContext` is unchanged, only the row count differs.
|
||||||
|
- Existing tests are NOT modified — the change is additive.
|
||||||
|
- Prisma schema is NOT touched.
|
||||||
1088
docs/superpowers/plans/2026-04-28-pr2-mentor-workspace-files.md
Normal file
1088
docs/superpowers/plans/2026-04-28-pr2-mentor-workspace-files.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
# PR 3 — MENTORING Round Config Completeness (§A)
|
||||||
|
|
||||||
|
> **For agentic workers:** Use superpowers:executing-plans for inline execution.
|
||||||
|
|
||||||
|
**Goal:** Surface every `MentoringConfigSchema` field on the round Config tab; hide the empty General Settings card on MENTORING rounds; relax the "File requirements set" Launch Readiness gate when no file promotion is configured.
|
||||||
|
|
||||||
|
**Architecture:** UI-only changes. No schema, no API. Three files touched.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md` §A.
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| File | Action | Why |
|
||||||
|
|------|--------|-----|
|
||||||
|
| `src/components/admin/rounds/config/mentoring-config.tsx` | Modify | Add `mentoringRequestDeadlineDays` numeric input + `passThroughIfNoRequest` toggle; add help-text to Eligibility |
|
||||||
|
| `src/app/(admin)/admin/rounds/[roundId]/page.tsx` | Modify | Hide General Settings card when `round.roundType === 'MENTORING'`; relax File-requirements readiness gate for MENTORING rounds without file promotion configured |
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Task 1: Add the two missing inputs to `mentoring-config.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Patch the file** — append a new "Mentoring Request Window" card BETWEEN the existing two cards, and add help-text to Eligibility. Code in execution.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Typecheck** — `npm run typecheck`. Expect 0 errors.
|
||||||
|
|
||||||
|
### Task 2: Hide General Settings card + relax readiness on MENTORING rounds
|
||||||
|
|
||||||
|
- [ ] **Step 1: Patch `(admin)/admin/rounds/[roundId]/page.tsx`** — wrap the General Settings card in `{!isMentoring && (...)}` and extend the file-requirements bypass condition.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Typecheck + build** — confirm clean.
|
||||||
|
|
||||||
|
### Task 3: Smoke + commit
|
||||||
|
|
||||||
|
- [ ] **Step 1: `npm run build`** — confirm clean.
|
||||||
|
- [ ] **Step 2: Commit** — message references §A.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Form unit tests (heavy render setup; existing config-save mutation already verified by other PRs). Manual smoke covers the UI work.
|
||||||
269
docs/superpowers/plans/2026-04-28-pr4-visa-tracking.md
Normal file
269
docs/superpowers/plans/2026-04-28-pr4-visa-tracking.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# PR 4: Visa Tracking Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Track the visa-application lifecycle for grand-finale attendees who need a visa, without ever holding sensitive documents (passport scans, visa letters). Documents continue to flow over email; the platform records process metadata only.
|
||||||
|
|
||||||
|
**Architecture:** A new `VisaApplication` model 1:1 with `AttendingMember`, auto-created when `needsVisa=true` flips on. Compact 5-stage status enum. A `Program.visaStatusVisibleToMembers` toggle gates whether teams see their own status. Auto-create / auto-cleanup runs in the same writes as `confirm` / `adminConfirm` / `editAttendees` so the lifecycle stays in sync.
|
||||||
|
|
||||||
|
**Tech Stack:** Prisma 6 (additive migration), tRPC (router additions to `logistics` + `applicant`), Vitest 4 for TDD, shadcn/ui for the admin tab + member badge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Schema migration (additive)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `prisma/schema.prisma`
|
||||||
|
- Create: `prisma/migrations/<timestamp>_add_visa_tracking/migration.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the enum + model + program toggle**
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
enum VisaStatus {
|
||||||
|
NOT_NEEDED
|
||||||
|
REQUESTED
|
||||||
|
INVITATION_SENT
|
||||||
|
APPOINTMENT_BOOKED
|
||||||
|
GRANTED
|
||||||
|
DENIED
|
||||||
|
}
|
||||||
|
|
||||||
|
model VisaApplication {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
attendingMemberId String @unique
|
||||||
|
status VisaStatus @default(REQUESTED)
|
||||||
|
nationality String? // self-declared, optional
|
||||||
|
invitationSentAt DateTime?
|
||||||
|
appointmentAt DateTime?
|
||||||
|
decisionAt DateTime? // GRANTED or DENIED date
|
||||||
|
notes String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the back-reference on `AttendingMember`:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
visaApplication VisaApplication?
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `Program`:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
visaStatusVisibleToMembers Boolean @default(true)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Generate migration with `--create-only`** and inspect the SQL for any non-additive operations (DROP COLUMN / ALTER COLUMN / RENAME). Should be only `CREATE TYPE`, `CREATE TABLE`, `ALTER TABLE Program ADD COLUMN`, foreign keys.
|
||||||
|
|
||||||
|
Run: `npx prisma migrate dev --name add_visa_tracking --create-only`
|
||||||
|
Then: read migration SQL, verify it's safe.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Apply migration + regenerate client**
|
||||||
|
|
||||||
|
Run: `npx prisma migrate dev` (apply pending) and `npx prisma generate`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Auto-create / cleanup VisaApplication on attendee writes (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/routers/finalist.ts` (`confirm`, `adminConfirm`, `editAttendees`)
|
||||||
|
- Create: `tests/unit/visa-application-lifecycle.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
describe('VisaApplication lifecycle', () => {
|
||||||
|
it('public confirm creates a VisaApplication for each needsVisa=true attendee', async () => {
|
||||||
|
// setup: PENDING confirmation, 2 team members
|
||||||
|
// call confirm with both attending, visaFlags { lead: false, member: true }
|
||||||
|
// assert: 1 VisaApplication with status=REQUESTED for member
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adminConfirm creates a VisaApplication for each needsVisa=true attendee', async () => {
|
||||||
|
// same as above but via adminConfirm
|
||||||
|
})
|
||||||
|
|
||||||
|
it('editAttendees creates a VisaApplication when an attendee flips to needsVisa=true', async () => {
|
||||||
|
// setup: CONFIRMED with 1 attendee (lead) needsVisa=false, no VisaApp
|
||||||
|
// call editAttendees with same attendees but visaFlags { lead: true }
|
||||||
|
// assert: 1 VisaApplication for lead
|
||||||
|
})
|
||||||
|
|
||||||
|
it('editAttendees deletes the VisaApplication when an attendee flips to needsVisa=false', async () => {
|
||||||
|
// setup: CONFIRMED with 1 attendee needsVisa=true + VisaApp exists
|
||||||
|
// call editAttendees same roster but visaFlags { lead: false }
|
||||||
|
// assert: 0 VisaApplications
|
||||||
|
})
|
||||||
|
|
||||||
|
it('editAttendees preserves an existing VisaApplication when needsVisa stays true', async () => {
|
||||||
|
// setup: VisaApp with notes='abc', status=APPOINTMENT_BOOKED
|
||||||
|
// call editAttendees same roster + visaFlags unchanged
|
||||||
|
// assert: VisaApp unchanged (notes still 'abc', status still APPOINTMENT_BOOKED)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removing an attendee cascades the VisaApplication', async () => {
|
||||||
|
// setup: CONFIRMED with 2 attendees, both needsVisa=true with VisaApp rows
|
||||||
|
// call editAttendees roster of just the lead
|
||||||
|
// assert: only 1 VisaApp left (for lead)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests, expect 6 failures**.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire auto-create in `confirm` (public)**
|
||||||
|
|
||||||
|
After the existing `attendingMember.createMany`, add a follow-up createMany for any user with `visaFlags[uid] === true`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// inside the same $transaction
|
||||||
|
ctx.prisma.visaApplication.createMany({
|
||||||
|
data: input.attendingUserIds
|
||||||
|
.filter((uid) => input.visaFlags[uid] === true)
|
||||||
|
.map((uid) => /* will need attendingMemberId — use a separate post-tx pass */),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `createMany` won't have the `attendingMemberId` until after the AttendingMember rows are created. Easiest: run the visa creation in a follow-up step (after the transaction commits) by re-querying the attendee rows. The transaction guarantees the attendees are committed; if the visa step fails the worst case is missing visa rows that the admin can manually re-create — but to be safer wrap both in a single Prisma transaction using `$transaction(async (tx) => {...})` callback form.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Wire auto-create in `adminConfirm`** — same pattern.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Wire diff-aware sync in `editAttendees`**
|
||||||
|
|
||||||
|
After the existing diff transaction, derive the desired `needsVisa=true` set, the existing VisaApplication set, and:
|
||||||
|
- Create rows for new needsVisa=true attendees with no VisaApp
|
||||||
|
- Delete rows for attendees whose needsVisa flipped to false (or whose AttendingMember was deleted — already cascaded)
|
||||||
|
- Leave alone rows where needsVisa stays true (preserves notes / status)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run tests, expect green**.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Admin visa CRUD procedures (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/routers/logistics.ts` (add 3 procedures)
|
||||||
|
- Create: `tests/unit/visa-admin.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
describe('logistics.listVisaApplications', () => {
|
||||||
|
it('returns rows joined with project + attendee for the program, sorted by status priority', async () => {
|
||||||
|
// 3 visa apps: GRANTED, REQUESTED, APPOINTMENT_BOOKED
|
||||||
|
// expect order: REQUESTED, INVITATION_SENT (none), APPOINTMENT_BOOKED, GRANTED
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('logistics.updateVisaApplication', () => {
|
||||||
|
it('updates status + dates + notes + nationality', async () => {
|
||||||
|
// setup: REQUESTED app
|
||||||
|
// update -> APPOINTMENT_BOOKED + appointmentAt + notes
|
||||||
|
// assert: row updated, audit log VISA_UPDATE written
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an unknown application id', async () => {
|
||||||
|
// expect throw /not found/i
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('logistics.setVisaVisibility', () => {
|
||||||
|
it('flips Program.visaStatusVisibleToMembers', async () => {
|
||||||
|
// default true -> set false -> verify
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement the three procedures** in `logistics.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests, expect green**.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Member visa query (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/routers/applicant.ts`
|
||||||
|
- Modify: `tests/unit/visa-admin.test.ts` (add a describe block) OR new file `tests/unit/visa-member-visibility.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
describe('applicant.getMyVisaApplications', () => {
|
||||||
|
it('returns the caller-team visa apps when toggle is true', async () => {
|
||||||
|
// setup: program toggle=true, member with VisaApp
|
||||||
|
// assert: returns array with that app
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when toggle is false', async () => {
|
||||||
|
// assert: returns null
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array when caller has no visa apps', async () => {
|
||||||
|
// assert: []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement** — query AttendingMember rows where `userId = caller`, include VisaApplication. Return null if `program.visaStatusVisibleToMembers === false`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Admin Visas tab UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/(admin)/admin/logistics/page.tsx` (un-disable the Visas tab)
|
||||||
|
- Create: `src/components/admin/logistics/visas-tab.tsx`
|
||||||
|
- Create: `src/components/admin/logistics/visa-edit-dialog.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build the tab**
|
||||||
|
|
||||||
|
Table columns: Project · Member · Nationality · Status · Next date · Notes (truncated) · Actions. Status filter pills mirror the Confirmations tab. Header has a "Visible to teams" Switch wired to `setVisaVisibility`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build the edit dialog**
|
||||||
|
|
||||||
|
Status dropdown, nationality input, three date pickers (invitation / appointment / decision), notes textarea. Save calls `updateVisaApplication`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Activate the tab** — remove the `disabled` prop and wire `<VisasTab programId={programId} />`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Live smoke** — confirm a finalist with one needsVisa attendee, open Visas tab, edit → verify persistence.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Member visa surface on AttendingMembersCard
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/applicant/attending-members-card.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Wire the query**
|
||||||
|
|
||||||
|
Call `applicant.getMyVisaApplications` alongside the existing confirmation query. If the result is null (toggle off), don't render any visa info. Otherwise, render a small status badge + the next upcoming date next to each attendee whose `needsVisa=true`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Live smoke** — toggle visibility off in the admin tab, confirm member can no longer see status.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Final verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full vitest** — `npx vitest run`. Expect 148 + new tests, all green.
|
||||||
|
- [ ] **Step 2: Typecheck** — `npm run typecheck`.
|
||||||
|
- [ ] **Step 3: Build** — `npm run build`.
|
||||||
|
- [ ] **Step 4: Live smoke** end-to-end — confirm an attendee with visa, edit status as admin, verify member view, toggle off, verify hidden.
|
||||||
142
docs/superpowers/plans/2026-04-28-pr5-settings-consolidation.md
Normal file
142
docs/superpowers/plans/2026-04-28-pr5-settings-consolidation.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# PR 5: Settings Consolidation Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Consolidate per-edition logistics configuration onto `/admin/settings` so admins find every knob in one place. Remove dead Logistics tabs and move the visa-visibility toggle out of the Visas tab.
|
||||||
|
|
||||||
|
**Architecture:** A new "Edition" tab on `/admin/settings` reads + writes a merged view: `Program` fields (defaultAttendeeCap, visaStatusVisibleToMembers) + the LIVE_FINAL round's `configJson` (attendeeEditCutoffHours, confirmationWindowHours). One reader (`program.getEditionSettings`) + one writer (`program.updateEditionSettings`) so the UI stays simple.
|
||||||
|
|
||||||
|
**Tech Stack:** Prisma 6 (no migration — purely a UX layer over existing fields), tRPC, Vitest 4 for the procedure tests, shadcn/ui for the tab + sub-sections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: tRPC procedures for edition settings (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/routers/program.ts`
|
||||||
|
- Create: `tests/unit/program-edition-settings.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
describe('program.getEditionSettings', () => {
|
||||||
|
it('returns the merged view (program fields + LIVE_FINAL round config)', async () => {
|
||||||
|
// setup: program with defaultAttendeeCap=5, visaStatusVisibleToMembers=false
|
||||||
|
// + LIVE_FINAL round with configJson { attendeeEditCutoffHours: 24, confirmationWindowHours: 12 }
|
||||||
|
// assert: shape { defaultAttendeeCap, visaStatusVisibleToMembers, attendeeEditCutoffHours, confirmationWindowHours }
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to defaults when LIVE_FINAL round has no config', async () => {
|
||||||
|
// assert: attendeeEditCutoffHours = 48, confirmationWindowHours = 24
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for round-derived fields if no LIVE_FINAL round exists', async () => {
|
||||||
|
// assert: attendeeEditCutoffHours = null, confirmationWindowHours = null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('program.updateEditionSettings', () => {
|
||||||
|
it('writes program fields + round configJson + audit-logs', async () => {
|
||||||
|
// call with partial { defaultAttendeeCap: 4, attendeeEditCutoffHours: 36 }
|
||||||
|
// assert: program.defaultAttendeeCap=4, round.configJson.attendeeEditCutoffHours=36
|
||||||
|
// assert: AuditLog action=PROGRAM_EDITION_SETTINGS_UPDATE
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves untouched configJson keys', async () => {
|
||||||
|
// round.configJson initially { foo: 'bar', attendeeEditCutoffHours: 48 }
|
||||||
|
// call with { attendeeEditCutoffHours: 24 }
|
||||||
|
// assert: round.configJson = { foo: 'bar', attendeeEditCutoffHours: 24 }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run failing tests**.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement getEditionSettings**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
getEditionSettings: adminProcedure
|
||||||
|
.input(z.object({ programId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const program = await ctx.prisma.program.findUniqueOrThrow({
|
||||||
|
where: { id: input.programId },
|
||||||
|
select: { id: true, defaultAttendeeCap: true, visaStatusVisibleToMembers: true },
|
||||||
|
})
|
||||||
|
const round = await ctx.prisma.round.findFirst({
|
||||||
|
where: { competition: { programId: input.programId }, roundType: 'LIVE_FINAL' },
|
||||||
|
orderBy: { sortOrder: 'desc' },
|
||||||
|
select: { id: true, configJson: true },
|
||||||
|
})
|
||||||
|
const cfg = (round?.configJson ?? {}) as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
programId: program.id,
|
||||||
|
defaultAttendeeCap: program.defaultAttendeeCap,
|
||||||
|
visaStatusVisibleToMembers: program.visaStatusVisibleToMembers,
|
||||||
|
liveFinalRoundId: round?.id ?? null,
|
||||||
|
attendeeEditCutoffHours: round
|
||||||
|
? ((cfg.attendeeEditCutoffHours as number | undefined) ?? 48)
|
||||||
|
: null,
|
||||||
|
confirmationWindowHours: round
|
||||||
|
? ((cfg.confirmationWindowHours as number | undefined) ?? 24)
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement updateEditionSettings** with merge semantics on configJson + audit log.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests, expect green**.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Edition Settings tab UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/admin/settings/edition-settings-tab.tsx`
|
||||||
|
- Modify: `src/components/settings/settings-content.tsx` (add the new tab + entry)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build the Edition Settings tab**
|
||||||
|
|
||||||
|
Three sub-sections (Card per section):
|
||||||
|
1. **Grand-finale logistics** — `defaultAttendeeCap` (number input), `attendeeEditCutoffHours` (number input with "hours before LIVE_FINAL" hint), `confirmationWindowHours` (number input with "hours from email send" hint).
|
||||||
|
2. **Visa** — `visaStatusVisibleToMembers` Switch + caption.
|
||||||
|
3. **Coming soon** — placeholder for Lunch + Email Templates that PRs 6 + 7 will fill.
|
||||||
|
|
||||||
|
Each input fires `program.updateEditionSettings.useMutation` debounced or on-blur. Toast on success.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wire into `/admin/settings`** — add `<TabsTrigger value="edition">` and `<TabsContent value="edition">` in settings-content. Place before existing tabs.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Live smoke** — open settings, verify it renders, toggle visa visibility, verify it persists in the DB and the applicant dashboard hides the surface.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Cleanup — remove dead Logistics tabs + visibility toggle
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/(admin)/admin/logistics/page.tsx`
|
||||||
|
- Modify: `src/components/admin/logistics/visas-tab.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove disabled tabs**
|
||||||
|
|
||||||
|
Drop the `<TabsTrigger value="documents" disabled>` and `<TabsTrigger value="settings" disabled>` blocks. Also drop their unused imports (`FileText`, `Settings`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace visibility toggle with a hint**
|
||||||
|
|
||||||
|
In `visas-tab.tsx`, swap the inline Switch + Label for a small banner: "Configure visibility in Settings → Edition" with a Link to `/admin/settings?tab=edition`. Drop the `getVisaVisibility` query and `setVisaVisibility` mutation usage from this component (keep the procedures — used by the new settings page).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Live smoke** — confirm Logistics tab bar shows only the active tabs, visas tab no longer has a toggle, settings page does.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Final verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full vitest** — `npx vitest run`. Expect 161 + new tests (~5).
|
||||||
|
- [ ] **Step 2: Typecheck** — clean.
|
||||||
|
- [ ] **Step 3: Build** — clean.
|
||||||
|
- [ ] **Step 4: E2E smoke** — open `/admin/settings` → Edition tab, change knobs, verify persistence; confirm the Visas tab no longer shows the toggle and the hint links to settings.
|
||||||
182
docs/superpowers/plans/2026-04-28-pr7-email-team-from-project.md
Normal file
182
docs/superpowers/plans/2026-04-28-pr7-email-team-from-project.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# PR 7 — "Email Team" Modal on Project Detail Page
|
||||||
|
|
||||||
|
> **For agentic workers:** Use superpowers:executing-plans for inline execution.
|
||||||
|
|
||||||
|
**Goal:** Add an "Email Team" button to `/admin/projects/[id]` that opens a modal composing a custom email to that project's team. Same look + features as `/admin/messages` (subject, body, live HTML preview, send-to-all). Body pre-filled with `Hello [Project Title] team,\n\n` so admin can edit and add their custom content beneath.
|
||||||
|
|
||||||
|
**Architecture:** Adds `PROJECT_TEAM` to the existing `RecipientType` enum (5 places) + a resolver branch. New self-contained `<ProjectEmailDialog>` component reuses the existing `message.previewEmail` and `message.send` procedures. No template-system rewrite.
|
||||||
|
|
||||||
|
**Spec:** New ask added during PR review (2026-04-28). Not in original spec but adjacent to §B (admin views).
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| File | Action | Why |
|
||||||
|
|------|--------|-----|
|
||||||
|
| `src/server/routers/message.ts` | Modify | Add `PROJECT_TEAM` to 4 input enums + 1 resolver case |
|
||||||
|
| `src/components/admin/project-email-dialog.tsx` | Create | Self-contained modal with subject + body + live preview + send |
|
||||||
|
| `src/app/(admin)/admin/projects/[id]/page.tsx` | Modify | Add "Email Team" button next to Edit button; render the dialog |
|
||||||
|
| `tests/unit/message-recipient-project-team.test.ts` | Create | Resolver returns team members + submittedByUserId |
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Task 1: Backend — `PROJECT_TEAM` recipient type
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// tests/unit/message-recipient-project-team.test.ts
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { prisma, createCaller } from '../setup'
|
||||||
|
import {
|
||||||
|
createTestUser, createTestProgram, createTestProject, cleanupTestData, uid,
|
||||||
|
} from '../helpers'
|
||||||
|
import { messageRouter } from '../../src/server/routers/message'
|
||||||
|
|
||||||
|
describe('message.previewRecipients — PROJECT_TEAM', () => {
|
||||||
|
let programId: string
|
||||||
|
let admin: { id: string; email: string; role: 'SUPER_ADMIN' }
|
||||||
|
let projectId: string
|
||||||
|
const userIds: string[] = []
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const program = await createTestProgram({ name: `proj-team-${uid()}` })
|
||||||
|
programId = program.id
|
||||||
|
|
||||||
|
const lead = await createTestUser('APPLICANT')
|
||||||
|
userIds.push(lead.id)
|
||||||
|
const project = await createTestProject(programId, { title: 'TestProj' })
|
||||||
|
projectId = project.id
|
||||||
|
await prisma.project.update({ where: { id: projectId }, data: { submittedByUserId: lead.id } })
|
||||||
|
|
||||||
|
const member1 = await createTestUser('APPLICANT')
|
||||||
|
const member2 = await createTestUser('APPLICANT')
|
||||||
|
userIds.push(member1.id, member2.id)
|
||||||
|
await prisma.teamMember.createMany({
|
||||||
|
data: [
|
||||||
|
{ id: uid('tm'), projectId, userId: member1.id, role: 'MEMBER' },
|
||||||
|
{ id: uid('tm'), projectId, userId: member2.id, role: 'MEMBER' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const a = await createTestUser('SUPER_ADMIN')
|
||||||
|
userIds.push(a.id)
|
||||||
|
admin = { id: a.id, email: a.email, role: 'SUPER_ADMIN' }
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupTestData(programId, userIds)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts the lead + 2 team members', async () => {
|
||||||
|
const caller = createCaller(messageRouter, admin)
|
||||||
|
const result = await caller.previewRecipients({
|
||||||
|
recipientType: 'PROJECT_TEAM',
|
||||||
|
recipientFilter: { projectId },
|
||||||
|
})
|
||||||
|
expect(result.totalApplicants).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 0 when projectId is missing', async () => {
|
||||||
|
const caller = createCaller(messageRouter, admin)
|
||||||
|
const result = await caller.previewRecipients({
|
||||||
|
recipientType: 'PROJECT_TEAM',
|
||||||
|
recipientFilter: {},
|
||||||
|
})
|
||||||
|
expect(result.totalApplicants).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run, expect FAIL** — `'PROJECT_TEAM'` not in enum.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Patch `src/server/routers/message.ts`**
|
||||||
|
|
||||||
|
Replace ALL FIVE enum literal lines:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'PROJECT_TEAM', 'ALL']),
|
||||||
|
```
|
||||||
|
|
||||||
|
(Use Edit's `replace_all: true` since the line is identical at all 5 occurrences.)
|
||||||
|
|
||||||
|
Add a new case in `resolveRecipients` (after `'PROGRAM_TEAM'`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
case 'PROJECT_TEAM': {
|
||||||
|
const projectId = filter?.projectId as string
|
||||||
|
if (!projectId) return []
|
||||||
|
const [teamMembers, project] = await Promise.all([
|
||||||
|
prisma.teamMember.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
select: { userId: true },
|
||||||
|
}),
|
||||||
|
prisma.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
select: { submittedByUserId: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
const ids = new Set<string>()
|
||||||
|
for (const tm of teamMembers) ids.add(tm.userId)
|
||||||
|
if (project?.submittedByUserId) ids.add(project.submittedByUserId)
|
||||||
|
return [...ids]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Re-run, expect PASS.**
|
||||||
|
|
||||||
|
### Task 2: Build `<ProjectEmailDialog>`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the component** (full code in execution)
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
- Open via `open: boolean; onClose: () => void; projectId: string; projectTitle: string` props.
|
||||||
|
- On open, body field is pre-filled: ``Hello ${projectTitle} team,\n\n``. Cursor placed at the end.
|
||||||
|
- Subject field default: empty (admin types).
|
||||||
|
- Live HTML preview pane below the form, calling `message.previewEmail` with `{ subject, body }` (debounced 300ms).
|
||||||
|
- Recipient count line: calls `message.previewRecipients` with `PROJECT_TEAM` + `{ projectId }`. Shows "Will email N team members."
|
||||||
|
- "Send Test" button: sends to the admin only via `message.sendTest`.
|
||||||
|
- "Send" button: calls `message.send` with `recipientType: 'PROJECT_TEAM'`, `recipientFilter: { projectId }`, `deliveryChannels: ['EMAIL']`, `linkType: 'NONE'`.
|
||||||
|
- On success: toast + close dialog. On error: toast.
|
||||||
|
|
||||||
|
### Task 3: Wire the button on project detail page
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add button next to Edit** in `src/app/(admin)/admin/projects/[id]/page.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button variant="outline" onClick={() => setEmailDialogOpen(true)}>
|
||||||
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
|
Email Team
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
(Add `Mail` to the lucide imports. Add `useState` for `emailDialogOpen`.)
|
||||||
|
|
||||||
|
Render the dialog at the bottom of the page:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{project && (
|
||||||
|
<ProjectEmailDialog
|
||||||
|
open={emailDialogOpen}
|
||||||
|
onClose={() => setEmailDialogOpen(false)}
|
||||||
|
projectId={project.id}
|
||||||
|
projectTitle={project.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Verify + commit
|
||||||
|
|
||||||
|
- [ ] `npx vitest run tests/unit` → all pass.
|
||||||
|
- [ ] `npm run typecheck` → clean.
|
||||||
|
- [ ] `npm run build` → clean.
|
||||||
|
- [ ] Commit with message referencing PR 7.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Template picker (admins can use plain body for now; integration with template system can land later). HTML editor (plain textarea — same UX as messages page composer).
|
||||||
3476
docs/superpowers/plans/2026-04-29-pr6-lunch-event.md
Normal file
3476
docs/superpowers/plans/2026-04-29-pr6-lunch-event.md
Normal file
File diff suppressed because it is too large
Load Diff
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,218 @@
|
|||||||
|
# Juror-Balanced Scoring Toggle + Round-Scoping Fixes
|
||||||
|
|
||||||
|
**Status:** design
|
||||||
|
**Date:** 2026-04-27
|
||||||
|
**Author:** Matt + Claude
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Two related changes to the ranking system:
|
||||||
|
|
||||||
|
1. **Add a per-round toggle** that controls whether the ranking dashboard ranks projects by the juror-balanced (z-normalized) score or by the raw average. The toggle persists in `Round.configJson` and is shared across all viewers. Admins flip it from the side panel of the admin ranking dashboard; observers see the effect (which score is "active") but don't get the toggle UI themselves, matching today's role gates on the dashboard.
|
||||||
|
2. **Fix cross-round contamination** in two analytics procedures (`getProjectDetail`, `getProjectRankings`) and several UI surfaces that consume them. Per-juror balance contexts must be computed within a single round; aggregate stats (avg score, evaluator count, pass rate) must be scoped to the round being viewed.
|
||||||
|
|
||||||
|
A side panel "deeper display" replaces the small `⇢ X.X` annotation on the list view: the list view stays clean, and clicking into a project surfaces the raw + balanced numbers, the toggle, an explainer, and per-juror balance contributions.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
Juror-balanced scoring (`src/server/services/juror-balance.ts`) corrects for per-juror grading harshness using z-normalization. Each juror's scores are normalized against their own mean + stddev across the round, then rescaled onto the round's overall mean + stddev so balanced numbers are comparable to raw averages.
|
||||||
|
|
||||||
|
The math is correct, but two scoping problems exist:
|
||||||
|
|
||||||
|
**Problem 1 — `getProjectDetail` is round-blind.** The query at `src/server/routers/analytics.ts:1417-1422` pulls every SUBMITTED evaluation for a project across every round it ever participated in, then computes Avg Score / Evaluators / Pass Rate from that pool. Meanwhile the per-juror list rendered in the admin sheet at `src/components/admin/round/ranking-dashboard.tsx:1034-1036` filters to the current round. Result: stats card disagrees with the visible per-juror list.
|
||||||
|
|
||||||
|
**Problem 2 — `getProjectRankings` (programId/edition mode) pools z-context across rounds.** At `src/server/routers/analytics.ts:212-218`, when invoked with `programId` (instead of `roundId`), evaluations from every round in the edition are fed into a single `computeBalanceContext`. A juror's mean/stddev is then computed across mixed contexts (e.g. quick intake screening + deep evaluation), producing meaningless personal calibration.
|
||||||
|
|
||||||
|
Other call sites (`ranking.ts`, `ai-juror-calibration.ts`) already filter by round and are unaffected.
|
||||||
|
|
||||||
|
## Surfaces affected
|
||||||
|
|
||||||
|
| # | Surface | Procedure | Issue |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Admin ranking dashboard side sheet | `analytics.getProjectDetail` | Stats card pulls cross-round evals |
|
||||||
|
| 2 | Observer full project detail page | `analytics.getProjectDetail` | Same; observer-side |
|
||||||
|
| 3 | Observer reports preview dialog | `analytics.getProjectDetail` | Same; observer-side |
|
||||||
|
| 4 | Admin reports overview tab rankings | `analytics.getProjectRankings` | Edition mode uses cross-round z-context |
|
||||||
|
| 5 | Admin reports detail tab rankings | `analytics.getProjectRankings` | Same |
|
||||||
|
| 6 | Admin reports overview "Balanced Avg" tile | derives from #4 | Inherits the bad numbers |
|
||||||
|
| 7 | Result lock controls | `analytics.getProjectRankings` (roundId only) | OK — already round-scoped |
|
||||||
|
| 8 | Admin ranking dashboard list | `ranking.getRoundRanking` | OK — already filters by roundId |
|
||||||
|
| 9 | AI juror calibration service | self-contained | OK — already filters by roundId |
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Round-scoping fixes
|
||||||
|
|
||||||
|
#### `analytics.getProjectDetail`
|
||||||
|
|
||||||
|
- Add an optional `roundId` to the input schema.
|
||||||
|
- When `roundId` is provided, filter `submittedEvaluations` (the query at line 1417) by `assignment: { roundId }`. The stats block computed from those evaluations becomes round-scoped automatically.
|
||||||
|
- When `roundId` is not provided, return `stats: null` and a new field `statsByRound: Array<{ roundId, roundName, stats }>` so callers can render per-round breakdowns instead of one misleading aggregate. (The current dialogs always know which round they want — they just weren't passing it.)
|
||||||
|
- Pass `roundId` from the three callers (#1, #2, #3 above).
|
||||||
|
|
||||||
|
#### `analytics.getProjectRankings`
|
||||||
|
|
||||||
|
When called in edition mode (`programId` only), z-normalization must run **per round**, not across the pool:
|
||||||
|
|
||||||
|
1. Group `points: ScorePoint[]` by `roundId` (we'll need to include `roundId` in each point — currently `evalWhere` returns flat evaluations; add `assignment.round.id` to the select).
|
||||||
|
2. For each round, call `computeBalanceContext(pointsForRound)` and `computeBalancedProjectScores(pointsForRound, ctx)`.
|
||||||
|
3. Aggregate per-project: a project's edition-level `balancedScore` is the unweighted mean of its per-round balanced averages. Its `averageScore` (raw) is the unweighted mean of its per-round raw averages.
|
||||||
|
4. `evaluationCount` becomes the total across rounds (unchanged in spirit).
|
||||||
|
|
||||||
|
In `roundId` mode, behavior is unchanged.
|
||||||
|
|
||||||
|
#### Default round resolution (observer full project page, #2)
|
||||||
|
|
||||||
|
The observer page at `/observer/projects/[projectId]` doesn't know which round to focus on. Resolution logic:
|
||||||
|
|
||||||
|
```
|
||||||
|
Among rounds where ProjectRoundState exists for this project:
|
||||||
|
1. If exactly one round.status = ROUND_ACTIVE, use it.
|
||||||
|
2. Else use the most recent round with status = ROUND_CLOSED
|
||||||
|
(ordered by sortOrder desc, or exitedAt desc as tiebreak).
|
||||||
|
3. Else if only ROUND_DRAFT rounds exist, fall back to none (stats: null).
|
||||||
|
```
|
||||||
|
|
||||||
|
A small round selector chip near the stats card lets the user switch contexts; the URL updates with `?round=<id>`.
|
||||||
|
|
||||||
|
### 2. Per-round balanced-scoring toggle
|
||||||
|
|
||||||
|
#### Storage
|
||||||
|
|
||||||
|
Add `useBalancedRanking: boolean` to `Round.configJson` (default `true` — preserve current behavior). No schema migration needed since `configJson` is already a flexible JSON column.
|
||||||
|
|
||||||
|
#### tRPC procedure
|
||||||
|
|
||||||
|
Extend `ranking.updateConfig` (or add `setUseBalancedRanking`) — admin/observer-procedure level. The page is admin-only today, so observer access for this toggle would be a deliberate widening. **Decision: keep it `adminProcedure`** (PROGRAM_ADMIN + SUPER_ADMIN). The user said "anyone who can view should be able to toggle," and the page is gated to admins.
|
||||||
|
|
||||||
|
#### UI integration
|
||||||
|
|
||||||
|
- Toggle lives at the top of the side sheet (not the list view) — labeled "Use balanced scoring for ranking" with a help icon that opens the explainer.
|
||||||
|
- When toggled, the dashboard re-sorts immediately (the list-view sort at `ranking-dashboard.tsx:417,879` reads from `evalScores.balanced[id]?.balancedAverage`; we'll wrap that in `useBalancedRanking ? balanced : raw`).
|
||||||
|
- The list row's compact `⇢ X.X` annotation is **removed**. Visual delta lives in the side panel only.
|
||||||
|
|
||||||
|
### 3. Side panel deeper display
|
||||||
|
|
||||||
|
The existing side sheet (`ranking-dashboard.tsx:970-1090`) gains:
|
||||||
|
|
||||||
|
#### Stats area (replaces the current 3-card grid)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Avg Score │
|
||||||
|
│ Raw: 8.3 Balanced: 8.0 ← used for ranking │
|
||||||
|
│ │
|
||||||
|
│ Evaluators: 3 Pass Rate: 67% │
|
||||||
|
│ │
|
||||||
|
│ ⓘ How is this calculated? (collapsible) │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- "Raw" and "Balanced" sit side-by-side. The active one (per the round's toggle) gets a subtle "← used for ranking" tag and bolder weight.
|
||||||
|
- Both numbers always show one decimal (`.toFixed(1)`).
|
||||||
|
- Below the numbers, a clickable affordance: **"How scores are calculated"** (small button or link with an info icon). Clicking opens an explainer dialog (see "Score explainer dialog" below).
|
||||||
|
|
||||||
|
#### Per-juror rows (extends current `Juror Evaluations` block)
|
||||||
|
|
||||||
|
Each row currently shows `Name · Yes/No badge · Score: 9.0`. New layout when balanced is on:
|
||||||
|
|
||||||
|
```
|
||||||
|
Rachid Benchaouir Yes Score: 9.0 (typical 7.2 → contributes 8.5)
|
||||||
|
```
|
||||||
|
|
||||||
|
The trailing chip is muted text. When balanced is off, the chip is hidden. Tooltip on the chip explains the calculation.
|
||||||
|
|
||||||
|
#### Per-round toggle row at top
|
||||||
|
|
||||||
|
```
|
||||||
|
[Use balanced scoring for ranking] [toggle] ⓘ
|
||||||
|
```
|
||||||
|
|
||||||
|
Single horizontal row, just below the project header. Persists on flip. The ⓘ icon opens the same "How scores are calculated" dialog.
|
||||||
|
|
||||||
|
#### Score explainer dialog ("How scores are calculated")
|
||||||
|
|
||||||
|
A reusable dialog component (`<ScoreExplainerDialog />`) opens from the affordance in the side panel and from a matching affordance on the observer surfaces (#2, #3) so both audiences see the same explanation. Content is plain-language, not academic, and walks through one concrete worked example.
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
|
||||||
|
1. **What it does (1 paragraph)** — "Different jurors have different grading styles. Some grade harshly, some leniently. Balanced scoring corrects for that so a project isn't punished for drawing harsh jurors or rewarded for drawing lenient ones."
|
||||||
|
|
||||||
|
2. **How it works, step by step** — five short numbered points:
|
||||||
|
1. For each juror, calculate their personal average and spread across all the projects they scored in this round.
|
||||||
|
2. Convert each individual score into "how many standard deviations above or below this juror's typical" — a 6 from a juror who averages 5 reads the same as a 9 from a juror who averages 8.
|
||||||
|
3. Average those normalized values across the project's jurors.
|
||||||
|
4. Rescale back onto the same 1–10 scale using the round's overall average and spread.
|
||||||
|
5. The result is directly comparable to the raw average — same scale, but corrected for grading style.
|
||||||
|
|
||||||
|
3. **Worked example** — a concrete table using fabricated jurors, e.g.:
|
||||||
|
|
||||||
|
| Juror | Their typical avg | Their score for "Project X" | What that means |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Juror A (lenient) | 8.2 | 9.0 | Just slightly above their typical (+0.4σ) |
|
||||||
|
| Juror B (harsh) | 5.8 | 7.5 | Well above their typical (+1.5σ) |
|
||||||
|
| Juror C (typical) | 7.0 | 8.0 | Slightly above their typical (+0.7σ) |
|
||||||
|
|
||||||
|
"Raw average: (9.0 + 7.5 + 8.0) / 3 = **8.2**
|
||||||
|
Balanced average rescales each juror's enthusiasm to the round's overall scale and lands at **8.4** — Juror B's strong endorsement (well above their harsh baseline) carries more weight than the raw 7.5 suggests."
|
||||||
|
|
||||||
|
4. **When it kicks in / when it doesn't** — short paragraph:
|
||||||
|
- Needs ≥ 2 evaluations from the round to compute a juror's spread; otherwise that juror falls back to the round-wide average.
|
||||||
|
- Needs at least one juror with non-zero spread for the round; if everyone gave identical scores, balanced equals raw.
|
||||||
|
- Computed within a single round only — a juror's grading style in an intake screening round doesn't affect their balance in a deeper evaluation round.
|
||||||
|
|
||||||
|
5. **Why "Raw" is still shown** — "We always show both numbers so admins can sanity-check. The toggle at the top of the panel decides which one is used for ranking."
|
||||||
|
|
||||||
|
The dialog is a `shadcn/ui` `Dialog`, max-width ~`md`, scrollable. No live data — content is static text + the static example table. Lives in `src/components/shared/score-explainer-dialog.tsx` so it can be imported by admin and observer surfaces alike.
|
||||||
|
|
||||||
|
### 4. Decimal display audit
|
||||||
|
|
||||||
|
Standardize on **one decimal** for all balanced/raw score surfaces:
|
||||||
|
|
||||||
|
- `admin/reports/page.tsx:368` currently shows `toFixed(2)` — change to `toFixed(1)`.
|
||||||
|
- All other sites already use `.toFixed(1)` or compute integers.
|
||||||
|
|
||||||
|
## Data flow summary
|
||||||
|
|
||||||
|
```
|
||||||
|
Round.configJson.useBalancedRanking ──→ ranking-dashboard reads on mount
|
||||||
|
──→ list sort uses raw or balanced based on flag
|
||||||
|
──→ side panel shows both, marks the active one
|
||||||
|
|
||||||
|
getProjectDetail({ id, roundId }) ──→ filtered submittedEvaluations
|
||||||
|
──→ round-scoped stats
|
||||||
|
──→ optionally: per-round balance context computed
|
||||||
|
inline for the side panel deeper display
|
||||||
|
|
||||||
|
getProjectRankings({ programId }) ──→ group by roundId
|
||||||
|
──→ per-round balance context
|
||||||
|
──→ aggregate per-project means across rounds
|
||||||
|
```
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Migrating historical `ResultLock` snapshots that captured the old (potentially miscomputed) edition-level rankings. Past locks were round-scoped, so they're already correct; only the read-time edition rollup was broken.
|
||||||
|
- Exposing the toggle to OBSERVER role. Today it's admin-only, matching page access.
|
||||||
|
- AI calibration service changes — already round-scoped.
|
||||||
|
- Changing the underlying juror-balance math. The algorithm is correct; only the inputs needed scoping.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Edition rollup semantic change.** Anyone currently looking at "all rounds" balanced rankings sees different numbers after the fix. This is the right outcome but should be communicated to the team. The numbers shown today are not trustworthy.
|
||||||
|
- **Toggle default.** Defaulting `useBalancedRanking = true` preserves today's behavior. Existing rounds without the field set use the default.
|
||||||
|
- **Side-panel re-renders.** The toggle live-updates the list sort; ensure `useQuery` invalidations are wired so a flip in the panel triggers a re-fetch / re-sort without a full page reload.
|
||||||
|
|
||||||
|
## Open items
|
||||||
|
|
||||||
|
None blocking. Implementation plan can proceed.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
1. With 3 round-scoped evaluations of 9, 8, 8, the side panel stats card shows **Avg 8.3** (not 8.0) and **Evaluators 3** (not 5).
|
||||||
|
2. Flipping the per-round toggle re-sorts the list view; the choice persists across page reloads and is shared across users.
|
||||||
|
3. The list view shows no per-row balanced delta annotation.
|
||||||
|
4. The side panel always shows both Raw and Balanced; the active one is marked.
|
||||||
|
5. Edition-level rankings (`programId` mode) compute one balance context per round and aggregate, never pooling across rounds.
|
||||||
|
6. Observer project detail page defaults to the currently-active or most-recently-closed round the project participated in.
|
||||||
|
7. All score displays use one decimal.
|
||||||
|
8. A "How scores are calculated" affordance is present in the admin side panel, the observer full project page, and the observer reports preview dialog. Clicking it opens an explainer dialog with the algorithm summary, a step-by-step plain-language walkthrough, and a worked example.
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
# Mentor Round Readiness — End-to-End Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-28
|
||||||
|
**Author:** Matt + Claude (brainstorming session)
|
||||||
|
**Status:** Draft, awaiting review
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
R5 (Semi-Final Evaluation) is about to close. Next is R6 (Mentoring) for projects that request or are assigned a mentor, then R7 (Grand Final). The MENTORING backend exists but has gaps that block operational use:
|
||||||
|
|
||||||
|
- Admin Config form omits two `MentoringConfigSchema` fields (`mentoringRequestDeadlineDays`, `passThroughIfNoRequest`)
|
||||||
|
- Round Overview shows generic stats only — no mentor-specific dashboard
|
||||||
|
- `/admin/projects/[id]/mentor` exposes only AI suggestions; manual mentor selection is missing entirely from the UI
|
||||||
|
- File uploads (`mentor.workspaceUploadFile`) accept client-controlled `bucket` / `objectKey` — security/consistency hole
|
||||||
|
- Juror "Confirm Your Evaluation Preferences" banner pulls in LIVE_FINAL groups (not appropriate for a live ceremony)
|
||||||
|
- Multi-role users (juror + mentor) land on primary role's dashboard only; no quick path for an admin to bulk-promote jurors
|
||||||
|
- Zero tests for MENTORING round behavior
|
||||||
|
|
||||||
|
This spec covers all of the above plus workspace messaging/file UX polish, in one design with phased PRs.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Admin can fully configure a MENTORING round from the UI (no DB-direct edits needed for any `MentoringConfigSchema` field).
|
||||||
|
2. Admin can see at a glance: who requested mentoring, who has a mentor, who doesn't, who's mentoring whom, what the mentor pool looks like.
|
||||||
|
3. Admin can manually assign a mentor to any project, AND auto-fill all unassigned projects in one action.
|
||||||
|
4. Files uploaded in the mentor workspace land at `<projectName>/mentorship/<file>` in the configured bucket, with paths constructed server-side.
|
||||||
|
5. Mentors and applicant teams see recent messages on their respective dashboards.
|
||||||
|
6. A juror who is also a mentor can switch dashboards in one click, without seeing irrelevant LIVE_FINAL preference cards.
|
||||||
|
7. The MENTORING round behavior (pass-through, eligibility, advancement) is covered by integration tests.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Redesigning messaging or notifications from scratch.
|
||||||
|
- Replacing the AI mentor-matching service with a different model.
|
||||||
|
- Building a mentor scheduling/calendar feature.
|
||||||
|
- Bulk-promoting jurors to mentors via CSV import (per-row checkbox + bulk action is enough for this iteration).
|
||||||
|
- Migrating any existing mentor file objects in MinIO (none exist yet — spec asserts a pre-flight check).
|
||||||
|
|
||||||
|
## Out-of-scope but adjacent
|
||||||
|
|
||||||
|
- Grand Finale (R7 LIVE_FINAL) UX — explicitly deferred per user direction (handled separately, much further build-out planned).
|
||||||
|
- Mentor pool capacity / load-balancing algorithm changes — covered only by surfacing existing fields in the admin view.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-level architecture
|
||||||
|
|
||||||
|
No new top-level architecture. Extending existing patterns:
|
||||||
|
|
||||||
|
- **Storage path:** new helper `generateMentorObjectKey(projectTitle, fileName)` in `src/lib/minio.ts` that returns `<sanitizedProjectName>/mentorship/<timestamp>-<sanitizedFileName>` — exact same shape as `generateObjectKey()` with `roundName="mentorship"`. Server-side only.
|
||||||
|
- **Config schema:** no Prisma migration. The two missing fields (`mentoringRequestDeadlineDays`, `passThroughIfNoRequest`) already exist in `MentoringConfigSchema` and are read by `round-engine.ts` and `applicant.ts` — only the form needs updating.
|
||||||
|
- **Multi-role dashboards:** existing `User.roles UserRole[]` array drives everything; logic-only changes (post-login redirect priority, bulk-promote bulk action, fix CSS layering on impersonation banner).
|
||||||
|
- **Preferences filter:** single Prisma query change in `getOnboardingContext`.
|
||||||
|
- **Workspace dashboards:** reuse existing `MentorMessage` table; new tRPC procedures return last-N message previews.
|
||||||
|
|
||||||
|
## Phasing / PR plan
|
||||||
|
|
||||||
|
Six PRs, ordered smallest-blast-radius first:
|
||||||
|
|
||||||
|
| PR | Section | Risk | What ships |
|
||||||
|
|----|---------|------|------------|
|
||||||
|
| 1 | §E | Low | Filter `getOnboardingContext` to review-only rounds |
|
||||||
|
| 2 | §F.1 | Low | Server-side `objectKey` enforcement + `generateMentorObjectKey` helper |
|
||||||
|
| 3 | §A | Med | Config form completeness (2 missing inputs + General Settings cleanup + Launch Readiness gate relax) |
|
||||||
|
| 4 | §C | Med | Manual mentor picker + bulk auto-fill + AI fallback |
|
||||||
|
| 5 | §B | Med | Mentor-specific Round Overview + un-redirect `/admin/mentors` |
|
||||||
|
| 6 | §D + §F.2 | Med | Multi-role redirect priority + bulk-promote + impersonation banner fix + dashboard message previews |
|
||||||
|
| (continuous) | §G | Low | Tests added in each PR for the surface changing in that PR |
|
||||||
|
|
||||||
|
A standalone test PR is *not* planned — tests ride with the change they cover.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §A. MENTORING round Config form
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/components/admin/round-config/mentoring-config.tsx` (likely path; locate the round-type-specific config component used by `(admin)/admin/rounds/[roundId]` Config tab)
|
||||||
|
- `src/components/admin/round-config/launch-readiness.tsx` (or similar — the component that renders the 0/3 readiness checklist)
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
|
||||||
|
1. Add **"Mentoring Request Window"** section to the Config form:
|
||||||
|
- Numeric input bound to `configJson.mentoringRequestDeadlineDays` — int, min 1, max 90, default 14.
|
||||||
|
- Help text: "Number of days from round opening during which teams may request mentoring. After this window, no new requests are accepted."
|
||||||
|
2. Add **"Pass-through behavior"** toggle bound to `configJson.passThroughIfNoRequest`:
|
||||||
|
- Default `true` (matches schema default).
|
||||||
|
- Off-state label: "Hold all projects in PENDING until mentor is assigned (manual gate)"
|
||||||
|
- On-state label: "Auto-PASS projects that don't request mentoring (default)"
|
||||||
|
3. Replace empty **"General Settings"** section header. Either:
|
||||||
|
- Delete the empty header (preferred — fewer questions); OR
|
||||||
|
- Move the eligibility dropdown into it (so the section has content).
|
||||||
|
4. Relax Launch Readiness "File requirements set" gate for MENTORING rounds:
|
||||||
|
- Required only when `configJson.filePromotionEnabled === true` AND `configJson.promotionTargetWindowId` is set (i.e., the round is configured to promote mentor-authored files into a downstream submission window).
|
||||||
|
- Otherwise treat the readiness item as N/A and don't count it against the 0/3 (it becomes 0/2 for mentoring rounds without promotion configured).
|
||||||
|
5. Help-text added to the existing **Eligibility** dropdown explaining each option:
|
||||||
|
- `requested_only` — only projects that flag `mentoringRequested` participate (default).
|
||||||
|
- `all_advancing` — every project advancing into this round gets a mentor.
|
||||||
|
- `admin_selected` — admin manually picks which projects participate.
|
||||||
|
|
||||||
|
**Tests** (in PR 3): one per `MentoringConfigSchema` field — render with default config, change input, submit, assert config persisted via the existing config-save mutation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §B. Mentoring-specific admin views
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (Round Overview tab)
|
||||||
|
- `src/app/(admin)/admin/rounds/[roundId]/projects-tab.tsx` (Projects tab — exact filename to confirm during impl)
|
||||||
|
- `src/app/(admin)/admin/mentors/page.tsx` (currently a redirect stub — replace with a real list page)
|
||||||
|
- `src/app/(admin)/admin/mentors/[id]/page.tsx` (also a stub today; replace with mentor detail)
|
||||||
|
- New tRPC procedures on `mentor` router (admin-gated): `getRoundStats`, `getMentorPool`, `getMentorDetail`
|
||||||
|
|
||||||
|
**Round Overview — replace generic Round Details with a mentoring-specific stats card** when `round.roundType === 'MENTORING'`:
|
||||||
|
|
||||||
|
- **Top-line counts** (single row of stat cards):
|
||||||
|
- Total projects in round
|
||||||
|
- Requested mentoring (count + % of total)
|
||||||
|
- Mentor assigned (count + % of total)
|
||||||
|
- Awaiting assignment (= requested - assigned)
|
||||||
|
- **Request window** card:
|
||||||
|
- Deadline (computed from `windowOpenAt + mentoringRequestDeadlineDays`)
|
||||||
|
- Time remaining (live countdown, using existing `formatCountdown` helper)
|
||||||
|
- "Closes in N days" pill, turns amber within 48 hours, red within 12 hours
|
||||||
|
- **Mentor pool** card:
|
||||||
|
- Pool size (count of users with MENTOR role in the program)
|
||||||
|
- Average load (assigned projects ÷ pool size)
|
||||||
|
- Capacity remaining (sum of `User.maxAssignmentsOverride` minus current load, where overrides exist)
|
||||||
|
- Link → `/admin/mentors`
|
||||||
|
- **Workspace activity** card:
|
||||||
|
- Total messages exchanged (sum across all assignments in round)
|
||||||
|
- Total files uploaded
|
||||||
|
- Total milestones completed
|
||||||
|
- "Last activity" timestamp
|
||||||
|
|
||||||
|
**Round Details panel** stays at the bottom of the Overview tab when round is MENTORING (the existing panel is still useful for type/status/position/dates), but with these field-level adjustments:
|
||||||
|
- Replace "Jury Group: —" row with "Mentor Pool: N members" (link to `/admin/mentors`).
|
||||||
|
- Keep "Type", "Status", "Position", "Opens", "Closes" rows unchanged.
|
||||||
|
- The new "mentoring stats card" (top-line counts, request window, mentor pool, workspace activity) renders **above** the Round Details panel, not in place of it.
|
||||||
|
|
||||||
|
**Projects tab — when round is MENTORING**, the per-project row shows:
|
||||||
|
- Project title + team lead
|
||||||
|
- "Requested mentoring" badge (yes/no)
|
||||||
|
- "Mentor assigned" cell — mentor name + expertise overlap chip, OR "Unassigned" with inline "Assign" button → opens the manual-pick drawer (see §C)
|
||||||
|
- "Workspace activity" small-text summary (msgs / files / milestones)
|
||||||
|
- Bulk action bar (when ≥1 project selected): "Auto-fill mentors for selected" → calls `mentor.autoAssignBulk`
|
||||||
|
|
||||||
|
**`/admin/mentors` — un-redirect, replace stub with a real list page:**
|
||||||
|
- Searchable/filterable list of all users with MENTOR role in the current edition.
|
||||||
|
- Columns: name, email, country, expertise tags (chips), assigned-projects count, completed count, capacity remaining, last activity.
|
||||||
|
- Row → `/admin/mentors/[id]` detail page (existing route, replace stub):
|
||||||
|
- Mentor profile + expertise + bio
|
||||||
|
- List of assigned projects (link to per-project workspace)
|
||||||
|
- Per-project status (in_progress / completed / paused)
|
||||||
|
- Recent activity feed (messages / file uploads / milestone completions across all assignments)
|
||||||
|
- Admin actions: reassign / unassign
|
||||||
|
|
||||||
|
**Tests** (in PR 5): integration test for `getRoundStats` returning correct counts; render-test for round overview when round.roundType=MENTORING.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §C. Manual + auto-fill mentor assignment
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/app/(admin)/admin/projects/[id]/mentor/page.tsx` (rewrite)
|
||||||
|
- `src/server/services/mentor-matching.ts` (add expertise-tag fallback)
|
||||||
|
- `src/server/routers/mentor.ts` (`getCandidates` new procedure for manual picker; ensure `autoAssignBulk` exposes a "skip already assigned" param — confirm and document)
|
||||||
|
|
||||||
|
**Page rewrite — three sections, all visible at once (not tabs):**
|
||||||
|
|
||||||
|
1. **Project Context** card (top):
|
||||||
|
- Project title, ocean issue, country, team size, expertise needs (project tags)
|
||||||
|
- Round being assigned for (linked)
|
||||||
|
- Mentoring requested? Yes/no
|
||||||
|
2. **Currently Assigned** card:
|
||||||
|
- If assigned: mentor name, email, country, expertise overlap chips, "Assigned by [admin], 3 days ago, method: MANUAL/AUTO", actions: Unassign | Swap
|
||||||
|
- If unassigned: empty state with copy "No mentor assigned yet — pick one below or use AI"
|
||||||
|
3. **Pick a mentor** card with a tab strip:
|
||||||
|
- **Tab 1 — Manual picker** (default selected):
|
||||||
|
- Searchable input
|
||||||
|
- Sortable table of all MENTOR-role users in the program: name, expertise tags, country, current load, capacity, **expertise overlap with this project** (computed: count of shared tags / total project tags, displayed as a percentage chip)
|
||||||
|
- Default sort: highest expertise overlap first
|
||||||
|
- Per-row "Assign" button → calls `mentor.assign({ projectId, mentorId, method: 'MANUAL' })`
|
||||||
|
- **Tab 2 — AI suggestions**:
|
||||||
|
- Existing pane (loads `getSuggestions`).
|
||||||
|
- **Fallback**: if AI fails (no `OPENAI_API_KEY`, network error, or returns empty) — show expertise-tag-overlap ranking as the suggestion source instead, with a banner: "AI matching unavailable — showing expertise-tag overlap instead". (The fallback ranking is the same algorithm as Tab 1's default sort, so the lists may look similar — that's fine.)
|
||||||
|
|
||||||
|
**Auto-fill remainder** (bulk action):
|
||||||
|
- On round Projects tab + Round Overview, button: "Auto-fill mentors for unassigned projects".
|
||||||
|
- Call `mentor.autoAssignBulk` with the round ID; the service filters to projects-in-round-without-MentorAssignment, scoped further by the round's `eligibility` config:
|
||||||
|
- `requested_only` → only projects with `mentoringRequested=true`
|
||||||
|
- `all_advancing` → every project in the round
|
||||||
|
- `admin_selected` → button disabled (admins must pick manually for this mode)
|
||||||
|
- Confirm the existing service already skips projects with a MentorAssignment (any method); if it doesn't, fix in the same PR.
|
||||||
|
- Result toast: "Assigned N projects, skipped M already-assigned, K unassignable (no matching mentor)".
|
||||||
|
|
||||||
|
**Tests** (in PR 4):
|
||||||
|
- `mentor.assign` round-trips with method=MANUAL
|
||||||
|
- `mentor.autoAssignBulk` skips manually-assigned projects
|
||||||
|
- `getCandidates` returns expected expertise-overlap ordering
|
||||||
|
- Fallback path used when AI unavailable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §D. Juror→mentor multi-role UX
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/app/page.tsx` (post-login redirect)
|
||||||
|
- `src/app/(admin)/admin/members/page.tsx` (bulk action)
|
||||||
|
- `src/components/layouts/role-nav.tsx` (no change — switcher already correct)
|
||||||
|
- `src/components/layouts/impersonation-banner.tsx` (or wherever the banner lives — find via grep)
|
||||||
|
- `src/server/routers/user.ts` (new `bulkUpdateRoles` mutation if not exists)
|
||||||
|
- `src/lib/email/templates/mentor-onboarding.tsx` (new)
|
||||||
|
- `src/server/services/notifications.ts` (or equivalent — call site to send mentor-onboarding email when MENTOR role is freshly added to a user)
|
||||||
|
|
||||||
|
**1. Post-login redirect — context-aware "go where the work is":**
|
||||||
|
|
||||||
|
Replace single-`role` switch in `src/app/page.tsx` with a priority list that is *filtered by actionable work*. The user lands on the highest-priority role for which they have something to do right now; if no role has active work, fall back to the static priority order.
|
||||||
|
|
||||||
|
New tRPC query: `user.getDefaultDashboard()` (server-side, called from `src/app/page.tsx`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Static priority — used as fallback ordering AND as the order we check for work.
|
||||||
|
const ROLE_DASHBOARD_PRIORITY: Array<[UserRole, Route]> = [
|
||||||
|
['SUPER_ADMIN', '/admin'],
|
||||||
|
['PROGRAM_ADMIN', '/admin'],
|
||||||
|
['AWARD_MASTER', '/award-master'],
|
||||||
|
['JURY_MEMBER', '/jury'],
|
||||||
|
['MENTOR', '/mentor'],
|
||||||
|
['APPLICANT', '/applicant'],
|
||||||
|
['OBSERVER', '/observer'],
|
||||||
|
['AUDIENCE', '/audience'],
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
For each role the user holds (in priority order), the server checks "does this user have actionable work in this role right now?":
|
||||||
|
|
||||||
|
| Role | "Has actionable work" predicate |
|
||||||
|
|------|---------------------------------|
|
||||||
|
| SUPER_ADMIN / PROGRAM_ADMIN | Always true (admin work is always present) |
|
||||||
|
| AWARD_MASTER | Any unfinalized award decision in an active round in current edition |
|
||||||
|
| JURY_MEMBER | Any `JuryAssignment` linked to a round whose `status = ROUND_ACTIVE` AND the user has at least one PENDING evaluation |
|
||||||
|
| MENTOR | Any `MentorAssignment` whose linked round is `ROUND_ACTIVE` AND `workspaceEnabled = true` |
|
||||||
|
| APPLICANT | Any `Project` led by user with at least one `ProjectRoundState` in a non-terminal state in an active round |
|
||||||
|
| OBSERVER | Always false (observers have nothing to act on) |
|
||||||
|
| AUDIENCE | Always false |
|
||||||
|
|
||||||
|
Algorithm:
|
||||||
|
|
||||||
|
1. Try roles in priority order. Return the first role whose predicate is true.
|
||||||
|
2. If no role has actionable work, return the highest-priority role the user holds (static fallback).
|
||||||
|
3. Always end with a non-null route (worst case: any signed-in user has at least their primary role).
|
||||||
|
|
||||||
|
**Why this matters (your example):** a juror+observer who logs in during an open jury round lands on `/jury` (because they have a pending evaluation), not `/observer`. A mentor+juror logs in during an active MENTORING round → `/mentor`. After both rounds close, same user logs in → static fallback (jury > mentor) → `/jury`. The role switcher in the user menu is always available to override.
|
||||||
|
|
||||||
|
**Decision: context-aware, not "remember last view".** "Remember last view" requires a new column and surprises users when their last context disappears (round closed, role removed). Context-aware is deterministic, explains itself, and handles the cross-role overlap cleanly. The role switcher dropdown is the user's escape hatch.
|
||||||
|
|
||||||
|
**Tests** (in PR 6):
|
||||||
|
- Juror with pending evaluation in active round + Observer → `/jury`
|
||||||
|
- Juror with no active assignments + Observer → `/jury` (fallback to static priority)
|
||||||
|
- Mentor+Juror, MENTORING round active, no jury work → `/mentor`
|
||||||
|
- Mentor+Juror, both rounds active with work in both → `/jury` (priority order breaks the tie)
|
||||||
|
- Observer-only user → `/observer`
|
||||||
|
- Multi-role with no active work anywhere → static-priority fallback
|
||||||
|
|
||||||
|
**2. Bulk juror→mentor promotion** on `/admin/members`:
|
||||||
|
- Add row checkboxes to the Members table (already a table — confirm during impl).
|
||||||
|
- When ≥1 row selected, surface a bulk action toolbar with "Add role…" dropdown (OBSERVER / MENTOR / AWARD_MASTER) and "Remove role…".
|
||||||
|
- Call new `user.bulkUpdateRoles({ userIds, addRole?, removeRole? })` mutation. Server-side: only SUPER_ADMIN/PROGRAM_ADMIN, log a `DecisionAuditLog` entry per user changed.
|
||||||
|
- After success, refresh the table and toast "Added MENTOR role to N users; M already had it (no-op)".
|
||||||
|
|
||||||
|
**3. Mentor-onboarding email** (one-shot):
|
||||||
|
- New email template at `src/lib/email/templates/mentor-onboarding.tsx`: brief welcome, explanation of mentor responsibilities, link to `/mentor`, link to "Switch View" doc/walkthrough.
|
||||||
|
- Trigger: in `user.bulkUpdateRoles` and the existing single-user `updateRoles` mutation, when MENTOR is **newly** added (i.e., wasn't in `roles[]` before this update) → enqueue the email. Idempotent on subsequent edits that keep MENTOR in `roles`.
|
||||||
|
- Add a `User.mentorOnboardingSentAt: DateTime?` column for idempotency. Migration: nullable column, no backfill needed.
|
||||||
|
|
||||||
|
**4. Fix impersonation banner pointer-events:**
|
||||||
|
- Locate the banner component (grep `Impersonating` / `bg-red-600 fixed top-0`).
|
||||||
|
- Restructure: banner sits in a flex container above the header rather than being `position: fixed` over it. The header height stays unchanged; the banner pushes content down.
|
||||||
|
- Alternative (smaller change): keep `position: fixed` but `pointer-events: none` on the banner div and re-enable `pointer-events: auto` on the inner "Return to Admin" button only. Either fixes the menu intercept.
|
||||||
|
- Pick the simpler diff at impl time; document choice in PR.
|
||||||
|
|
||||||
|
**5. Banner shows all roles:**
|
||||||
|
- When `session.user.roles.length > 1`, render comma-separated list: "Impersonating Dr. Sophie Laurent (JURY MEMBER, MENTOR)".
|
||||||
|
|
||||||
|
**6. Standardize the role-switcher (location + presentation):**
|
||||||
|
|
||||||
|
Today's state:
|
||||||
|
- Header layouts (`role-nav.tsx`) — used by jury, mentor, applicant, observer, award-master — put the user menu **top-right** with role-switcher items inside the dropdown.
|
||||||
|
- Admin layout (`admin-sidebar.tsx`) puts the user menu **bottom-left of the sidebar** with its own duplicate `ROLE_SWITCH_OPTIONS` constant + `switchableRoles` filter (lines 161, 191, 377-401).
|
||||||
|
|
||||||
|
Two problems: (a) duplicated logic across two files; (b) different physical placement, so a multi-role user has to learn two patterns to find "Switch View".
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
|
||||||
|
- **Extract a shared module** at `src/components/layouts/role-switcher.tsx` exporting:
|
||||||
|
- `useRoleSwitcher()` hook returning `{ switchableRoles: Array<{ label, path, icon }>, currentBasePath }`. Both `role-nav.tsx` and `admin-sidebar.tsx` import this. Source of truth for `ROLE_SWITCH_OPTIONS` lives here only.
|
||||||
|
- `RoleSwitcherMenuItems` component — renders the dropdown items (used inside both layouts' user menus). Keeps rendering inline-consistent.
|
||||||
|
- `RoleSwitcherPill` component — a standalone visible button that renders just outside the user-menu dropdown, with label "Switch View" + the icon of the next-best alternate role. Visible only when `switchableRoles.length > 0`. Click opens a small popover listing alternates.
|
||||||
|
|
||||||
|
- **Place the `RoleSwitcherPill` in a consistent location across all layouts**: top-right of the header, immediately to the LEFT of the notifications bell. For the admin layout (sidebar-based), add a top-right header strip that hosts the pill + notifications + theme toggle, mirroring the other dashboards. (The admin sidebar keeps everything else; just the top-bar is added.)
|
||||||
|
|
||||||
|
Why top-right: that's where the existing role-nav layouts already put switching/profile actions. Admins gain the pill in the same spot — no learning curve when switching from /admin to /jury.
|
||||||
|
|
||||||
|
- **Pill behavior:**
|
||||||
|
- Hidden if `switchableRoles.length === 0` (single-role users see nothing — clean default).
|
||||||
|
- Hidden when `isImpersonating` (impersonator UX is already different; the existing impersonation banner with "Return to Admin" handles role-switching for that path).
|
||||||
|
- On hover/focus: shows tooltip "Switch dashboard view".
|
||||||
|
- Keyboard: `Cmd+Shift+V` shortcut opens the popover (nice-to-have; ship if it doesn't add much code).
|
||||||
|
|
||||||
|
- **Admin sidebar bottom user pill stays** (so admin users can still sign out / open settings from there). The role-switcher items are removed from that menu — they live exclusively in the new pill + the user-dropdown's switch list. (Avoids three places to switch view.)
|
||||||
|
|
||||||
|
**Acceptance for §D.6:** any signed-in user with `roles.length > 1` sees a "Switch View" pill in the same screen position regardless of which dashboard they're currently in.
|
||||||
|
|
||||||
|
**Tests** (in PR 6):
|
||||||
|
- `user.getDefaultDashboard` test cases enumerated above (juror+observer with active jury round → /jury; etc.).
|
||||||
|
- `bulkUpdateRoles` adds MENTOR to N users and sends N onboarding emails.
|
||||||
|
- Idempotency: second `bulkUpdateRoles` with same input does NOT resend email.
|
||||||
|
- Impersonation banner does not intercept clicks on user dropdown (Playwright e2e if available).
|
||||||
|
- `RoleSwitcherPill` renders in the top-right of every dashboard for multi-role users; renders nothing for single-role users.
|
||||||
|
- Single shared `useRoleSwitcher` source means changing `ROLE_SWITCH_OPTIONS` updates both layouts simultaneously.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §E. Filter juror preferences to review-only rounds (PR 1)
|
||||||
|
|
||||||
|
**File:** `src/server/routers/user.ts:1397-1422` (`getOnboardingContext`)
|
||||||
|
|
||||||
|
**Change:** Query the membership's jury group, including its linked rounds. Filter out memberships where every linked round is LIVE_FINAL or DELIBERATION. Keep memberships where at least one linked round is INTAKE / FILTERING / EVALUATION / SUBMISSION / MENTORING.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const memberships = await ctx.prisma.juryGroupMember.findMany({
|
||||||
|
where: {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
juryGroup: {
|
||||||
|
rounds: {
|
||||||
|
some: {
|
||||||
|
roundType: {
|
||||||
|
in: ['INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { juryGroup: { select: { id: true, name: true, defaultMaxAssignments: true } } },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
(Confirm the relation field name `rounds` on `JuryGroup` during impl — Prisma schema field may be `Round[]` named differently.)
|
||||||
|
|
||||||
|
**Tests** (in PR 1):
|
||||||
|
- Juror with memberships in (Screening: FILTERING) + (Finals: LIVE_FINAL) → only Screening returned.
|
||||||
|
- Juror with memberships in (Mixed: EVALUATION + LIVE_FINAL) → returned (group has at least one review round).
|
||||||
|
- Juror with only (Finals: LIVE_FINAL) → no memberships returned.
|
||||||
|
|
||||||
|
**Risk:** very low. Single procedure, additive Prisma filter, easy to revert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §F. Workspace messaging + files end-to-end
|
||||||
|
|
||||||
|
### §F.1 — Server-side path enforcement (PR 2)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/lib/minio.ts` (add helper)
|
||||||
|
- `src/server/routers/mentor.ts` (`workspaceUploadFile` procedure + presign procedure)
|
||||||
|
- `src/server/services/mentor-workspace.ts` (`uploadFile` service)
|
||||||
|
|
||||||
|
**New helper** in `src/lib/minio.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function generateMentorObjectKey(projectTitle: string, fileName: string): string {
|
||||||
|
return generateObjectKey(projectTitle, fileName, 'mentorship')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces `<sanitizedProjectName>/mentorship/<timestamp>-<sanitizedFileName>`, matching the existing project-file scheme.
|
||||||
|
|
||||||
|
**Procedure changes:**
|
||||||
|
|
||||||
|
1. Add a presign procedure (if not present): `mentor.presignWorkspaceUpload({ mentorAssignmentId, fileName, mimeType, size })` →
|
||||||
|
- Loads the `MentorAssignment` + linked `Project` (server-side).
|
||||||
|
- Authorizes: user is the assigned mentor OR a project team member (mentorProcedure for mentors; protectedProcedure with project-team check for applicants).
|
||||||
|
- Constructs `objectKey = generateMentorObjectKey(project.title, fileName)`.
|
||||||
|
- Returns `{ uploadUrl, bucket, objectKey }` — the presigned PUT URL is short-lived (1h).
|
||||||
|
2. Change `workspaceUploadFile` to accept ONLY `{ uploadToken, description? }` (where `uploadToken` is an opaque value returned by the presign call). The presign procedure stores `{ token → { mentorAssignmentId, fileName, mimeType, size, bucket, objectKey } }` in a short-lived cache (in-memory or Redis if configured, 1h TTL). The upload procedure looks up the token, validates that the user is the same one who called presign, then writes the `MentorFile` row using the cached values. This eliminates any client-controlled path entirely.
|
||||||
|
3. Mirror the same change for applicant-side uploads to mentor workspace (if a separate procedure exists).
|
||||||
|
|
||||||
|
**Migration:** Pre-flight — confirm `MentorFile` table is empty (or only test data) in production. If it has any rows, migrate `objectKey`s to the new scheme via a one-shot script; otherwise skip migration.
|
||||||
|
|
||||||
|
**Tests** (in PR 2):
|
||||||
|
- Presign returns key matching `<projectName>/mentorship/<timestamp>-<file>` shape.
|
||||||
|
- `workspaceUploadFile` rejects payloads that include `bucket` or `objectKey` (input schema rejects unknown fields via Zod).
|
||||||
|
- Authorization: mentor uploading to a workspace they're NOT assigned to → throws TRPCError UNAUTHORIZED.
|
||||||
|
|
||||||
|
### §F.2 — Dashboard message previews (PR 6)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- New component: `src/components/mentor/recent-messages-card.tsx`
|
||||||
|
- New component: `src/components/applicant/mentor-conversation-card.tsx`
|
||||||
|
- `src/app/(mentor)/mentor/page.tsx` — embed RecentMessagesCard
|
||||||
|
- `src/app/(applicant)/applicant/page.tsx` — embed MentorConversationCard (only render when project has mentorAssignment + workspace enabled)
|
||||||
|
- `src/server/routers/mentor.ts` — new procedure `getRecentMessagesForMentor` (returns last N msgs across all assignments)
|
||||||
|
- `src/server/routers/applicant.ts` — new procedure `getMentorConversationPreview({ projectId })` (returns last 3 msgs + unread count for one project)
|
||||||
|
|
||||||
|
**Mentor dashboard preview**:
|
||||||
|
- Card title: "Recent Messages"
|
||||||
|
- Shows last 5 unread messages across ALL assignments (sender name + project + first 100 chars + relative timestamp).
|
||||||
|
- Each row links to `/mentor/workspace/<projectId>` (jumps to that conversation).
|
||||||
|
- "View all" link → `/mentor/messages` (existing or new index — confirm during impl).
|
||||||
|
- Empty state: "No new messages. Your mentees will appear here when they reach out."
|
||||||
|
|
||||||
|
**Applicant dashboard preview** (only when project has assigned mentor + workspace enabled):
|
||||||
|
- Card title: "Conversation with [Mentor Name]"
|
||||||
|
- Shows last 3 messages (sender name + content + timestamp).
|
||||||
|
- Unread count badge.
|
||||||
|
- "Send a message" inline composer or "Open chat" button → `/applicant/mentor`.
|
||||||
|
- Empty state: "Say hi to your mentor — they're here to help you sharpen your project."
|
||||||
|
|
||||||
|
**Performance:** both queries use indexed lookups on `MentorMessage(workspaceId, createdAt)`. Add an index migration if not present.
|
||||||
|
|
||||||
|
**Tests** (in PR 6):
|
||||||
|
- `getRecentMessagesForMentor` returns N most-recent unread messages across assignments.
|
||||||
|
- `getMentorConversationPreview` returns 3 most-recent messages + correct unread count.
|
||||||
|
- Renders gracefully when no assignment / no messages.
|
||||||
|
|
||||||
|
### §F.3 — End-to-end verification scenario (covered in §G)
|
||||||
|
|
||||||
|
A single integration test walking through the full happy path. See §G.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §G. Tests
|
||||||
|
|
||||||
|
**New test files:**
|
||||||
|
- `tests/unit/mentor-config.test.ts` (PR 3) — Config form persistence per field
|
||||||
|
- `tests/unit/mentor-key-construction.test.ts` (PR 2) — `generateMentorObjectKey` shape + sanitization
|
||||||
|
- `tests/integration/mentor-assignment.test.ts` (PR 4) — manual + auto + bulk + skip
|
||||||
|
- `tests/integration/mentor-round-engine.test.ts` (NEW for PR 3 or PR 5) — pass-through behavior, eligibility variants, advancement
|
||||||
|
- `tests/integration/mentor-workspace.test.ts` (PR 6) — message + file lifecycle, dashboard previews, milestone auto-complete
|
||||||
|
- `tests/unit/jury-preferences-filter.test.ts` (PR 1) — `getOnboardingContext` filter
|
||||||
|
|
||||||
|
**End-to-end happy path** (`tests/integration/mentor-round-e2e.test.ts`, ships with PR 6):
|
||||||
|
|
||||||
|
1. Admin creates a MENTORING round, sets dates + eligibility=requested_only + 14-day deadline.
|
||||||
|
2. Admin activates round.
|
||||||
|
3. Project A has `mentoringRequested=true`, project B does not.
|
||||||
|
4. Round-engine activation: B auto-PASSED (pass-through), A stays PENDING.
|
||||||
|
5. Admin manually assigns mentor M1 to project A. A flips PENDING → IN_PROGRESS. Mentor + team get assignment notification.
|
||||||
|
6. M1 sends a message in workspace; team replies. Both messages appear in respective dashboard previews.
|
||||||
|
7. M1 uploads a file. ObjectKey matches `<projectA-title>/mentorship/<timestamp>-...`. Team comments on the file.
|
||||||
|
8. M1 marks all required milestones complete → assignment.completionStatus = "completed".
|
||||||
|
9. Admin closes round. A and B both PASSED; A also COMPLETED.
|
||||||
|
|
||||||
|
This single test covers the operational path the user actually cares about for the upcoming round.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. **`generateMentorObjectKey` — which "project name" field do we pass?** `Project.title` is the obvious choice (it's what `generateObjectKey` for submission files uses). Confirm during impl that there's no team-name-specific field we should prefer.
|
||||||
|
2. **Does `JuryGroup` have a direct `rounds` Prisma relation?** Spec assumes it; confirm field name during impl. If it's `Round.juryGroupId` only (no back-relation), use a nested `Round` query.
|
||||||
|
3. **Mentor-onboarding email content** — copy needs writing. Owned by admin, not blocking impl; can ship with placeholder copy and finalize before going live.
|
||||||
|
4. **`mentor.autoAssignBulk` — does it already skip manually-assigned?** Spec assumes yes; confirm by reading source during PR 4. If no, change is small (add `where: { method: { not: 'MANUAL' } }` to its query).
|
||||||
|
5. **Pre-flight check on existing mentor files in prod MinIO before §F.1** — must be empty or migrated, not orphaned. Confirm via `prisma db query` against prod read replica before deploying PR 2.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Severity | Mitigation |
|
||||||
|
|------|----------|------------|
|
||||||
|
| Existing mentor files in prod use legacy keys | High if hit | Pre-flight check; migration script ready before deploy |
|
||||||
|
| `bulkUpdateRoles` accidentally removes a critical role | Med | Server-side guard: SUPER_ADMIN cannot be self-demoted; audit log all changes |
|
||||||
|
| Multi-role redirect priority surprises some users | Low | Document the priority order; role switcher exists for override |
|
||||||
|
| AI fallback ordering doesn't match prior AI suggestions | Low | UX banner clearly states fallback is in use; keep logic simple |
|
||||||
|
| Filter on `getOnboardingContext` accidentally hides valid memberships | Low | Tests cover the three cases; ship behind no flag, easy to revert |
|
||||||
|
|
||||||
|
## Migration plan
|
||||||
|
|
||||||
|
- §A: no migration.
|
||||||
|
- §B: no migration.
|
||||||
|
- §C: no migration.
|
||||||
|
- §D: one Prisma migration adding nullable `User.mentorOnboardingSentAt: DateTime?`. No backfill (treat all existing users as not-yet-onboarded; on next role edit, email fires once).
|
||||||
|
- §E: no migration.
|
||||||
|
- §F.1: optional one-shot script to rewrite legacy `MentorFile.objectKey` rows to the new scheme. Only runs if pre-flight check finds rows. The script copies objects to the new key path then updates DB rows in a transaction; old keys remain readable until manual cleanup.
|
||||||
|
- §F.2: optional Prisma index on `MentorMessage(workspaceId, createdAt DESC)` if not present.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Each PR independently revertable. PRs 1, 2, 4 ship with no migration → straight git revert. PR 6 has a migration → revert PR + one-line down migration to drop the column. PR 3 has no migration; PR 5 has no migration.
|
||||||
|
|
||||||
|
## Acceptance criteria (per phase)
|
||||||
|
|
||||||
|
**PR 1 (§E):**
|
||||||
|
- Sophie Laurent (member of Screening, Expert, Finals jury groups) sees Screening + Expert preferences only — not Finals.
|
||||||
|
|
||||||
|
**PR 2 (§F.1):**
|
||||||
|
- New mentor file uploads write to `<projectName>/mentorship/<timestamp>-<file>` in MinIO.
|
||||||
|
- Removing `bucket` / `objectKey` from a `workspaceUploadFile` call still succeeds.
|
||||||
|
- Old `objectKey` upload payloads now fail Zod validation.
|
||||||
|
|
||||||
|
**PR 3 (§A):**
|
||||||
|
- All `MentoringConfigSchema` fields are editable from the Config tab.
|
||||||
|
- A draft MENTORING round with no document-promotion configured can pass Launch Readiness without a "File requirements set" check.
|
||||||
|
|
||||||
|
**PR 4 (§C):**
|
||||||
|
- Admin can manually assign any MENTOR-role user to any project from `/admin/projects/[id]/mentor`.
|
||||||
|
- Round Projects tab "Auto-fill remaining" assigns to all `mentoringRequested=true` projects without a mentor.
|
||||||
|
- Page renders sensibly with no `OPENAI_API_KEY` set (expertise-tag fallback).
|
||||||
|
|
||||||
|
**PR 5 (§B):**
|
||||||
|
- MENTORING round Overview shows live counts (requested / assigned / unassigned), deadline countdown, mentor pool size, workspace activity totals.
|
||||||
|
- `/admin/mentors` shows real list of MENTOR-role users with current assignments.
|
||||||
|
|
||||||
|
**PR 6 (§D + §F.2):**
|
||||||
|
- Juror+observer logging in during an active jury round lands on `/jury` (context-aware default). Same user logging in after the round closes lands on `/jury` via static fallback (still highest-priority role they hold).
|
||||||
|
- Mentor+juror with active mentoring assignments and no jury work lands on `/mentor`.
|
||||||
|
- `RoleSwitcherPill` ("Switch View") renders in the top-right of the header on every dashboard for multi-role users, in the same screen position regardless of layout. Single-role users don't see it.
|
||||||
|
- Admin sidebar still has the user pill at the bottom-left for sign-out / settings; role-switcher entries are removed from that menu (live in the new pill instead).
|
||||||
|
- `/admin/members` allows multi-select + "Add MENTOR role to selected" → all selected users get email + role.
|
||||||
|
- Impersonation banner doesn't intercept clicks on the user dropdown.
|
||||||
|
- Mentor `/mentor` dashboard shows "Recent Messages" card; applicant `/applicant` dashboard shows "Conversation with [Mentor]" card.
|
||||||
348
docs/superpowers/specs/2026-04-29-pr6-lunch-event-design.md
Normal file
348
docs/superpowers/specs/2026-04-29-pr6-lunch-event-design.md
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
# PR 6 — Lunch event (design)
|
||||||
|
|
||||||
|
Date: 2026-04-29
|
||||||
|
Status: design locked, ready for implementation plan
|
||||||
|
|
||||||
|
## 1. Goal & scope
|
||||||
|
|
||||||
|
Replace the Lunch tab placeholder on `/admin/logistics` with a working flow: admins configure a single per-edition lunch event, attendees pick a dish + log allergies, and the manifest is reviewable in-app, exportable to CSV, and emailed at the change deadline.
|
||||||
|
|
||||||
|
**In scope:**
|
||||||
|
|
||||||
|
- New models: `LunchEvent` (1:1 per program), `Dish` (per event), `MemberLunchPick` (1:1 per `AttendingMember`), `ExternalAttendee` (per program, optionally team-attached).
|
||||||
|
- Enums: `DietaryTag`, `Allergen`.
|
||||||
|
- Admin UI on the existing Logistics → Lunch tab: event config card, dishes CRUD, manifest table, externals CRUD, recap preview/send, audit logging.
|
||||||
|
- Team-lead UX: dish/allergy editing for any `AttendingMember` on their project, on the existing applicant dashboard.
|
||||||
|
- Member self-serve UX: dish/allergy editing for own `AttendingMember`, on the same dashboard.
|
||||||
|
- Single reminder email (configurable hours before deadline).
|
||||||
|
- Recap email (manual button + cron auto-send, both toggleable; admin recipients + free-form extras).
|
||||||
|
- Removal of the Lunch placeholders from Edition settings and from the disabled Logistics tab trigger.
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
|
||||||
|
- No caterer-facing email integration. Admins forward the recap manually.
|
||||||
|
- No multi-event per edition (1:1 with `Program`).
|
||||||
|
- No public token-gated picker. Members must have an account; team leads or admins fill in for non-active members.
|
||||||
|
- Editable email templates (lands in PR 7).
|
||||||
|
|
||||||
|
## 2. Permission matrix
|
||||||
|
|
||||||
|
| Editor | Can edit |
|
||||||
|
| --- | --- |
|
||||||
|
| Member (logged in) | Their own dish + allergies, until deadline |
|
||||||
|
| Team lead | Any `AttendingMember` on their project, until deadline |
|
||||||
|
| Admin | Everything — all `AttendingMember` picks + all `ExternalAttendee` records, no deadline cap |
|
||||||
|
|
||||||
|
External (off-platform) attendees are admin-only to manage; team leads see them on the project page read-only when an external is attached to their team.
|
||||||
|
|
||||||
|
*"Team lead"* throughout this spec means a user with a `TeamMember` row on the project where `TeamMember.role === 'LEAD'` (existing enum value, defined at `schema.prisma:273-277`).
|
||||||
|
|
||||||
|
*"Admins of the edition"* (used by recap recipients and audit-log actor scoping) means all users with `role === 'SUPER_ADMIN'` plus all users with `role === 'PROGRAM_ADMIN'`. There is no per-program admin scoping today, so all program admins receive the recap.
|
||||||
|
|
||||||
|
## 3. Data model
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
enum DietaryTag {
|
||||||
|
VEGETARIAN
|
||||||
|
VEGAN
|
||||||
|
GLUTEN_FREE
|
||||||
|
PESCATARIAN
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Allergen {
|
||||||
|
GLUTEN // cereals containing gluten
|
||||||
|
CRUSTACEANS
|
||||||
|
EGGS
|
||||||
|
FISH
|
||||||
|
PEANUTS
|
||||||
|
SOYBEANS
|
||||||
|
MILK
|
||||||
|
TREE_NUTS
|
||||||
|
CELERY
|
||||||
|
MUSTARD
|
||||||
|
SESAME
|
||||||
|
SULPHITES
|
||||||
|
LUPIN
|
||||||
|
MOLLUSCS
|
||||||
|
}
|
||||||
|
|
||||||
|
model LunchEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
programId String @unique // 1:1 — one lunch per edition
|
||||||
|
enabled Boolean @default(false)
|
||||||
|
eventAt DateTime? // nullable until admin sets it
|
||||||
|
endAt DateTime?
|
||||||
|
venue String?
|
||||||
|
notes String? @db.Text
|
||||||
|
changeCutoffHours Int @default(48)
|
||||||
|
reminderHoursBeforeDeadline Int? // null = no reminder
|
||||||
|
cronEnabled Boolean @default(true) // auto-recap at deadline
|
||||||
|
extraRecipients String[] @default([]) // off-platform recap recipients
|
||||||
|
reminderSentAt DateTime? // cron idempotency
|
||||||
|
recapSentAt DateTime? // gates "send updated recap?" prompt
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||||
|
dishes Dish[]
|
||||||
|
externalAttendees ExternalAttendee[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Dish {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
lunchEventId String
|
||||||
|
name String
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
dietaryTags DietaryTag[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
|
||||||
|
memberPicks MemberLunchPick[]
|
||||||
|
externals ExternalAttendee[]
|
||||||
|
|
||||||
|
@@index([lunchEventId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model MemberLunchPick {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
attendingMemberId String @unique // 1:1, mirrors FlightDetail/VisaApplication
|
||||||
|
dishId String? // null = not picked yet
|
||||||
|
allergens Allergen[] @default([])
|
||||||
|
allergenOther String? // "other" free-text
|
||||||
|
pickedAt DateTime? // null until first pick made
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
|
||||||
|
dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([dishId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ExternalAttendee {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
lunchEventId String
|
||||||
|
projectId String? // optional — null = standalone (jury/dignitary/etc.)
|
||||||
|
name String
|
||||||
|
email String?
|
||||||
|
roleNote String?
|
||||||
|
dishId String?
|
||||||
|
allergens Allergen[] @default([])
|
||||||
|
allergenOther String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
|
||||||
|
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||||
|
dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([lunchEventId])
|
||||||
|
@@index([projectId])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Back-references on existing models:**
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Program {
|
||||||
|
// ...existing fields...
|
||||||
|
lunchEvent LunchEvent?
|
||||||
|
}
|
||||||
|
|
||||||
|
model AttendingMember {
|
||||||
|
// ...existing fields...
|
||||||
|
lunchPick MemberLunchPick?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Project {
|
||||||
|
// ...existing fields...
|
||||||
|
externalLunchAttendees ExternalAttendee[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auto-create hook.** When an `AttendingMember` is created, if a `LunchEvent` exists for the parent program, also create an empty `MemberLunchPick` (`dishId=null`, `pickedAt=null`). When the `AttendingMember` is deleted, the cascade handles the pick. Mirrors the visa-application auto-sync added in commit `bdfd998`.
|
||||||
|
|
||||||
|
**Migrations are additive.** Nothing existing changes shape. `pickedAt` is set on the first `upsertPick` call where `dishId` is non-null; subsequent edits update `updatedAt` only.
|
||||||
|
|
||||||
|
## 4. API surface
|
||||||
|
|
||||||
|
New router `src/server/routers/lunch.ts`, mounted as `trpc.lunch.*`. Logistics router unchanged.
|
||||||
|
|
||||||
|
### Admin-only (`adminProcedure`)
|
||||||
|
|
||||||
|
| Procedure | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `getEvent` | Get-or-create the `LunchEvent` for the current program (lazy create, mirrors hotel's pattern). |
|
||||||
|
| `updateEvent` | Patch any subset of: `enabled, eventAt, endAt, venue, notes, changeCutoffHours, reminderHoursBeforeDeadline, cronEnabled, extraRecipients[]`. |
|
||||||
|
| `createDish` / `updateDish` / `deleteDish` / `reorderDishes` | Dish CRUD. Delete sets `dishId=null` on picks via Prisma `SetNull`. |
|
||||||
|
| `listExternals` / `createExternal` / `updateExternal` / `deleteExternal` | External-attendee CRUD. |
|
||||||
|
| `getManifest` | Full manifest: attending members (filtered to `FinalistConfirmation.status === CONFIRMED`) + externals, each with project, name, dish, allergens. Backs the Lunch tab table and CSV export. |
|
||||||
|
| `exportManifestCsv` | Server-side CSV generation; returns string for client-side download. |
|
||||||
|
| `getRecapPreview` | Returns the recap email payload (counts + table) for in-app preview. |
|
||||||
|
| `sendRecap` | Manual send. Input `{ forceUpdate?: boolean }`. If `recapSentAt` is set and `forceUpdate=false`, throws `PRECONDITION_FAILED` so the UI can show the "send updated?" confirm. Sends to admins of the edition + `extraRecipients[]`. Updates `recapSentAt`. Audit-logged. |
|
||||||
|
|
||||||
|
### Mixed permission (`protectedProcedure` with role guard inside)
|
||||||
|
|
||||||
|
| Procedure | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `upsertPick` | Single procedure for member-self / team-lead / admin. Input: `{ attendingMemberId, dishId, allergens, allergenOther }`. Guard: caller is (a) the `AttendingMember.userId`, OR (b) team lead of the parent project, OR (c) admin. After `changeCutoffHours` cutoff, only admins pass. Audit-logged on every write with actor role. |
|
||||||
|
|
||||||
|
### Member read (`protectedProcedure`)
|
||||||
|
|
||||||
|
| Procedure | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `getEventForMember` | Public-ish event view: `{ enabled, eventAt, endAt, venue, notes, changeDeadline }` for the dashboard banner. Returns `null` when `enabled=false`. |
|
||||||
|
| `getTeamPicks` | All picks for the caller's team (resolved via `TeamMember → project`). Returns `[{ attendingMemberId, memberName, dish, allergens, hasPicked }]` for the team-wide-read visibility. |
|
||||||
|
|
||||||
|
### Cron endpoints (REST, `CRON_SECRET` guarded)
|
||||||
|
|
||||||
|
| Endpoint | Behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /api/cron/lunch-reminders` | Single fire per event: scans enabled `LunchEvent`s with `reminderHoursBeforeDeadline` set and `reminderSentAt` null. If `now ∈ [reminderAt, deadline)`, emails attending members with `pickedAt=null` whose parent `FinalistConfirmation.status === CONFIRMED`, then stamps `reminderSentAt`. Idempotent. |
|
||||||
|
| `POST /api/cron/lunch-recap` | Single fire per event: scans enabled `LunchEvent`s with `cronEnabled=true`, `recapSentAt` null, and `now >= deadline`. Sends recap to admins + `extraRecipients[]`, stamps `recapSentAt`. Idempotent. |
|
||||||
|
|
||||||
|
Both endpoints follow the CLAUDE.md rule "Round notifications never throw — all errors caught and logged" with per-event `try/catch` so one failure does not poison the sweep.
|
||||||
|
|
||||||
|
## 5. UI
|
||||||
|
|
||||||
|
### Admin: `/admin/logistics → Lunch tab`
|
||||||
|
|
||||||
|
Stack of cards on the existing tab content area:
|
||||||
|
|
||||||
|
1. **Event config card** — enabled toggle (master switch), `eventAt` + `endAt` date pickers, `venue`, `notes`, `changeCutoffHours`, `reminderHoursBeforeDeadline`, `cronEnabled`, `extraRecipients[]` (chip-input for emails). Same blur-to-commit pattern used by the Edition settings tab.
|
||||||
|
2. **Dishes card** — list of dishes (name, dietary-tag pills, drag handle for `sortOrder`), inline add row, edit/delete buttons. Empty state: *"Add at least one dish to open picks."*
|
||||||
|
3. **Manifest card** — table: `Team | Attendee | Type (member/external) | Dish | Allergens | Picked at`. Filters: team dropdown, "Missing picks only" toggle. Header summary chip: *"23/30 picked · 3 vegan · 2 nut-allergic · 1 missing"*. Externals appear inline (team column shows "—" for standalone). Edit-pencil opens a slide-over on any row (admin override).
|
||||||
|
4. **Externals card** — table of external attendees with add button → dialog (name, email, project (optional), `roleNote`, `dishId`, `allergens`, `allergenOther`). Edits use the same dialog.
|
||||||
|
5. **Recap actions card** — two buttons: *"Preview recap"* (modal showing email body) and *"Send recap now"* (with the post-deadline "you already sent — resend updated?" confirm); plus *"Download CSV"*. Footer text: *"Last sent: 2026-06-25 14:02. Recipients: 4 admins + 2 extra."*
|
||||||
|
|
||||||
|
When `enabled=false`, cards 2–5 collapse to a single empty state: *"Lunch is disabled — toggle on to configure."*
|
||||||
|
|
||||||
|
### Applicant dashboard (`/applicant`) — extend `AttendingMembersCard`
|
||||||
|
|
||||||
|
Each attending-member row (already shows visa + flight) gets a new collapsible **Lunch** subsection:
|
||||||
|
|
||||||
|
- Dish dropdown (grouped by dietary tag — *"Vegetarian options"*, *"All options"*).
|
||||||
|
- Allergen checklist (EU 14 inline grid) + "other" textarea.
|
||||||
|
- "Picked" chip with timestamp once `pickedAt` is set.
|
||||||
|
|
||||||
|
Edit affordance:
|
||||||
|
|
||||||
|
- **Member viewing own row:** editable until deadline.
|
||||||
|
- **Team lead viewing teammates' rows:** editable until deadline, with a clear *"Editing on behalf of [Name]"* label.
|
||||||
|
- **Past deadline:** read-only, with note *"Past change deadline. Contact an admin for changes."*
|
||||||
|
|
||||||
|
Above `AttendingMembersCard`, a thin **lunch banner** (only when `enabled=true`) shows event date/time, venue, change-deadline countdown, and a *"Notes from organizers"* expander.
|
||||||
|
|
||||||
|
### Project page
|
||||||
|
|
||||||
|
Read-only **External attendees for your team** strip — only when externals with `projectId === thisProject` exist, so the team knows who's joining them. No edits — admin-only.
|
||||||
|
|
||||||
|
### Removals
|
||||||
|
|
||||||
|
- Drop the Lunch line from the "Coming soon" card on `edition-settings-tab.tsx:212-216`.
|
||||||
|
- Remove `disabled` from the Lunch tab trigger in `logistics/page.tsx:55-58` and wire it to a new `<LunchTab>` component.
|
||||||
|
|
||||||
|
## 6. Email + cron details
|
||||||
|
|
||||||
|
**Email templates** live inline in `src/lib/email.ts` (the existing single-file pattern); no new infrastructure.
|
||||||
|
|
||||||
|
**Reminder.** Subject: *"Pick your lunch dish — deadline in [Xh]"*. Body: greeting, event date/venue/notes, deadline timestamp, button → applicant dashboard. Sent only to attending members with `pickedAt=null` whose confirmation is `CONFIRMED`.
|
||||||
|
|
||||||
|
**Recap.** Subject: *"Lunch manifest — [event date]"*. Body: aggregate counts (dishes, dietary tags, allergens), per-attendee table grouped by team (externals as a final group), event metadata. Plain HTML; no CSV attachment in v1 — admins use the in-app *"Download CSV"* button when needed.
|
||||||
|
|
||||||
|
**Time formatting.** Same approach as the confirmation page: format with `Intl.DateTimeFormat` in the recipient's email-client locale, plus a hardcoded `"Europe/Monaco"` zone label and the ISO timestamp for unambiguous parsing.
|
||||||
|
|
||||||
|
**Audit log entries** (new `eventType` string literals on the existing `DecisionAuditLog.eventType` field — no schema change since the column is free-form):
|
||||||
|
|
||||||
|
- `LUNCH_EVENT_UPDATED`
|
||||||
|
- `LUNCH_DISH_CREATED` / `LUNCH_DISH_UPDATED` / `LUNCH_DISH_DELETED`
|
||||||
|
- `LUNCH_PICK_UPDATED` (records actor role: `SELF` / `TEAM_LEAD` / `ADMIN`)
|
||||||
|
- `LUNCH_EXTERNAL_CREATED` / `LUNCH_EXTERNAL_UPDATED` / `LUNCH_EXTERNAL_DELETED`
|
||||||
|
- `LUNCH_RECAP_SENT` (with recipient count)
|
||||||
|
|
||||||
|
## 7. Edge cases & error handling
|
||||||
|
|
||||||
|
| Case | Behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| `LunchEvent` does not yet exist for the program | `getEvent` lazily creates it with defaults; member/team-lead reads return `null` (banner hidden). |
|
||||||
|
| Admin disables lunch after picks made | Picks remain in the database; UI hides them; recap still works if re-enabled. |
|
||||||
|
| `FinalistConfirmation` flips from `CONFIRMED` to `SUPERSEDED` after a pick was made | Pick row stays; manifest filters the team out. If the team is later re-promoted via waitlist, picks are visible again. |
|
||||||
|
| Dish is deleted | `dishId` becomes `null` on picks/externals; rows show as "not picked"; admins re-pick. Audit logged. |
|
||||||
|
| `eventAt` is moved | Deadline (`eventAt - changeCutoffHours`) and reminder window recalculate automatically — no manual adjustment needed. |
|
||||||
|
| `eventAt` is set in the past | Admin sees a UI warning; cron treats deadline as already-past (so a manual send is the only recourse, since `recapSentAt` may already be moot). |
|
||||||
|
| `changeCutoffHours = 0` | Deadline equals `eventAt`. Allowed. |
|
||||||
|
| Admin edits a pick after `recapSentAt` is set | UI surfaces a confirm dialog: *"This will not auto-resend the recap. Send updated recap?"* ─ "Yes" calls `sendRecap` with `forceUpdate=true`. Audit logged regardless. |
|
||||||
|
| Member with no `AttendingMember` row | Cannot edit. UI hides the lunch subsection (no row exists). |
|
||||||
|
| External with `projectId` that points to a project no longer in the edition | `onDelete: SetNull` on the relation already covers cascades; standalone-display fallback. |
|
||||||
|
|
||||||
|
## 8. Testing strategy
|
||||||
|
|
||||||
|
Vitest, sequential pool (per CLAUDE.md). Tests grouped by router/service:
|
||||||
|
|
||||||
|
**`tests/lunch/lunch-router.test.ts`**
|
||||||
|
|
||||||
|
- `getEvent` lazily creates the row on first call.
|
||||||
|
- `updateEvent` patches an arbitrary subset.
|
||||||
|
- Dish CRUD (`createDish`, `updateDish`, `deleteDish`, `reorderDishes`) — delete sets `dishId=null` on existing picks.
|
||||||
|
- External CRUD covers the standalone (`projectId=null`) and team-attached cases.
|
||||||
|
- `getManifest` filters out non-`CONFIRMED` confirmations and merges externals.
|
||||||
|
|
||||||
|
**`tests/lunch/upsert-pick.test.ts`**
|
||||||
|
|
||||||
|
- Member edits own row: succeeds before deadline, fails after.
|
||||||
|
- Team lead edits teammate row: succeeds before deadline, fails after.
|
||||||
|
- Team lead edits a non-team member's row: fails with `FORBIDDEN`.
|
||||||
|
- Admin edits any row before/after deadline: succeeds in both cases.
|
||||||
|
- Audit log records actor role correctly per case.
|
||||||
|
|
||||||
|
**`tests/lunch/recap.test.ts`**
|
||||||
|
|
||||||
|
- `sendRecap` with `recapSentAt=null` succeeds and stamps the timestamp.
|
||||||
|
- `sendRecap` with `recapSentAt` set and `forceUpdate=false` throws `PRECONDITION_FAILED`.
|
||||||
|
- `sendRecap` with `forceUpdate=true` succeeds and re-stamps.
|
||||||
|
- Recap aggregation is correct (dish counts, dietary-tag counts, allergen counts).
|
||||||
|
|
||||||
|
**`tests/lunch/cron.test.ts`**
|
||||||
|
|
||||||
|
- `lunch-reminders` is idempotent (second call within window does not double-send).
|
||||||
|
- `lunch-reminders` skips events with `reminderSentAt` already set.
|
||||||
|
- `lunch-recap` skips events with `cronEnabled=false`.
|
||||||
|
- `lunch-recap` skips events with `recapSentAt` already set.
|
||||||
|
- Per-event try/catch — a failing send for one event does not stop the next from being processed.
|
||||||
|
|
||||||
|
**`tests/lunch/auto-create.test.ts`**
|
||||||
|
|
||||||
|
- Creating an `AttendingMember` while a `LunchEvent` exists also creates an empty `MemberLunchPick`.
|
||||||
|
- Creating an `AttendingMember` while no `LunchEvent` exists does not error and does not create a pick.
|
||||||
|
|
||||||
|
Build (`npm run build`), typecheck (`npm run typecheck`), and full test suite must be green before commit.
|
||||||
|
|
||||||
|
## 9. File-level work surface (informative — drives the implementation plan)
|
||||||
|
|
||||||
|
- `prisma/schema.prisma` — add models, enums, back-references; new migration.
|
||||||
|
- `src/server/routers/lunch.ts` (new) — router as designed.
|
||||||
|
- `src/server/routers/_app.ts` — mount `lunch` router.
|
||||||
|
- `src/server/services/lunch-pick-sync.ts` (new) — `ensureLunchPickForAttendingMember` helper called from existing attendee-creation paths.
|
||||||
|
- `src/server/services/lunch-recap.ts` (new) — manifest aggregation + email body builder, used by `sendRecap` and the recap cron.
|
||||||
|
- `src/lib/email.ts` — append two new template functions (reminder + recap).
|
||||||
|
- `src/app/api/cron/lunch-reminders/route.ts` (new).
|
||||||
|
- `src/app/api/cron/lunch-recap/route.ts` (new).
|
||||||
|
- `src/app/(admin)/admin/logistics/page.tsx` — un-disable the Lunch tab trigger; mount new tab content.
|
||||||
|
- `src/components/admin/logistics/lunch-tab.tsx` (new) — orchestrates the five cards.
|
||||||
|
- `src/components/admin/logistics/lunch-event-config.tsx` (new) — config card.
|
||||||
|
- `src/components/admin/logistics/lunch-dishes.tsx` (new) — dishes card.
|
||||||
|
- `src/components/admin/logistics/lunch-manifest.tsx` (new) — manifest card.
|
||||||
|
- `src/components/admin/logistics/lunch-externals.tsx` (new) — externals card.
|
||||||
|
- `src/components/admin/logistics/lunch-recap-actions.tsx` (new) — recap actions card.
|
||||||
|
- `src/components/applicant/attending-members-card.tsx` — extend each row with the lunch subsection.
|
||||||
|
- `src/components/applicant/lunch-banner.tsx` (new) — the dashboard banner above the attending-members card.
|
||||||
|
- `src/components/admin/settings/edition-settings-tab.tsx` — drop the Lunch line from the "Coming soon" card.
|
||||||
|
|
||||||
|
## 10. Non-goals reminder
|
||||||
|
|
||||||
|
- No keyboard shortcuts anywhere — visible UI affordances only (per existing user feedback memory).
|
||||||
|
- No editable email templates in this PR (PR 7).
|
||||||
|
- No public token-gated picker.
|
||||||
|
- No multi-event support.
|
||||||
|
- No caterer email integration.
|
||||||
@@ -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.
|
||||||
25
package-lock.json
generated
25
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mopc-platform",
|
"name": "mopc-platform",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mopc-platform",
|
"name": "mopc-platform",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.78.0",
|
"@anthropic-ai/sdk": "^0.78.0",
|
||||||
"@auth/prisma-adapter": "^2.7.4",
|
"@auth/prisma-adapter": "^2.7.4",
|
||||||
@@ -61,11 +61,11 @@
|
|||||||
"motion": "^11.15.0",
|
"motion": "^11.15.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"nodemailer": "^7.0.7",
|
"nodemailer": "^7.0.7",
|
||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.0.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": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
@@ -13428,6 +13418,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mopc-platform",
|
"name": "mopc-platform",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -75,11 +75,11 @@
|
|||||||
"motion": "^11.15.0",
|
"motion": "^11.15.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"nodemailer": "^7.0.7",
|
"nodemailer": "^7.0.7",
|
||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "mentorOnboardingSentAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WaitlistEntryStatus" AS ENUM ('WAITING', 'PROMOTED', 'USED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "FinalistConfirmationStatus" AS ENUM ('PENDING', 'CONFIRMED', 'DECLINED', 'EXPIRED', 'SUPERSEDED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Program" ADD COLUMN "defaultAttendeeCap" INTEGER NOT NULL DEFAULT 3;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FinalistSlotQuota" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"programId" TEXT NOT NULL,
|
||||||
|
"category" "CompetitionCategory" NOT NULL,
|
||||||
|
"quota" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "FinalistSlotQuota_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WaitlistEntry" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"programId" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"category" "CompetitionCategory" NOT NULL,
|
||||||
|
"rank" INTEGER NOT NULL,
|
||||||
|
"status" "WaitlistEntryStatus" NOT NULL DEFAULT 'WAITING',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FinalistConfirmation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"category" "CompetitionCategory" NOT NULL,
|
||||||
|
"status" "FinalistConfirmationStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"deadline" TIMESTAMP(3) NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"confirmedAt" TIMESTAMP(3),
|
||||||
|
"declinedAt" TIMESTAMP(3),
|
||||||
|
"declineReason" TEXT,
|
||||||
|
"expiredAt" TIMESTAMP(3),
|
||||||
|
"promotedFromWaitlistEntryId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "FinalistConfirmation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AttendingMember" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"confirmationId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"needsVisa" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AttendingMember_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FinalistSlotQuota_programId_idx" ON "FinalistSlotQuota"("programId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "FinalistSlotQuota_programId_category_key" ON "FinalistSlotQuota"("programId", "category");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WaitlistEntry_projectId_key" ON "WaitlistEntry"("projectId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WaitlistEntry_programId_category_status_idx" ON "WaitlistEntry"("programId", "category", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WaitlistEntry_programId_category_rank_key" ON "WaitlistEntry"("programId", "category", "rank");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "FinalistConfirmation_projectId_key" ON "FinalistConfirmation"("projectId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "FinalistConfirmation_token_key" ON "FinalistConfirmation"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "FinalistConfirmation_promotedFromWaitlistEntryId_key" ON "FinalistConfirmation"("promotedFromWaitlistEntryId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FinalistConfirmation_status_deadline_idx" ON "FinalistConfirmation"("status", "deadline");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FinalistConfirmation_category_status_idx" ON "FinalistConfirmation"("category", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AttendingMember_userId_idx" ON "AttendingMember"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AttendingMember_confirmationId_userId_key" ON "AttendingMember"("confirmationId", "userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "FinalistSlotQuota" ADD CONSTRAINT "FinalistSlotQuota_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "FinalistConfirmation" ADD CONSTRAINT "FinalistConfirmation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AttendingMember" ADD CONSTRAINT "AttendingMember_confirmationId_fkey" FOREIGN KEY ("confirmationId") REFERENCES "FinalistConfirmation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AttendingMember" ADD CONSTRAINT "AttendingMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "FlightDetailStatus" AS ENUM ('PENDING', 'CONFIRMED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Hotel" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"programId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"address" TEXT,
|
||||||
|
"link" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Hotel_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FlightDetail" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"attendingMemberId" TEXT NOT NULL,
|
||||||
|
"arrivalAt" TIMESTAMP(3),
|
||||||
|
"arrivalFlightNumber" TEXT,
|
||||||
|
"arrivalAirport" TEXT,
|
||||||
|
"departureAt" TIMESTAMP(3),
|
||||||
|
"departureFlightNumber" TEXT,
|
||||||
|
"departureAirport" TEXT,
|
||||||
|
"status" "FlightDetailStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"adminNotes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "FlightDetail_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Hotel_programId_key" ON "Hotel"("programId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "FlightDetail_attendingMemberId_key" ON "FlightDetail"("attendingMemberId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FlightDetail_status_idx" ON "FlightDetail"("status");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Hotel" ADD CONSTRAINT "Hotel_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "FlightDetail" ADD CONSTRAINT "FlightDetail_attendingMemberId_fkey" FOREIGN KEY ("attendingMemberId") REFERENCES "AttendingMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "MentorAssignment" ADD COLUMN "droppedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "droppedBy" TEXT,
|
||||||
|
ADD COLUMN "droppedReason" TEXT;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "VisaStatus" AS ENUM ('NOT_NEEDED', 'REQUESTED', 'INVITATION_SENT', 'APPOINTMENT_BOOKED', 'GRANTED', 'DENIED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Program" ADD COLUMN "visaStatusVisibleToMembers" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VisaApplication" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"attendingMemberId" TEXT NOT NULL,
|
||||||
|
"status" "VisaStatus" NOT NULL DEFAULT 'REQUESTED',
|
||||||
|
"nationality" TEXT,
|
||||||
|
"invitationSentAt" TIMESTAMP(3),
|
||||||
|
"appointmentAt" TIMESTAMP(3),
|
||||||
|
"decisionAt" TIMESTAMP(3),
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "VisaApplication_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "VisaApplication_attendingMemberId_key" ON "VisaApplication"("attendingMemberId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VisaApplication_status_idx" ON "VisaApplication"("status");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VisaApplication" ADD CONSTRAINT "VisaApplication_attendingMemberId_fkey" FOREIGN KEY ("attendingMemberId") REFERENCES "AttendingMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
109
prisma/migrations/20260429002325_add_lunch_event/migration.sql
Normal file
109
prisma/migrations/20260429002325_add_lunch_event/migration.sql
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DietaryTag" AS ENUM ('VEGETARIAN', 'VEGAN', 'GLUTEN_FREE', 'PESCATARIAN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Allergen" AS ENUM ('GLUTEN', 'CRUSTACEANS', 'EGGS', 'FISH', 'PEANUTS', 'SOYBEANS', 'MILK', 'TREE_NUTS', 'CELERY', 'MUSTARD', 'SESAME', 'SULPHITES', 'LUPIN', 'MOLLUSCS');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "LunchEvent" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"programId" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"eventAt" TIMESTAMP(3),
|
||||||
|
"endAt" TIMESTAMP(3),
|
||||||
|
"venue" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"changeCutoffHours" INTEGER NOT NULL DEFAULT 48,
|
||||||
|
"reminderHoursBeforeDeadline" INTEGER,
|
||||||
|
"cronEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"extraRecipients" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"reminderSentAt" TIMESTAMP(3),
|
||||||
|
"recapSentAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "LunchEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Dish" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"lunchEventId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"dietaryTags" "DietaryTag"[],
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Dish_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MemberLunchPick" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"attendingMemberId" TEXT NOT NULL,
|
||||||
|
"dishId" TEXT,
|
||||||
|
"allergens" "Allergen"[] DEFAULT ARRAY[]::"Allergen"[],
|
||||||
|
"allergenOther" TEXT,
|
||||||
|
"pickedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "MemberLunchPick_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ExternalAttendee" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"lunchEventId" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"email" TEXT,
|
||||||
|
"roleNote" TEXT,
|
||||||
|
"dishId" TEXT,
|
||||||
|
"allergens" "Allergen"[] DEFAULT ARRAY[]::"Allergen"[],
|
||||||
|
"allergenOther" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ExternalAttendee_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "LunchEvent_programId_key" ON "LunchEvent"("programId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Dish_lunchEventId_idx" ON "Dish"("lunchEventId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "MemberLunchPick_attendingMemberId_key" ON "MemberLunchPick"("attendingMemberId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MemberLunchPick_dishId_idx" ON "MemberLunchPick"("dishId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ExternalAttendee_lunchEventId_idx" ON "ExternalAttendee"("lunchEventId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ExternalAttendee_projectId_idx" ON "ExternalAttendee"("projectId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "LunchEvent" ADD CONSTRAINT "LunchEvent_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Dish" ADD CONSTRAINT "Dish_lunchEventId_fkey" FOREIGN KEY ("lunchEventId") REFERENCES "LunchEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MemberLunchPick" ADD CONSTRAINT "MemberLunchPick_attendingMemberId_fkey" FOREIGN KEY ("attendingMemberId") REFERENCES "AttendingMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MemberLunchPick" ADD CONSTRAINT "MemberLunchPick_dishId_fkey" FOREIGN KEY ("dishId") REFERENCES "Dish"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ExternalAttendee" ADD CONSTRAINT "ExternalAttendee_lunchEventId_fkey" FOREIGN KEY ("lunchEventId") REFERENCES "LunchEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ExternalAttendee" ADD CONSTRAINT "ExternalAttendee_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ExternalAttendee" ADD CONSTRAINT "ExternalAttendee_dishId_fkey" FOREIGN KEY ("dishId") REFERENCES "Dish"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -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
|
MENTOR
|
||||||
OBSERVER
|
OBSERVER
|
||||||
APPLICANT
|
APPLICANT
|
||||||
AWARD_MASTER
|
|
||||||
AUDIENCE
|
AUDIENCE
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +118,6 @@ enum NotificationChannel {
|
|||||||
NONE
|
NONE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
enum PartnerVisibility {
|
enum PartnerVisibility {
|
||||||
ADMIN_ONLY
|
ADMIN_ONLY
|
||||||
JURY_VISIBLE
|
JURY_VISIBLE
|
||||||
@@ -134,7 +132,6 @@ enum PartnerType {
|
|||||||
OTHER
|
OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// COMPETITION / ROUND ENGINE ENUMS
|
// COMPETITION / ROUND ENGINE ENUMS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -172,7 +169,6 @@ enum ProjectRoundStateValue {
|
|||||||
WITHDRAWN
|
WITHDRAWN
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
enum CapMode {
|
enum CapMode {
|
||||||
HARD
|
HARD
|
||||||
SOFT
|
SOFT
|
||||||
@@ -302,6 +298,9 @@ model User {
|
|||||||
institution String? // User's institution/organization
|
institution String? // User's institution/organization
|
||||||
metadataJson Json? @db.JsonB
|
metadataJson Json? @db.JsonB
|
||||||
|
|
||||||
|
// Mentor onboarding email idempotency: stamped once when MENTOR role is first added.
|
||||||
|
mentorOnboardingSentAt DateTime?
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
bio String? // User bio for matching with project descriptions
|
bio String? // User bio for matching with project descriptions
|
||||||
profileImageKey String? // Storage key (e.g., "avatars/user123/1234567890.jpg")
|
profileImageKey String? // Storage key (e.g., "avatars/user123/1234567890.jpg")
|
||||||
@@ -348,6 +347,7 @@ model User {
|
|||||||
resourceAccess ResourceAccess[]
|
resourceAccess ResourceAccess[]
|
||||||
submittedProjects Project[] @relation("ProjectSubmittedBy")
|
submittedProjects Project[] @relation("ProjectSubmittedBy")
|
||||||
liveVotes LiveVote[]
|
liveVotes LiveVote[]
|
||||||
|
liveNotes LiveNote[]
|
||||||
|
|
||||||
// Team membership & mentorship
|
// Team membership & mentorship
|
||||||
teamMemberships TeamMember[]
|
teamMemberships TeamMember[]
|
||||||
@@ -423,6 +423,13 @@ model User {
|
|||||||
// AI Ranking
|
// AI Ranking
|
||||||
rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots")
|
rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots")
|
||||||
|
|
||||||
|
// Grand-finale logistics
|
||||||
|
finalistAttendances AttendingMember[]
|
||||||
|
|
||||||
|
// Mentor change requests
|
||||||
|
mentorChangeRequestsRequested MentorChangeRequest[] @relation("MentorChangeRequester")
|
||||||
|
mentorChangeRequestsResolved MentorChangeRequest[] @relation("MentorChangeResolver")
|
||||||
|
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
@@ -480,6 +487,10 @@ model Program {
|
|||||||
description String?
|
description String?
|
||||||
settingsJson Json? @db.JsonB
|
settingsJson Json? @db.JsonB
|
||||||
|
|
||||||
|
// Grand-finale logistics
|
||||||
|
defaultAttendeeCap Int @default(3) // Max team members allowed at the grand finale per finalist team
|
||||||
|
visaStatusVisibleToMembers Boolean @default(true) // Whether team members see their own visa status on the applicant dashboard
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -493,6 +504,12 @@ model Program {
|
|||||||
mentorMilestones MentorMilestone[]
|
mentorMilestones MentorMilestone[]
|
||||||
competitions Competition[]
|
competitions Competition[]
|
||||||
|
|
||||||
|
// Grand-finale logistics
|
||||||
|
finalistSlotQuotas FinalistSlotQuota[]
|
||||||
|
waitlistEntries WaitlistEntry[]
|
||||||
|
hotels Hotel[]
|
||||||
|
lunchEvent LunchEvent?
|
||||||
|
|
||||||
@@unique([name, year])
|
@@unique([name, year])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
@@ -614,7 +631,9 @@ model Project {
|
|||||||
assignments Assignment[]
|
assignments Assignment[]
|
||||||
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
|
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
|
||||||
teamMembers TeamMember[]
|
teamMembers TeamMember[]
|
||||||
mentorAssignment MentorAssignment?
|
mentorAssignments MentorAssignment[]
|
||||||
|
mentorFiles MentorFile[]
|
||||||
|
mentorChangeRequests MentorChangeRequest[]
|
||||||
filteringResults FilteringResult[]
|
filteringResults FilteringResult[]
|
||||||
awardEligibilities AwardEligibility[]
|
awardEligibilities AwardEligibility[]
|
||||||
awardVotes AwardVote[]
|
awardVotes AwardVote[]
|
||||||
@@ -634,6 +653,15 @@ model Project {
|
|||||||
submissionPromotions SubmissionPromotionEvent[]
|
submissionPromotions SubmissionPromotionEvent[]
|
||||||
notificationLogs NotificationLog[]
|
notificationLogs NotificationLog[]
|
||||||
|
|
||||||
|
// Grand-finale logistics
|
||||||
|
waitlistEntry WaitlistEntry?
|
||||||
|
finalistConfirmation FinalistConfirmation?
|
||||||
|
externalLunchAttendees ExternalAttendee[]
|
||||||
|
|
||||||
|
// Grand-finale ceremony
|
||||||
|
audienceFavoriteVotes AudienceFavoriteVote[]
|
||||||
|
liveNotes LiveNote[]
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([tags])
|
@@index([tags])
|
||||||
@@ -1165,6 +1193,13 @@ model LiveVotingSession {
|
|||||||
audienceRequireId Boolean @default(false) // Require email/phone for audience
|
audienceRequireId Boolean @default(false) // Require email/phone for audience
|
||||||
audienceVotingDuration Int? // Minutes (null = same as jury)
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1172,6 +1207,8 @@ model LiveVotingSession {
|
|||||||
round Round? @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
round Round? @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||||
votes LiveVote[]
|
votes LiveVote[]
|
||||||
audienceVoters AudienceVoter[]
|
audienceVoters AudienceVoter[]
|
||||||
|
favoriteVotes AudienceFavoriteVote[]
|
||||||
|
revealState RevealState?
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
@@ -1188,6 +1225,9 @@ model LiveVote {
|
|||||||
// Criteria scores (used when votingMode="criteria")
|
// Criteria scores (used when votingMode="criteria")
|
||||||
criterionScoresJson Json? @db.JsonB // { [criterionId]: score } - null for simple mode
|
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
|
// Audience voter link
|
||||||
audienceVoterId String?
|
audienceVoterId String?
|
||||||
|
|
||||||
@@ -1217,11 +1257,79 @@ model AudienceVoter {
|
|||||||
// Relations
|
// Relations
|
||||||
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||||
votes LiveVote[]
|
votes LiveVote[]
|
||||||
|
favoriteVotes AudienceFavoriteVote[]
|
||||||
|
|
||||||
@@index([sessionId])
|
@@index([sessionId])
|
||||||
@@index([token])
|
@@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
|
// TEAM MEMBERSHIP
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -1250,7 +1358,7 @@ model TeamMember {
|
|||||||
|
|
||||||
model MentorAssignment {
|
model MentorAssignment {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
projectId String @unique // One mentor per project
|
projectId String // Team can have multiple mentors; uniqueness enforced via composite below
|
||||||
mentorId String // User with MENTOR role or expertise
|
mentorId String // User with MENTOR role or expertise
|
||||||
|
|
||||||
// Assignment tracking
|
// Assignment tracking
|
||||||
@@ -1258,6 +1366,16 @@ model MentorAssignment {
|
|||||||
assignedAt DateTime @default(now())
|
assignedAt DateTime @default(now())
|
||||||
assignedBy String? // Admin who assigned
|
assignedBy String? // Admin who assigned
|
||||||
|
|
||||||
|
// Per-assignment email idempotency: stamped once the MENTOR-side notification
|
||||||
|
// email has been sent (the "you've been assigned a project" email to the mentor).
|
||||||
|
notificationSentAt DateTime?
|
||||||
|
|
||||||
|
// Stamped once the TEAM has been introduced to this mentor (the "meet your
|
||||||
|
// mentor" email with mentor contact info). Fired by `activateRound` for
|
||||||
|
// MENTORING rounds and by mentor.assign when the project's MENTORING round
|
||||||
|
// is already ROUND_ACTIVE. Independent from notificationSentAt above.
|
||||||
|
teamIntroducedAt DateTime?
|
||||||
|
|
||||||
// AI assignment metadata
|
// AI assignment metadata
|
||||||
aiConfidenceScore Float?
|
aiConfidenceScore Float?
|
||||||
expertiseMatchScore Float?
|
expertiseMatchScore Float?
|
||||||
@@ -1267,6 +1385,11 @@ model MentorAssignment {
|
|||||||
completionStatus String @default("in_progress") // 'in_progress' | 'completed' | 'paused'
|
completionStatus String @default("in_progress") // 'in_progress' | 'completed' | 'paused'
|
||||||
lastViewedAt DateTime?
|
lastViewedAt DateTime?
|
||||||
|
|
||||||
|
// Drop tracking — null while assignment is active
|
||||||
|
droppedAt DateTime?
|
||||||
|
droppedReason String? @db.Text
|
||||||
|
droppedBy String? // 'mentor' | 'admin' | 'finalist_unconfirmed'
|
||||||
|
|
||||||
// ── Competition/Round architecture — workspace activation ──
|
// ── Competition/Round architecture — workspace activation ──
|
||||||
workspaceEnabled Boolean @default(false)
|
workspaceEnabled Boolean @default(false)
|
||||||
workspaceOpenAt DateTime?
|
workspaceOpenAt DateTime?
|
||||||
@@ -1279,11 +1402,47 @@ model MentorAssignment {
|
|||||||
milestoneCompletions MentorMilestoneCompletion[]
|
milestoneCompletions MentorMilestoneCompletion[]
|
||||||
messages MentorMessage[]
|
messages MentorMessage[]
|
||||||
files MentorFile[]
|
files MentorFile[]
|
||||||
|
changeRequests MentorChangeRequest[] @relation("MentorChangeRequestTarget")
|
||||||
|
|
||||||
|
@@unique([projectId, mentorId])
|
||||||
|
@@index([projectId])
|
||||||
@@index([mentorId])
|
@@index([mentorId])
|
||||||
@@index([method])
|
@@index([method])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MENTOR CHANGE REQUESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
enum MentorChangeRequestStatus {
|
||||||
|
PENDING
|
||||||
|
RESOLVED
|
||||||
|
DISMISSED
|
||||||
|
}
|
||||||
|
|
||||||
|
model MentorChangeRequest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
targetAssignmentId String? // Optional: a specific co-mentor the request is about
|
||||||
|
requestedByUserId String?
|
||||||
|
reason String @db.Text
|
||||||
|
status MentorChangeRequestStatus @default(PENDING)
|
||||||
|
resolvedByUserId String?
|
||||||
|
resolvedAt DateTime?
|
||||||
|
resolutionNote String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
targetAssignment MentorAssignment? @relation("MentorChangeRequestTarget", fields: [targetAssignmentId], references: [id], onDelete: SetNull)
|
||||||
|
requestedBy User? @relation("MentorChangeRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
|
||||||
|
resolvedBy User? @relation("MentorChangeResolver", fields: [resolvedByUserId], references: [id])
|
||||||
|
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([targetAssignmentId])
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// FILTERING ROUND SYSTEM
|
// FILTERING ROUND SYSTEM
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -1574,7 +1733,7 @@ model SpecialAward {
|
|||||||
evaluationRoundId String?
|
evaluationRoundId String?
|
||||||
juryGroupId String?
|
juryGroupId String?
|
||||||
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
|
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
|
||||||
decisionMode String? // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION"
|
decisionMode String? // "JURY_VOTE" | "ADMIN_DECISION"
|
||||||
shortlistSize Int @default(10)
|
shortlistSize Int @default(10)
|
||||||
|
|
||||||
// Eligibility job tracking
|
// Eligibility job tracking
|
||||||
@@ -2083,6 +2242,15 @@ model LiveProgressCursor {
|
|||||||
activeOrderIndex Int @default(0)
|
activeOrderIndex Int @default(0)
|
||||||
isPaused Boolean @default(false)
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -2209,6 +2377,7 @@ model Round {
|
|||||||
notificationLogs NotificationLog[]
|
notificationLogs NotificationLog[]
|
||||||
cohorts Cohort[]
|
cohorts Cohort[]
|
||||||
liveCursor LiveProgressCursor?
|
liveCursor LiveProgressCursor?
|
||||||
|
liveNotes LiveNote[]
|
||||||
|
|
||||||
@@unique([competitionId, slug])
|
@@unique([competitionId, slug])
|
||||||
@@unique([competitionId, sortOrder])
|
@@unique([competitionId, sortOrder])
|
||||||
@@ -2424,7 +2593,8 @@ model AssignmentIntent {
|
|||||||
|
|
||||||
model MentorFile {
|
model MentorFile {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
mentorAssignmentId String
|
projectId String // Primary access scope: files belong to the team
|
||||||
|
mentorAssignmentId String? // Nullable audit FK: which assignment uploaded; survives mentor drop
|
||||||
uploadedByUserId String
|
uploadedByUserId String
|
||||||
|
|
||||||
fileName String
|
fileName String
|
||||||
@@ -2443,13 +2613,15 @@ model MentorFile {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
mentorAssignment MentorAssignment? @relation(fields: [mentorAssignmentId], references: [id], onDelete: SetNull)
|
||||||
uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id])
|
uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id])
|
||||||
promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
|
promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
|
||||||
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
|
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
|
||||||
comments MentorFileComment[]
|
comments MentorFileComment[]
|
||||||
promotionEvents SubmissionPromotionEvent[]
|
promotionEvents SubmissionPromotionEvent[]
|
||||||
|
|
||||||
|
@@index([projectId])
|
||||||
@@index([mentorAssignmentId])
|
@@index([mentorAssignmentId])
|
||||||
@@index([uploadedByUserId])
|
@@index([uploadedByUserId])
|
||||||
}
|
}
|
||||||
@@ -2623,3 +2795,298 @@ model ResultUnlockEvent {
|
|||||||
@@index([resultLockId])
|
@@index([resultLockId])
|
||||||
@@index([unlockedById])
|
@@index([unlockedById])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Grand-finale logistics (PR 1: finalist confirmation flow)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum WaitlistEntryStatus {
|
||||||
|
WAITING // available for promotion
|
||||||
|
PROMOTED // moved into a finalist slot
|
||||||
|
USED // promoted and confirmation flow completed (declined or accepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FinalistConfirmationStatus {
|
||||||
|
PENDING // sent, awaiting team response
|
||||||
|
CONFIRMED // team accepted, attendees selected
|
||||||
|
DECLINED // team explicitly declined
|
||||||
|
EXPIRED // deadline passed without response
|
||||||
|
SUPERSEDED // admin manually overrode (e.g. unconfirmed to allow quota decrease)
|
||||||
|
}
|
||||||
|
|
||||||
|
model FinalistSlotQuota {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
programId String
|
||||||
|
category CompetitionCategory
|
||||||
|
quota Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([programId, category])
|
||||||
|
@@index([programId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WaitlistEntry {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
programId String
|
||||||
|
projectId String @unique
|
||||||
|
category CompetitionCategory
|
||||||
|
rank Int
|
||||||
|
status WaitlistEntryStatus @default(WAITING)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([programId, category, rank])
|
||||||
|
@@index([programId, category, status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model FinalistConfirmation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String @unique
|
||||||
|
category CompetitionCategory
|
||||||
|
status FinalistConfirmationStatus @default(PENDING)
|
||||||
|
deadline DateTime
|
||||||
|
token String @unique
|
||||||
|
confirmedAt DateTime?
|
||||||
|
declinedAt DateTime?
|
||||||
|
declineReason String? // optional free-text on decline
|
||||||
|
expiredAt DateTime?
|
||||||
|
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
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
attendingMembers AttendingMember[]
|
||||||
|
|
||||||
|
@@index([status, deadline]) // for cron scan
|
||||||
|
@@index([category, status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AttendingMember {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
confirmationId String
|
||||||
|
userId String // must be a TeamMember of the same project (validated at write time)
|
||||||
|
needsVisa Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
confirmation FinalistConfirmation @relation(fields: [confirmationId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
flightDetail FlightDetail?
|
||||||
|
visaApplication VisaApplication?
|
||||||
|
lunchPick MemberLunchPick?
|
||||||
|
hotelStay HotelStay?
|
||||||
|
|
||||||
|
@@unique([confirmationId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Grand-finale logistics (PR 2: hotels + flight tracking)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum FlightDetailStatus {
|
||||||
|
PENDING // team submitted details, admin not yet reviewed
|
||||||
|
CONFIRMED // admin verified booking
|
||||||
|
}
|
||||||
|
|
||||||
|
model Hotel {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
programId String // many hotels per edition
|
||||||
|
name String
|
||||||
|
address String? @db.Text
|
||||||
|
link String? // external URL to hotel page / booking confirmation
|
||||||
|
notes String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||||
|
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 {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
attendingMemberId String @unique // 1:1
|
||||||
|
arrivalAt DateTime?
|
||||||
|
arrivalFlightNumber String?
|
||||||
|
arrivalAirport String?
|
||||||
|
departureAt DateTime?
|
||||||
|
departureFlightNumber String?
|
||||||
|
departureAirport String?
|
||||||
|
status FlightDetailStatus @default(PENDING)
|
||||||
|
adminNotes String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Grand-finale visa tracking (PR 4)
|
||||||
|
// Process metadata only — no document storage. Passport scans / invitation
|
||||||
|
// letters / decision documents are exchanged over email; this model just
|
||||||
|
// records what stage the application is at, key dates, and free-text notes.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum VisaStatus {
|
||||||
|
NOT_NEEDED
|
||||||
|
REQUESTED
|
||||||
|
INVITATION_SENT
|
||||||
|
APPOINTMENT_BOOKED
|
||||||
|
GRANTED
|
||||||
|
DENIED
|
||||||
|
}
|
||||||
|
|
||||||
|
model VisaApplication {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
attendingMemberId String @unique // 1:1
|
||||||
|
status VisaStatus @default(REQUESTED)
|
||||||
|
nationality String? // self-declared, optional
|
||||||
|
invitationSentAt DateTime?
|
||||||
|
appointmentAt DateTime?
|
||||||
|
decisionAt DateTime? // GRANTED or DENIED date
|
||||||
|
notes String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Grand-finale lunch event (PR 6)
|
||||||
|
// Single configurable lunch event per edition. Each attending member has a
|
||||||
|
// 1:1 MemberLunchPick (auto-created via lunch-pick-sync). External attendees
|
||||||
|
// can be standalone or attached to a finalist project. Allergens use the
|
||||||
|
// EU 14 regulated list; dishes carry dietary tags.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum DietaryTag {
|
||||||
|
VEGETARIAN
|
||||||
|
VEGAN
|
||||||
|
GLUTEN_FREE
|
||||||
|
PESCATARIAN
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Allergen {
|
||||||
|
GLUTEN
|
||||||
|
CRUSTACEANS
|
||||||
|
EGGS
|
||||||
|
FISH
|
||||||
|
PEANUTS
|
||||||
|
SOYBEANS
|
||||||
|
MILK
|
||||||
|
TREE_NUTS
|
||||||
|
CELERY
|
||||||
|
MUSTARD
|
||||||
|
SESAME
|
||||||
|
SULPHITES
|
||||||
|
LUPIN
|
||||||
|
MOLLUSCS
|
||||||
|
}
|
||||||
|
|
||||||
|
model LunchEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
programId String @unique
|
||||||
|
enabled Boolean @default(false)
|
||||||
|
eventAt DateTime?
|
||||||
|
endAt DateTime?
|
||||||
|
venue String?
|
||||||
|
notes String? @db.Text
|
||||||
|
changeCutoffHours Int @default(48)
|
||||||
|
reminderHoursBeforeDeadline Int?
|
||||||
|
cronEnabled Boolean @default(true)
|
||||||
|
extraRecipients String[] @default([])
|
||||||
|
reminderSentAt DateTime?
|
||||||
|
recapSentAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||||
|
dishes Dish[]
|
||||||
|
externalAttendees ExternalAttendee[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Dish {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
lunchEventId String
|
||||||
|
name String
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
dietaryTags DietaryTag[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
|
||||||
|
memberPicks MemberLunchPick[]
|
||||||
|
externals ExternalAttendee[]
|
||||||
|
|
||||||
|
@@index([lunchEventId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model MemberLunchPick {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
attendingMemberId String @unique
|
||||||
|
dishId String?
|
||||||
|
allergens Allergen[] @default([])
|
||||||
|
allergenOther String?
|
||||||
|
pickedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
|
||||||
|
dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([dishId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ExternalAttendee {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
lunchEventId String
|
||||||
|
projectId String?
|
||||||
|
name String
|
||||||
|
email String?
|
||||||
|
roleNote String?
|
||||||
|
dishId String?
|
||||||
|
allergens Allergen[] @default([])
|
||||||
|
allergenOther String?
|
||||||
|
inviteSentAt DateTime? // when the dish-selection email was last sent (null = never invited)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
|
||||||
|
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||||
|
dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([lunchEventId])
|
||||||
|
@@index([projectId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -214,6 +214,78 @@ const NOTIFICATION_EMAIL_SETTINGS = [
|
|||||||
sendEmail: true,
|
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)
|
// Admin notifications (in-app only by default)
|
||||||
{
|
{
|
||||||
notificationType: 'FILTERING_COMPLETE',
|
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@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' },
|
||||||
{ email: 'matt@letsbe.solutions', name: 'Matt (Applicant)', role: UserRole.APPLICANT, password: '195260Mp!' },
|
{ email: 'matt@letsbe.solutions', name: 'Matt (Applicant)', role: UserRole.APPLICANT, password: '195260Mp!' },
|
||||||
{ email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' },
|
{ email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' },
|
||||||
{ email: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const staffUsers: Record<string, string> = {}
|
const staffUsers: Record<string, string> = {}
|
||||||
|
|||||||
101
scripts/cleanup-test-pollution.ts
Normal file
101
scripts/cleanup-test-pollution.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* One-shot: remove leaked test data from dev DB.
|
||||||
|
*
|
||||||
|
* Test runs that crashed before reaching `afterAll` left orphan test users +
|
||||||
|
* programs. This mirrors `tests/helpers.ts#cleanupTestData` with the same
|
||||||
|
* reverse-dependency order, applied to all programs whose name matches the
|
||||||
|
* test patterns.
|
||||||
|
*
|
||||||
|
* Run: npx tsx scripts/cleanup-test-pollution.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
const TEST_PROGRAM_PATTERNS = [
|
||||||
|
'Test Program prog-%',
|
||||||
|
'getCandidates-%',
|
||||||
|
'bulk-%',
|
||||||
|
'source-flag-%',
|
||||||
|
'mentor-files-%',
|
||||||
|
'mentor-config-%',
|
||||||
|
]
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const programs = await prisma.program.findMany({
|
||||||
|
where: {
|
||||||
|
OR: TEST_PROGRAM_PATTERNS.map((p) => ({ name: { startsWith: p.replace('%', '') } })),
|
||||||
|
},
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Found ${programs.length} test programs:`)
|
||||||
|
programs.forEach((p) => console.log(` - ${p.id} ${p.name}`))
|
||||||
|
|
||||||
|
for (const program of programs) {
|
||||||
|
const programId = program.id
|
||||||
|
console.log(`\nCleaning ${program.name}...`)
|
||||||
|
|
||||||
|
// MentorAssignment isn't in cleanupTestData — kill it first
|
||||||
|
const ma = await prisma.mentorAssignment.deleteMany({
|
||||||
|
where: { project: { programId } },
|
||||||
|
})
|
||||||
|
if (ma.count > 0) console.log(` ${ma.count} MentorAssignment`)
|
||||||
|
|
||||||
|
// Mirror tests/helpers.ts#cleanupTestData order
|
||||||
|
await prisma.cohortProject.deleteMany({ where: { cohort: { round: { competition: { programId } } } } })
|
||||||
|
await prisma.cohort.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.liveProgressCursor.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.filteringResult.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.filteringRule.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.filteringJob.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.assignmentJob.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.conflictOfInterest.deleteMany({ where: { assignment: { round: { competition: { programId } } } } })
|
||||||
|
await prisma.evaluation.deleteMany({ where: { assignment: { round: { competition: { programId } } } } })
|
||||||
|
await prisma.assignment.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.evaluationForm.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.fileRequirement.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.gracePeriod.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.reminderLog.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.evaluationSummary.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.evaluationDiscussion.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.projectRoundState.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.awardEligibility.deleteMany({ where: { award: { program: { id: programId } } } })
|
||||||
|
await prisma.awardVote.deleteMany({ where: { award: { program: { id: programId } } } })
|
||||||
|
await prisma.awardJuror.deleteMany({ where: { award: { program: { id: programId } } } })
|
||||||
|
await prisma.specialAward.deleteMany({ where: { programId } })
|
||||||
|
await prisma.round.deleteMany({ where: { competition: { programId } } })
|
||||||
|
await prisma.competition.deleteMany({ where: { programId } })
|
||||||
|
await prisma.projectStatusHistory.deleteMany({ where: { project: { programId } } })
|
||||||
|
await prisma.projectFile.deleteMany({ where: { project: { programId } } })
|
||||||
|
await prisma.projectTag.deleteMany({ where: { project: { programId } } })
|
||||||
|
await prisma.project.deleteMany({ where: { programId } })
|
||||||
|
await prisma.program.deleteMany({ where: { id: programId } })
|
||||||
|
console.log(' cascade complete')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete test users (@test.local). Catch any audit-log refs first.
|
||||||
|
const testUsers = await prisma.user.findMany({
|
||||||
|
where: { email: { endsWith: '@test.local' } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
const testUserIds = testUsers.map((u) => u.id)
|
||||||
|
console.log(`\nDeleting ${testUserIds.length} @test.local users...`)
|
||||||
|
if (testUserIds.length > 0) {
|
||||||
|
await prisma.decisionAuditLog.deleteMany({ where: { actorId: { in: testUserIds } } })
|
||||||
|
await prisma.auditLog.deleteMany({ where: { userId: { in: testUserIds } } })
|
||||||
|
// Any remaining MentorAssignments referencing these users (e.g., from other tests)
|
||||||
|
await prisma.mentorAssignment.deleteMany({ where: { mentorId: { in: testUserIds } } })
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: testUserIds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nDone.')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => prisma.$disconnect())
|
||||||
|
.catch(async (e) => {
|
||||||
|
console.error(e)
|
||||||
|
await prisma.$disconnect()
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
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 [votingEndAt, setVotingEndAt] = useState('')
|
||||||
const [evaluationRoundId, setEvaluationRoundId] = useState('')
|
const [evaluationRoundId, setEvaluationRoundId] = useState('')
|
||||||
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
|
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
|
||||||
const [decisionMode, setDecisionMode] = useState<'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION'>('JURY_VOTE')
|
const [decisionMode, setDecisionMode] = useState<'JURY_VOTE' | 'ADMIN_DECISION'>('JURY_VOTE')
|
||||||
|
|
||||||
// Helper to format date for datetime-local input
|
// Helper to format date for datetime-local input
|
||||||
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
||||||
@@ -236,7 +236,6 @@ export default function EditAwardPage({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="JURY_VOTE">Jury Vote — tallied from all jurors</SelectItem>
|
<SelectItem value="JURY_VOTE">Jury Vote — tallied from all jurors</SelectItem>
|
||||||
<SelectItem value="AWARD_MASTER_DECISION">Award Master — sponsor picks winner</SelectItem>
|
|
||||||
<SelectItem value="ADMIN_DECISION">Admin Decision — admin selects winner</SelectItem>
|
<SelectItem value="ADMIN_DECISION">Admin Decision — admin selects winner</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -335,20 +335,20 @@ function RoundsDndGrid({
|
|||||||
function ConfidenceBadge({ confidence }: { confidence: number }) {
|
function ConfidenceBadge({ confidence }: { confidence: number }) {
|
||||||
if (confidence > 0.8) {
|
if (confidence > 0.8) {
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs tabular-nums">
|
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700 text-xs tabular-nums">
|
||||||
{Math.round(confidence * 100)}%
|
{Math.round(confidence * 100)}%
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (confidence >= 0.5) {
|
if (confidence >= 0.5) {
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-400 text-xs tabular-nums">
|
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700 text-xs tabular-nums">
|
||||||
{Math.round(confidence * 100)}%
|
{Math.round(confidence * 100)}%
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-950/30 dark:text-red-400 text-xs tabular-nums">
|
<Badge variant="outline" className="border-red-300 bg-red-50 text-red-700 text-xs tabular-nums">
|
||||||
{Math.round(confidence * 100)}%
|
{Math.round(confidence * 100)}%
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
@@ -408,7 +408,7 @@ export default function AwardDetailPage({
|
|||||||
|
|
||||||
// Deferred queries - only load when needed
|
// Deferred queries - only load when needed
|
||||||
const { data: allUsers } = trpc.user.list.useQuery(
|
const { data: allUsers } = trpc.user.list.useQuery(
|
||||||
{ page: 1, perPage: 100, roles: ['JURY_MEMBER', 'AWARD_MASTER'] },
|
{ page: 1, perPage: 100, roles: ['JURY_MEMBER'] },
|
||||||
{ enabled: activeTab === 'jurors' }
|
{ enabled: activeTab === 'jurors' }
|
||||||
)
|
)
|
||||||
const { data: juryGroups } = trpc.juryGroup.list.useQuery(
|
const { data: juryGroups } = trpc.juryGroup.list.useQuery(
|
||||||
@@ -513,6 +513,13 @@ export default function AwardDetailPage({
|
|||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
const notifyJurors = trpc.specialAward.notifyJurors.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const failedNote = data.failed > 0 ? ` (${data.failed} failed)` : ''
|
||||||
|
toast.success(`Reminder sent to ${data.sent} of ${data.targeted} juror(s)${failedNote}`)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
const setWinner = trpc.specialAward.setWinner.useMutation({
|
const setWinner = trpc.specialAward.setWinner.useMutation({
|
||||||
onSuccess: invalidateAward,
|
onSuccess: invalidateAward,
|
||||||
})
|
})
|
||||||
@@ -890,8 +897,8 @@ export default function AwardDetailPage({
|
|||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Eligible</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Eligible</p>
|
||||||
<p className="text-2xl font-bold tabular-nums">{award.eligibleCount}</p>
|
<p className="text-2xl font-bold tabular-nums">{award.eligibleCount}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/40">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100">
|
||||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -903,8 +910,8 @@ export default function AwardDetailPage({
|
|||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p>
|
||||||
<p className="text-2xl font-bold tabular-nums">{(award as any).totalAssessed ?? award._count.eligibilities}</p>
|
<p className="text-2xl font-bold tabular-nums">{(award as any).totalAssessed ?? award._count.eligibilities}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
|
||||||
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
<ListChecks className="h-5 w-5 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -916,8 +923,8 @@ export default function AwardDetailPage({
|
|||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Jurors</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Jurors</p>
|
||||||
<p className="text-2xl font-bold tabular-nums">{award._count.jurors}</p>
|
<p className="text-2xl font-bold tabular-nums">{award._count.jurors}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-100 dark:bg-violet-950/40">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-100">
|
||||||
<Users className="h-5 w-5 text-violet-600 dark:text-violet-400" />
|
<Users className="h-5 w-5 text-violet-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -929,8 +936,8 @@ export default function AwardDetailPage({
|
|||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Votes</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Votes</p>
|
||||||
<p className="text-2xl font-bold tabular-nums">{award._count.votes}</p>
|
<p className="text-2xl font-bold tabular-nums">{award._count.votes}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-950/40">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100">
|
||||||
<Vote className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
<Vote className="h-5 w-5 text-amber-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -1335,7 +1342,7 @@ export default function AwardDetailPage({
|
|||||||
|
|
||||||
{/* Jurors Tab */}
|
{/* Jurors Tab */}
|
||||||
<TabsContent value="jurors" className="space-y-4">
|
<TabsContent value="jurors" className="space-y-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Select value={selectedJurorId} onValueChange={setSelectedJurorId}>
|
<Select value={selectedJurorId} onValueChange={setSelectedJurorId}>
|
||||||
<SelectTrigger className="w-64">
|
<SelectTrigger className="w-64">
|
||||||
<SelectValue placeholder="Select a juror..." />
|
<SelectValue placeholder="Select a juror..." />
|
||||||
@@ -1355,6 +1362,19 @@ export default function AwardDetailPage({
|
|||||||
<UserPlus className="mr-2 h-4 w-4" />
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
Add Juror
|
Add Juror
|
||||||
</Button>
|
</Button>
|
||||||
|
{jurors && jurors.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => notifyJurors.mutate({ awardId })}
|
||||||
|
disabled={notifyJurors.isPending}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
|
{notifyJurors.isPending
|
||||||
|
? 'Sending...'
|
||||||
|
: `Send reminder to all (${jurors.length})`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Import from Jury Group */}
|
{/* Import from Jury Group */}
|
||||||
@@ -1498,7 +1518,6 @@ export default function AwardDetailPage({
|
|||||||
onSubmit={async (rows) => {
|
onSubmit={async (rows) => {
|
||||||
await bulkInvite.mutateAsync({
|
await bulkInvite.mutateAsync({
|
||||||
awardId,
|
awardId,
|
||||||
role: 'AWARD_MASTER',
|
|
||||||
invitees: rows.map((r) => ({ name: r.name || undefined, email: r.email })),
|
invitees: rows.map((r) => ({ name: r.name || undefined, email: r.email })),
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
@@ -1549,11 +1568,23 @@ export default function AwardDetailPage({
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
notifyJurors.mutate({ awardId, userIds: [j.userId] })
|
||||||
|
}
|
||||||
|
disabled={notifyJurors.isPending}
|
||||||
|
title="Send reminder email"
|
||||||
|
>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleRemoveJuror(j.userId)}
|
onClick={() => handleRemoveJuror(j.userId)}
|
||||||
disabled={removeJuror.isPending}
|
disabled={removeJuror.isPending}
|
||||||
|
title="Remove juror"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1581,7 +1612,7 @@ export default function AwardDetailPage({
|
|||||||
{/* Rounds Tab */}
|
{/* Rounds Tab */}
|
||||||
<TabsContent value="rounds" className="space-y-4">
|
<TabsContent value="rounds" className="space-y-4">
|
||||||
{award.eligibilityMode !== 'SEPARATE_POOL' && (
|
{award.eligibilityMode !== 'SEPARATE_POOL' && (
|
||||||
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
|
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800">
|
||||||
<Info className="h-4 w-4 mt-0.5 shrink-0" />
|
<Info className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects.
|
Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects.
|
||||||
@@ -1589,7 +1620,7 @@ export default function AwardDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!award.competitionId && (
|
{!award.competitionId && (
|
||||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800">
|
||||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Link this award to a competition first before creating rounds.
|
Link this award to a competition first before creating rounds.
|
||||||
@@ -1719,16 +1750,16 @@ export default function AwardDetailPage({
|
|||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={r.project.id}
|
key={r.project.id}
|
||||||
className={isWinner ? 'bg-amber-50/80 dark:bg-amber-950/20' : ''}
|
className={isWinner ? 'bg-amber-50/80' : ''}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold ${
|
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold ${
|
||||||
i === 0
|
i === 0
|
||||||
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
|
? 'bg-amber-100 text-amber-800'
|
||||||
: i === 1
|
: i === 1
|
||||||
? 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-300'
|
? 'bg-slate-200 text-slate-700'
|
||||||
: i === 2
|
: i === 2
|
||||||
? 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300'
|
? 'bg-orange-100 text-orange-800'
|
||||||
: 'text-muted-foreground'
|
: 'text-muted-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
|
|||||||
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 { Textarea } from '@/components/ui/textarea'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { cn, formatEnumLabel } from '@/lib/utils'
|
import { cn, formatEnumLabel } from '@/lib/utils'
|
||||||
import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot } from 'lucide-react'
|
import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot, Trophy } from 'lucide-react'
|
||||||
|
|
||||||
const capModeLabels = {
|
const capModeLabels = {
|
||||||
HARD: 'Hard Cap',
|
HARD: 'Hard Cap',
|
||||||
@@ -301,10 +301,10 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Round assignments */}
|
{/* Round + Special-award assignments */}
|
||||||
{(group as any).rounds?.length > 0 && (
|
{((group as any).rounds?.length > 0 || (group as any).awards?.length > 0) && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{(group as any).rounds.map((r: any) => (
|
{(group as any).rounds?.map((r: any) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={r.id}
|
key={r.id}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -319,6 +319,21 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
|
|||||||
{r.name}
|
{r.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
{(group as any).awards?.map((a: any) => (
|
||||||
|
<Badge
|
||||||
|
key={a.id}
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] gap-1',
|
||||||
|
a.status === 'VOTING_OPEN' && 'border-amber-300 bg-amber-50 text-amber-700',
|
||||||
|
a.status === 'CLOSED' && 'border-emerald-300 bg-emerald-50 text-emerald-700',
|
||||||
|
(a.status === 'DRAFT' || a.status === 'NOMINATIONS_OPEN') && 'border-slate-200 text-slate-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trophy className="h-2.5 w-2.5" />
|
||||||
|
{a.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ const ROLE_OPTIONS = [
|
|||||||
{ value: 'MENTOR', label: 'Mentors' },
|
{ value: 'MENTOR', label: 'Mentors' },
|
||||||
{ value: 'OBSERVER', label: 'Observers' },
|
{ value: 'OBSERVER', label: 'Observers' },
|
||||||
{ value: 'APPLICANT', label: 'Applicants' },
|
{ value: 'APPLICANT', label: 'Applicants' },
|
||||||
{ value: 'AWARD_MASTER', label: 'Award Masters' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
type AccessRule =
|
type AccessRule =
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ const ROLE_OPTIONS = [
|
|||||||
{ value: 'MENTOR', label: 'Mentors' },
|
{ value: 'MENTOR', label: 'Mentors' },
|
||||||
{ value: 'OBSERVER', label: 'Observers' },
|
{ value: 'OBSERVER', label: 'Observers' },
|
||||||
{ value: 'APPLICANT', label: 'Applicants' },
|
{ value: 'APPLICANT', label: 'Applicants' },
|
||||||
{ value: 'AWARD_MASTER', label: 'Award Masters' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
type AccessRule =
|
type AccessRule =
|
||||||
|
|||||||
86
src/app/(admin)/admin/logistics/page.tsx
Normal file
86
src/app/(admin)/admin/logistics/page.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useEdition } from '@/contexts/edition-context'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Hotel as HotelIcon,
|
||||||
|
Plane,
|
||||||
|
Salad,
|
||||||
|
ScrollText,
|
||||||
|
Stamp,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { ConfirmationsTab } from '@/components/admin/logistics/confirmations-tab'
|
||||||
|
import { TravelTab } from '@/components/admin/logistics/travel-tab'
|
||||||
|
import { HotelsTab } from '@/components/admin/logistics/hotels-tab'
|
||||||
|
import { VisasTab } from '@/components/admin/logistics/visas-tab'
|
||||||
|
import { LunchTab } from '@/components/admin/logistics/lunch-tab'
|
||||||
|
import { EmailTemplatesTab } from '@/components/admin/logistics/email-templates-tab'
|
||||||
|
|
||||||
|
export default function LogisticsPage() {
|
||||||
|
const { currentEdition } = useEdition()
|
||||||
|
const [tab, setTab] = useState('confirmations')
|
||||||
|
|
||||||
|
if (!currentEdition) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted-foreground py-12 text-center text-sm">
|
||||||
|
Select an edition to view logistics.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const programId = currentEdition.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Logistics</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Operational hub for the grand finale: confirmations, travel, hotels, and more.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={tab} onValueChange={setTab} className="space-y-6">
|
||||||
|
<TabsList className="h-auto w-full justify-start overflow-x-auto pb-2">
|
||||||
|
<TabsTrigger value="confirmations">
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" /> Confirmations
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="travel">
|
||||||
|
<Plane className="mr-2 h-4 w-4" /> Travel
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="hotels">
|
||||||
|
<HotelIcon className="mr-2 h-4 w-4" /> Hotels
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="visas">
|
||||||
|
<Stamp className="mr-2 h-4 w-4" /> Visas
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="lunch">
|
||||||
|
<Salad className="mr-2 h-4 w-4" /> Lunch
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="email-templates">
|
||||||
|
<ScrollText className="mr-2 h-4 w-4" /> Email Templates
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="confirmations">
|
||||||
|
<ConfirmationsTab programId={programId} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="travel">
|
||||||
|
<TravelTab programId={programId} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="hotels">
|
||||||
|
<HotelsTab programId={programId} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="visas">
|
||||||
|
<VisasTab programId={programId} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="lunch">
|
||||||
|
<LunchTab programId={programId} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="email-templates">
|
||||||
|
<EmailTemplatesTab programId={programId} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -48,6 +48,22 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import {
|
import {
|
||||||
@@ -69,6 +85,11 @@ import {
|
|||||||
LogIn,
|
LogIn,
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||||
import { formatRelativeTime } from '@/lib/utils'
|
import { formatRelativeTime } from '@/lib/utils'
|
||||||
@@ -97,7 +118,6 @@ const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
|||||||
PROGRAM_ADMIN: 'default',
|
PROGRAM_ADMIN: 'default',
|
||||||
SUPER_ADMIN: 'default',
|
SUPER_ADMIN: 'default',
|
||||||
APPLICANT: 'secondary',
|
APPLICANT: 'secondary',
|
||||||
AWARD_MASTER: 'outline',
|
|
||||||
AUDIENCE: 'outline',
|
AUDIENCE: 'outline',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +132,45 @@ export default function MemberDetailPage() {
|
|||||||
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
|
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
|
||||||
const updateUser = trpc.user.update.useMutation()
|
const updateUser = trpc.user.update.useMutation()
|
||||||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
||||||
|
const generateAccessLink = trpc.user.generateAccessLink.useMutation()
|
||||||
const startImpersonation = trpc.user.startImpersonation.useMutation()
|
const startImpersonation = trpc.user.startImpersonation.useMutation()
|
||||||
|
|
||||||
|
const [accessLinkOpen, setAccessLinkOpen] = useState(false)
|
||||||
|
const [accessLink, setAccessLink] = useState<{
|
||||||
|
url: string
|
||||||
|
kind: 'setup' | 'magic_login'
|
||||||
|
expiresAt: Date
|
||||||
|
} | null>(null)
|
||||||
|
const [linkCopied, setLinkCopied] = useState(false)
|
||||||
|
|
||||||
|
const handleGenerateAccessLink = async () => {
|
||||||
|
try {
|
||||||
|
const result = await generateAccessLink.mutateAsync({ userId })
|
||||||
|
setAccessLink({
|
||||||
|
url: result.url,
|
||||||
|
kind: result.kind,
|
||||||
|
expiresAt: new Date(result.expiresAt),
|
||||||
|
})
|
||||||
|
setLinkCopied(false)
|
||||||
|
setAccessLinkOpen(true)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Failed to generate access link'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyAccessLink = async () => {
|
||||||
|
if (!accessLink) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(accessLink.url)
|
||||||
|
setLinkCopied(true)
|
||||||
|
toast.success('Link copied to clipboard')
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not copy — please select and copy the link manually')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mentor assignments (only fetched for mentors)
|
// Mentor assignments (only fetched for mentors)
|
||||||
const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery(
|
const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery(
|
||||||
{ mentorId: userId, page: 1, perPage: 50 },
|
{ mentorId: userId, page: 1, perPage: 50 },
|
||||||
@@ -138,6 +195,10 @@ export default function MemberDetailPage() {
|
|||||||
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
||||||
const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false)
|
const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false)
|
||||||
const [pendingSuperAdminRole, setPendingSuperAdminRole] = useState(false)
|
const [pendingSuperAdminRole, setPendingSuperAdminRole] = useState(false)
|
||||||
|
const [pendingAdditionalRole, setPendingAdditionalRole] = useState<{
|
||||||
|
role: 'JURY_MEMBER' | 'OBSERVER' | 'MENTOR'
|
||||||
|
action: 'add' | 'remove'
|
||||||
|
} | null>(null)
|
||||||
const [additionalRoles, setAdditionalRoles] = useState<string[]>([])
|
const [additionalRoles, setAdditionalRoles] = useState<string[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -154,7 +215,7 @@ export default function MemberDetailPage() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
const allRoles = [role, ...additionalRoles.filter((r) => r !== role)] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'>
|
const allRoles = [role, ...additionalRoles.filter((r) => r !== role)] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'>
|
||||||
await updateUser.mutateAsync({
|
await updateUser.mutateAsync({
|
||||||
id: userId,
|
id: userId,
|
||||||
email: email || undefined,
|
email: email || undefined,
|
||||||
@@ -283,6 +344,21 @@ export default function MemberDetailPage() {
|
|||||||
{user.status === 'INVITED' ? 'Resend Invite' : 'Send Invitation'}
|
{user.status === 'INVITED' ? 'Resend Invite' : 'Send Invitation'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{user.status !== 'SUSPENDED' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleGenerateAccessLink}
|
||||||
|
disabled={generateAccessLink.isPending}
|
||||||
|
title="Generate a one-time link to share manually if email isn't reaching them"
|
||||||
|
>
|
||||||
|
{generateAccessLink.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LinkIcon className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Copy Access Link
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleImpersonate}
|
onClick={handleImpersonate}
|
||||||
@@ -622,7 +698,6 @@ export default function MemberDetailPage() {
|
|||||||
<SelectItem value="MENTOR">Mentor</SelectItem>
|
<SelectItem value="MENTOR">Mentor</SelectItem>
|
||||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||||
<SelectItem value="APPLICANT">Applicant</SelectItem>
|
<SelectItem value="APPLICANT">Applicant</SelectItem>
|
||||||
<SelectItem value="AWARD_MASTER">Award Master</SelectItem>
|
|
||||||
<SelectItem value="AUDIENCE">Audience</SelectItem>
|
<SelectItem value="AUDIENCE">Audience</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -630,26 +705,72 @@ export default function MemberDetailPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Additional Roles</Label>
|
<Label>Additional Roles</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Grant additional dashboard access beyond the primary role
|
Grant additional dashboard access beyond the primary role.
|
||||||
|
Click the menu to add or remove a role — you'll be
|
||||||
|
asked to confirm each change.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{(['JURY_MEMBER', 'OBSERVER', 'MENTOR', 'AWARD_MASTER'] as const)
|
{additionalRoles.length === 0 ? (
|
||||||
.filter((r) => r !== role)
|
<span className="text-sm text-muted-foreground italic">
|
||||||
.map((r) => (
|
None — only the primary role above
|
||||||
<label key={r} className="flex items-center gap-2 text-sm cursor-pointer">
|
</span>
|
||||||
<Checkbox
|
) : (
|
||||||
checked={additionalRoles.includes(r)}
|
additionalRoles.map((r) => (
|
||||||
onCheckedChange={(checked) => {
|
<Badge
|
||||||
if (checked) {
|
key={r}
|
||||||
setAdditionalRoles((prev) => [...prev, r])
|
variant={roleColors[r] || 'secondary'}
|
||||||
} else {
|
className="gap-1.5 pl-2 pr-1 py-0.5"
|
||||||
setAdditionalRoles((prev) => prev.filter((x) => x !== r))
|
>
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{r.replace(/_/g, ' ')}
|
{r.replace(/_/g, ' ')}
|
||||||
</label>
|
<button
|
||||||
))}
|
type="button"
|
||||||
|
aria-label={`Remove ${r.replace(/_/g, ' ')} role`}
|
||||||
|
className="rounded-full hover:bg-foreground/10 p-0.5 transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
setPendingAdditionalRole({
|
||||||
|
role: r as 'JURY_MEMBER' | 'OBSERVER' | 'MENTOR',
|
||||||
|
action: 'remove',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" type="button">
|
||||||
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Manage roles
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
|
<DropdownMenuLabel>Toggle additional roles</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{(['JURY_MEMBER', 'OBSERVER', 'MENTOR'] as const)
|
||||||
|
.filter((r) => r !== role)
|
||||||
|
.map((r) => {
|
||||||
|
const isAssigned = additionalRoles.includes(r)
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={r}
|
||||||
|
checked={isAssigned}
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setPendingAdditionalRole({
|
||||||
|
role: r,
|
||||||
|
action: isAssigned ? 'remove' : 'add',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.replace(/_/g, ' ')}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -821,6 +942,81 @@ export default function MemberDetailPage() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Super Admin Confirmation Dialog */}
|
{/* Super Admin Confirmation Dialog */}
|
||||||
|
<AlertDialog
|
||||||
|
open={pendingAdditionalRole !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setPendingAdditionalRole(null) }}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{pendingAdditionalRole?.action === 'add' ? 'Add' : 'Remove'}{' '}
|
||||||
|
{pendingAdditionalRole?.role.replace(/_/g, ' ')} role?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{pendingAdditionalRole?.action === 'add' ? (
|
||||||
|
<>
|
||||||
|
This will give <strong>{name || user?.name || 'this user'}</strong>{' '}
|
||||||
|
the {pendingAdditionalRole.role.replace(/_/g, ' ')} dashboard
|
||||||
|
in addition to their primary role. They'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}>
|
<AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@@ -848,6 +1044,67 @@ export default function MemberDetailPage() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<Dialog open={accessLinkOpen} onOpenChange={setAccessLinkOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
Access link ready
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{accessLink?.kind === 'magic_login'
|
||||||
|
? `Share this with ${user.name || user.email} via Slack, WhatsApp, or any channel that reaches them. Clicking it will sign them in directly (their existing password is preserved) and take them to their dashboard.`
|
||||||
|
: `Share this with ${user.name || user.email} via Slack, WhatsApp, or any channel that reaches them. They'll be walked through setting a password and onboarding.`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-md border bg-muted/40 p-3">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={accessLink?.url ?? ''}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className="font-mono text-xs bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Expires {accessLink?.expiresAt.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
|
||||||
|
{' · '}consumed on first successful login
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Don'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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ import { useSession } from 'next-auth/react'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type Step = 'input' | 'preview' | 'sending' | 'complete'
|
type Step = 'input' | 'preview' | 'sending' | 'complete'
|
||||||
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||||
|
|
||||||
interface Assignment {
|
interface Assignment {
|
||||||
projectId: string
|
projectId: string
|
||||||
@@ -106,7 +106,6 @@ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|||||||
const ROLE_LABELS: Record<Role, string> = {
|
const ROLE_LABELS: Record<Role, string> = {
|
||||||
SUPER_ADMIN: 'Super Admin',
|
SUPER_ADMIN: 'Super Admin',
|
||||||
PROGRAM_ADMIN: 'Program Admin',
|
PROGRAM_ADMIN: 'Program Admin',
|
||||||
AWARD_MASTER: 'Award Master',
|
|
||||||
JURY_MEMBER: 'Jury Member',
|
JURY_MEMBER: 'Jury Member',
|
||||||
MENTOR: 'Mentor',
|
MENTOR: 'Mentor',
|
||||||
OBSERVER: 'Observer',
|
OBSERVER: 'Observer',
|
||||||
@@ -289,7 +288,7 @@ export default function MemberInvitePage() {
|
|||||||
const availableRoles = useMemo((): Role[] => {
|
const availableRoles = useMemo((): Role[] => {
|
||||||
const roles: Role[] = []
|
const roles: Role[] = []
|
||||||
if (isSuperAdmin) roles.push('SUPER_ADMIN')
|
if (isSuperAdmin) roles.push('SUPER_ADMIN')
|
||||||
if (isAdmin) roles.push('PROGRAM_ADMIN', 'AWARD_MASTER')
|
if (isAdmin) roles.push('PROGRAM_ADMIN')
|
||||||
roles.push('JURY_MEMBER', 'MENTOR', 'OBSERVER')
|
roles.push('JURY_MEMBER', 'MENTOR', 'OBSERVER')
|
||||||
return roles
|
return roles
|
||||||
}, [isSuperAdmin, isAdmin])
|
}, [isSuperAdmin, isAdmin])
|
||||||
@@ -423,8 +422,6 @@ export default function MemberInvitePage() {
|
|||||||
? 'SUPER_ADMIN'
|
? 'SUPER_ADMIN'
|
||||||
: rawRole === 'PROGRAM_ADMIN'
|
: rawRole === 'PROGRAM_ADMIN'
|
||||||
? 'PROGRAM_ADMIN'
|
? 'PROGRAM_ADMIN'
|
||||||
: rawRole === 'AWARD_MASTER'
|
|
||||||
? 'AWARD_MASTER'
|
|
||||||
: rawRole === 'MENTOR'
|
: rawRole === 'MENTOR'
|
||||||
? 'MENTOR'
|
? 'MENTOR'
|
||||||
: rawRole === 'OBSERVER'
|
: rawRole === 'OBSERVER'
|
||||||
@@ -910,7 +907,7 @@ export default function MemberInvitePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!sendInvitation && (
|
{!sendInvitation && (
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-blue-500/10 p-4 text-blue-700 dark:text-blue-400">
|
<div className="flex items-start gap-3 rounded-lg bg-blue-500/10 p-4 text-blue-700">
|
||||||
<MailX className="h-5 w-5 shrink-0 mt-0.5" />
|
<MailX className="h-5 w-5 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">No invitations will be sent</p>
|
<p className="font-medium">No invitations will be sent</p>
|
||||||
|
|||||||
@@ -1,5 +1,470 @@
|
|||||||
import { redirect } from 'next/navigation'
|
'use client'
|
||||||
|
|
||||||
export default function MentorsPage() {
|
import { useMemo, useState } from 'react'
|
||||||
redirect('/admin/members')
|
import Link from 'next/link'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { ArrowUpDown, GraduationCap, Search, Users } from 'lucide-react'
|
||||||
|
import { formatEnumLabel } from '@/lib/utils'
|
||||||
|
import { MentorDetailSheet } from '@/components/admin/mentor/mentor-detail-sheet'
|
||||||
|
|
||||||
|
type SortKey = 'name' | 'load' | 'capacity' | 'lastActivity'
|
||||||
|
|
||||||
|
function formatRelativePast(date: Date | string | null): string {
|
||||||
|
if (!date) return '—'
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date
|
||||||
|
const ms = Date.now() - d.getTime()
|
||||||
|
const days = Math.floor(ms / 86_400_000)
|
||||||
|
const hours = Math.floor(ms / 3_600_000)
|
||||||
|
if (days >= 1) return `${days}d ago`
|
||||||
|
if (hours >= 1) return `${hours}h ago`
|
||||||
|
const minutes = Math.floor(ms / 60_000)
|
||||||
|
return `${Math.max(0, minutes)}m ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<
|
||||||
|
'unassigned' | 'assigned' | 'active' | 'stalled',
|
||||||
|
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
|
||||||
|
> = {
|
||||||
|
unassigned: { label: 'Unassigned', variant: 'outline' },
|
||||||
|
assigned: { label: 'Assigned', variant: 'secondary' },
|
||||||
|
active: { label: 'Active', variant: 'default' },
|
||||||
|
stalled: { label: 'Stalled', variant: 'destructive' },
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mentor = {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
email: string
|
||||||
|
country: string | null
|
||||||
|
expertiseTags: string[]
|
||||||
|
currentAssignments: number
|
||||||
|
completedAssignments: number
|
||||||
|
maxAssignments: number | null
|
||||||
|
capacityRemaining: number | null
|
||||||
|
lastActivityAt: Date | string | null
|
||||||
|
activeTeams: { id: string; title: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function MentorListPanel() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>('load')
|
||||||
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||||
|
const [detailMentorId, setDetailMentorId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = trpc.mentor.getMentorPool.useQuery({})
|
||||||
|
|
||||||
|
const filtered = useMemo<Mentor[]>(() => {
|
||||||
|
if (!data) return []
|
||||||
|
const q = search.trim().toLowerCase()
|
||||||
|
let rows: Mentor[] = data.mentors
|
||||||
|
if (q) {
|
||||||
|
rows = rows.filter((m) =>
|
||||||
|
[m.name ?? '', m.email, m.country ?? '', ...m.expertiseTags]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(q),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rows = [...rows].sort((a, b) => {
|
||||||
|
let av: string | number = 0
|
||||||
|
let bv: string | number = 0
|
||||||
|
switch (sortKey) {
|
||||||
|
case 'name':
|
||||||
|
av = (a.name ?? '').toLowerCase()
|
||||||
|
bv = (b.name ?? '').toLowerCase()
|
||||||
|
break
|
||||||
|
case 'load':
|
||||||
|
av = a.currentAssignments
|
||||||
|
bv = b.currentAssignments
|
||||||
|
break
|
||||||
|
case 'capacity':
|
||||||
|
av = a.capacityRemaining ?? Number.MAX_SAFE_INTEGER
|
||||||
|
bv = b.capacityRemaining ?? Number.MAX_SAFE_INTEGER
|
||||||
|
break
|
||||||
|
case 'lastActivity':
|
||||||
|
av = a.lastActivityAt ? new Date(a.lastActivityAt).getTime() : 0
|
||||||
|
bv = b.lastActivityAt ? new Date(b.lastActivityAt).getTime() : 0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (av < bv) return sortDir === 'asc' ? -1 : 1
|
||||||
|
if (av > bv) return sortDir === 'asc' ? 1 : -1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
}, [data, search, sortKey, sortDir])
|
||||||
|
|
||||||
|
const toggleSort = (key: SortKey) => {
|
||||||
|
if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||||
|
else {
|
||||||
|
setSortKey(key)
|
||||||
|
setSortDir(key === 'name' ? 'asc' : 'desc')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortHeader = ({
|
||||||
|
k,
|
||||||
|
children,
|
||||||
|
align = 'left',
|
||||||
|
}: {
|
||||||
|
k: SortKey
|
||||||
|
children: React.ReactNode
|
||||||
|
align?: 'left' | 'right'
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSort(k)}
|
||||||
|
className={`flex items-center gap-1 text-sm font-medium ${align === 'right' ? 'ml-auto' : ''}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ArrowUpDown
|
||||||
|
className={`h-3 w-3 ${sortKey === k ? 'text-foreground' : 'text-muted-foreground/50'}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-4">
|
||||||
|
<CardTitle className="text-base">Mentor list</CardTitle>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by name, email, country, or expertise tag…"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||||
|
{search ? 'No matching mentors.' : 'No mentors yet.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>
|
||||||
|
<SortHeader k="name">Mentor</SortHeader>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Expertise</TableHead>
|
||||||
|
<TableHead>Country</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
<SortHeader k="capacity" align="right">
|
||||||
|
Capacity
|
||||||
|
</SortHeader>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<SortHeader k="lastActivity">Last activity</SortHeader>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<SortHeader k="load">Teams</SortHeader>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map((m) => (
|
||||||
|
<TableRow
|
||||||
|
key={m.id}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setDetailMentorId(m.id)}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{m.name ?? 'Unnamed'}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">{m.email}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{m.expertiseTags.slice(0, 4).map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-xs">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{m.expertiseTags.length > 4 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{m.expertiseTags.length - 4}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{m.country ?? '—'}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm tabular-nums">
|
||||||
|
{m.capacityRemaining != null ? m.capacityRemaining : '∞'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{formatRelativePast(m.lastActivityAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{m.activeTeams.length === 0 ? (
|
||||||
|
<span className="text-muted-foreground text-xs">—</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{m.activeTeams.slice(0, 2).map((t) => (
|
||||||
|
<Badge
|
||||||
|
key={t.id}
|
||||||
|
variant="outline"
|
||||||
|
className="max-w-[12rem] truncate text-xs"
|
||||||
|
title={t.title}
|
||||||
|
>
|
||||||
|
{t.title}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{m.activeTeams.length > 2 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{m.activeTeams.length - 2}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<MentorDetailSheet
|
||||||
|
mentorId={detailMentorId}
|
||||||
|
open={!!detailMentorId}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) setDetailMentorId(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | 'unassigned' | 'assigned' | 'active' | 'stalled'
|
||||||
|
|
||||||
|
function MenteeActivityPanel() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||||
|
|
||||||
|
const { data, isLoading } = trpc.mentor.getMenteeActivity.useQuery({})
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!data) return []
|
||||||
|
const q = search.trim().toLowerCase()
|
||||||
|
return data.rows.filter((r) => {
|
||||||
|
if (statusFilter !== 'all' && r.status !== statusFilter) return false
|
||||||
|
if (!q) return true
|
||||||
|
const hay = [
|
||||||
|
r.project.title,
|
||||||
|
r.project.country ?? '',
|
||||||
|
r.teamLead?.name ?? '',
|
||||||
|
r.teamLead?.email ?? '',
|
||||||
|
r.mentor?.name ?? '',
|
||||||
|
r.mentor?.email ?? '',
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
return hay.includes(q)
|
||||||
|
})
|
||||||
|
}, [data, search, statusFilter])
|
||||||
|
|
||||||
|
const totals = data?.totals ?? { unassigned: 0, assigned: 0, active: 0, stalled: 0 }
|
||||||
|
|
||||||
|
const StatusPill = ({ value, label, count }: { value: StatusFilter; label: string; count: number }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter(value)}
|
||||||
|
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||||
|
statusFilter === value
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-background hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label} <span className="tabular-nums opacity-80">({count})</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<CardTitle className="text-base">Mentee teams</CardTitle>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<StatusPill
|
||||||
|
value="all"
|
||||||
|
label="All"
|
||||||
|
count={
|
||||||
|
totals.unassigned + totals.assigned + totals.active + totals.stalled
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatusPill value="unassigned" label="Unassigned" count={totals.unassigned} />
|
||||||
|
<StatusPill value="assigned" label="Assigned" count={totals.assigned} />
|
||||||
|
<StatusPill value="active" label="Active" count={totals.active} />
|
||||||
|
<StatusPill value="stalled" label="Stalled" count={totals.stalled} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by project, team lead, or mentor…"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||||
|
{search || statusFilter !== 'all'
|
||||||
|
? 'No matching teams.'
|
||||||
|
: 'No teams have requested mentorship yet.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Project</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Mentor</TableHead>
|
||||||
|
<TableHead className="text-right">Messages</TableHead>
|
||||||
|
<TableHead className="text-right">Files</TableHead>
|
||||||
|
<TableHead>Last activity</TableHead>
|
||||||
|
<TableHead></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map((r) => {
|
||||||
|
const badge = STATUS_BADGE[r.status]
|
||||||
|
return (
|
||||||
|
<TableRow key={r.project.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{r.project.title}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{r.teamLead?.name ?? r.teamLead?.email ?? '—'}
|
||||||
|
{r.project.oceanIssue && (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
{formatEnumLabel(r.project.oceanIssue)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={badge.variant} className="text-xs">
|
||||||
|
{badge.label}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{r.mentor ? (
|
||||||
|
<div className="text-sm">
|
||||||
|
<div>{r.mentor.name ?? r.mentor.email}</div>
|
||||||
|
<div className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
{r.mentor.currentLoad}
|
||||||
|
{r.mentor.maxAssignments != null
|
||||||
|
? `/${r.mentor.maxAssignments}`
|
||||||
|
: ''}
|
||||||
|
{' load'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{r.messageCount}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{r.fileCount}</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{formatRelativePast(r.lastActivityAt as unknown as Date | null)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<Link href={`/admin/projects/${r.project.id}/mentor`}>
|
||||||
|
{r.mentor ? 'Open' : 'Assign'}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MentorsListPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Mentors</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage the mentor pool and track mentee teams across the program.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/admin/members">
|
||||||
|
<Users className="mr-2 h-4 w-4" />
|
||||||
|
Manage Members
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="mentors" className="space-y-6">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="mentors">
|
||||||
|
<GraduationCap className="mr-2 h-4 w-4" /> Mentors
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="mentees">
|
||||||
|
<Users className="mr-2 h-4 w-4" /> Mentees & Activity
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="mentors">
|
||||||
|
<MentorListPanel />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="mentees">
|
||||||
|
<MenteeActivityPanel />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -75,7 +75,9 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
Plus,
|
Plus,
|
||||||
X,
|
X,
|
||||||
|
Mail,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { ProjectEmailDialog } from '@/components/admin/project-email-dialog'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||||
@@ -161,6 +163,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
// State for remove member confirmation
|
// State for remove member confirmation
|
||||||
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
||||||
|
const [emailDialogOpen, setEmailDialogOpen] = useState(false)
|
||||||
|
|
||||||
const addTeamMember = trpc.project.addTeamMember.useMutation({
|
const addTeamMember = trpc.project.addTeamMember.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -269,6 +272,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setEmailDialogOpen(true)}>
|
||||||
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
|
Email Team
|
||||||
|
</Button>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link href={`/admin/projects/${projectId}/edit`}>
|
<Link href={`/admin/projects/${projectId}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
@@ -276,6 +284,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{project && (
|
||||||
|
<ProjectEmailDialog
|
||||||
|
open={emailDialogOpen}
|
||||||
|
onClose={() => setEmailDialogOpen(false)}
|
||||||
|
projectId={project.id}
|
||||||
|
projectTitle={project.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ export default function BulkUploadPage() {
|
|||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.project.id}
|
key={row.project.id}
|
||||||
className={row.isComplete ? 'bg-green-50/50 dark:bg-green-950/10' : ''}
|
className={row.isComplete ? 'bg-green-50/50' : ''}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -53,15 +53,15 @@ type TeamMemberEntry = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_COLORS: Record<string, string> = {
|
const ROLE_COLORS: Record<string, string> = {
|
||||||
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
LEAD: 'bg-red-100 text-red-700',
|
||||||
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400',
|
MEMBER: 'bg-teal-100 text-teal-700',
|
||||||
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
ADVISOR: 'bg-blue-100 text-blue-700',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_AVATAR_COLORS: Record<string, string> = {
|
const ROLE_AVATAR_COLORS: Record<string, string> = {
|
||||||
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
LEAD: 'bg-red-100 text-red-700',
|
||||||
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
|
MEMBER: 'bg-teal-100 text-teal-700',
|
||||||
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
ADVISOR: 'bg-blue-100 text-blue-700',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
|||||||
@@ -679,7 +679,7 @@ export default function ProjectsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setAiTagDialogOpen(true)}
|
onClick={() => setAiTagDialogOpen(true)}
|
||||||
className={taggingInProgress ? 'border-amber-400 bg-amber-50 dark:bg-amber-950/20' : ''}
|
className={taggingInProgress ? 'border-amber-400 bg-amber-50' : ''}
|
||||||
>
|
>
|
||||||
{taggingInProgress ? (
|
{taggingInProgress ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-amber-600" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin text-amber-600" />
|
||||||
@@ -1716,15 +1716,15 @@ export default function ProjectsPage() {
|
|||||||
<div className="space-y-6 py-4">
|
<div className="space-y-6 py-4">
|
||||||
{/* Progress Indicator (when running) */}
|
{/* Progress Indicator (when running) */}
|
||||||
{taggingInProgress && (
|
{taggingInProgress && (
|
||||||
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
|
<div className="p-4 rounded-lg bg-blue-50 border border-blue-200">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
|
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-medium text-blue-900 dark:text-blue-100">
|
<p className="font-medium text-blue-900">
|
||||||
AI Tagging in Progress
|
AI Tagging in Progress
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
<p className="text-sm text-blue-700">
|
||||||
{jobStatus?.status === 'PENDING'
|
{jobStatus?.status === 'PENDING'
|
||||||
? 'Initializing...'
|
? 'Initializing...'
|
||||||
: `Processing ${jobStatus?.totalProjects || 0} projects with AI...`}
|
: `Processing ${jobStatus?.totalProjects || 0} projects with AI...`}
|
||||||
@@ -1739,12 +1739,12 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-blue-700 dark:text-blue-300">
|
<span className="text-blue-700">
|
||||||
{jobStatus?.processedCount || 0} of {jobStatus?.totalProjects || '?'} projects processed
|
{jobStatus?.processedCount || 0} of {jobStatus?.totalProjects || '?'} projects processed
|
||||||
{jobStatus?.taggedCount ? ` (${jobStatus.taggedCount} tagged)` : ''}
|
{jobStatus?.taggedCount ? ` (${jobStatus.taggedCount} tagged)` : ''}
|
||||||
</span>
|
</span>
|
||||||
{jobStatus && jobStatus.totalProjects > 0 && (
|
{jobStatus && jobStatus.totalProjects > 0 && (
|
||||||
<span className="font-medium text-blue-900 dark:text-blue-100">
|
<span className="font-medium text-blue-900">
|
||||||
{taggingProgressPercent}%
|
{taggingProgressPercent}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1767,9 +1767,9 @@ export default function ProjectsPage() {
|
|||||||
{taggingResult && !taggingInProgress && (
|
{taggingResult && !taggingInProgress && (
|
||||||
<div className={`p-4 rounded-lg border ${
|
<div className={`p-4 rounded-lg border ${
|
||||||
taggingResult.failed > 0
|
taggingResult.failed > 0
|
||||||
? 'bg-amber-50 dark:bg-amber-950/20 border-amber-200 dark:border-amber-900'
|
? 'bg-amber-50 border-amber-200'
|
||||||
: taggingResult.processed > 0
|
: taggingResult.processed > 0
|
||||||
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900'
|
? 'bg-green-50 border-green-200'
|
||||||
: 'bg-muted border-border'
|
: 'bg-muted border-border'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
@@ -1804,12 +1804,12 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{taggingResult.errors.length > 0 && (
|
{taggingResult.errors.length > 0 && (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
<p className="text-sm font-medium text-amber-700">
|
||||||
{taggingResult.errors.length} project{taggingResult.errors.length > 1 ? 's' : ''} failed:
|
{taggingResult.errors.length} project{taggingResult.errors.length > 1 ? 's' : ''} failed:
|
||||||
</p>
|
</p>
|
||||||
<div className="max-h-32 overflow-y-auto rounded bg-background/50 p-2 text-xs space-y-1">
|
<div className="max-h-32 overflow-y-auto rounded bg-background/50 p-2 text-xs space-y-1">
|
||||||
{taggingResult.errors.map((error, i) => (
|
{taggingResult.errors.map((error, i) => (
|
||||||
<p key={i} className="text-amber-700 dark:text-amber-300">
|
<p key={i} className="text-amber-700">
|
||||||
• {error}
|
• {error}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -45,8 +46,13 @@ import {
|
|||||||
Trophy,
|
Trophy,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Hash,
|
Hash,
|
||||||
|
Sparkles,
|
||||||
|
Loader2,
|
||||||
|
AlertTriangle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
|
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||||
import {
|
import {
|
||||||
ScoreDistributionChart,
|
ScoreDistributionChart,
|
||||||
EvaluationTimelineChart,
|
EvaluationTimelineChart,
|
||||||
@@ -62,33 +68,41 @@ import {
|
|||||||
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
|
||||||
function ReportsOverview() {
|
function ReportsOverview({ scope }: { scope: string | null }) {
|
||||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
|
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
const { data: dashStats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery()
|
const { data: dashStats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery()
|
||||||
|
|
||||||
// Flatten stages from all programs
|
|
||||||
const rounds = programs?.flatMap(p =>
|
const rounds = programs?.flatMap(p =>
|
||||||
((p.stages ?? []) as Array<{ id: string; name: string; status: string; votingEndAt?: string | Date | null }>).map((s: { id: string; name: string; status: string; votingEndAt?: string | Date | null }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
|
((p.stages ?? []) as Array<{ id: string; name: string; status: string; votingEndAt?: string | Date | null }>).map((s: { id: string; name: string; status: string; votingEndAt?: string | Date | null }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
// Project reporting scope (default: latest program, all rounds)
|
const scopeInput = parseSelection(scope)
|
||||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (programs?.length && !selectedValue) {
|
|
||||||
setSelectedValue(`all:${programs[0].id}`)
|
|
||||||
}
|
|
||||||
}, [programs, selectedValue])
|
|
||||||
|
|
||||||
const scopeInput = parseSelection(selectedValue)
|
|
||||||
const hasScope = !!scopeInput.roundId || !!scopeInput.programId
|
const hasScope = !!scopeInput.roundId || !!scopeInput.programId
|
||||||
|
|
||||||
|
const selectedRound = scope ? rounds.find((r) => r.id === scope) : null
|
||||||
|
const selectedScopeLabel = selectedRound
|
||||||
|
? `${selectedRound.programName} — ${selectedRound.name}`
|
||||||
|
: scope?.startsWith('all:')
|
||||||
|
? `${programs?.find((p) => `all:${p.id}` === scope)?.year ?? ''} Edition — All Rounds`
|
||||||
|
: 'All projects'
|
||||||
|
|
||||||
const { data: projectRankings, isLoading: projectsLoading } =
|
const { data: projectRankings, isLoading: projectsLoading } =
|
||||||
trpc.analytics.getProjectRankings.useQuery(
|
trpc.analytics.getProjectRankings.useQuery(
|
||||||
{ ...scopeInput, limit: 5000 },
|
{ ...scopeInput, limit: 5000 },
|
||||||
{ enabled: hasScope }
|
{ enabled: hasScope }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Applicant nationality breakdown — always runs (scope optional;
|
||||||
|
// empty scope = global view across all programs).
|
||||||
|
const { data: nationalityStats, isLoading: nationalityLoading } =
|
||||||
|
trpc.analytics.getApplicantNationalities.useQuery(scopeInput)
|
||||||
|
|
||||||
|
const nationalityScopeLabel = scopeInput.roundId
|
||||||
|
? 'in this round'
|
||||||
|
: scopeInput.programId
|
||||||
|
? `in ${programs?.find((p) => p.id === scopeInput.programId)?.year ?? ''} Edition`
|
||||||
|
: 'across all programs'
|
||||||
|
|
||||||
if (isLoading || statsLoading) {
|
if (isLoading || statsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -196,6 +210,13 @@ function ReportsOverview() {
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Applicant Nationalities */}
|
||||||
|
<ApplicantNationalitiesCard
|
||||||
|
data={nationalityStats}
|
||||||
|
loading={nationalityLoading}
|
||||||
|
scopeLabel={nationalityScopeLabel}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Score Distribution (if any evaluations exist) */}
|
{/* Score Distribution (if any evaluations exist) */}
|
||||||
{dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && (
|
{dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -239,26 +260,9 @@ function ReportsOverview() {
|
|||||||
Project Reports
|
Project Reports
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Summary dashboard — optionally filter to a specific round
|
{selectedScopeLabel}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
|
||||||
<SelectTrigger className="w-[280px]">
|
|
||||||
<SelectValue placeholder="All projects" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{programs?.map((p) => (
|
|
||||||
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
|
|
||||||
{p.year} Edition — All Rounds
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
{rounds.map((round) => (
|
|
||||||
<SelectItem key={round.id} value={round.id}>
|
|
||||||
{round.programName} - {round.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5">
|
<CardContent className="space-y-5">
|
||||||
@@ -271,6 +275,12 @@ function ReportsOverview() {
|
|||||||
const evaluated = projectRankings.filter(p => p.averageScore !== null)
|
const evaluated = projectRankings.filter(p => p.averageScore !== null)
|
||||||
const scores = evaluated.map(p => p.averageScore as number)
|
const scores = evaluated.map(p => p.averageScore as number)
|
||||||
const avgScore = scores.length ? scores.reduce((a, b) => a + b, 0) / scores.length : 0
|
const avgScore = scores.length ? scores.reduce((a, b) => a + b, 0) / scores.length : 0
|
||||||
|
const balancedScores = projectRankings
|
||||||
|
.map(p => p.balancedScore)
|
||||||
|
.filter((s): s is number => s != null)
|
||||||
|
const avgBalanced = balancedScores.length
|
||||||
|
? balancedScores.reduce((a, b) => a + b, 0) / balancedScores.length
|
||||||
|
: null
|
||||||
const minScore = scores.length ? Math.min(...scores) : 0
|
const minScore = scores.length ? Math.min(...scores) : 0
|
||||||
const maxScore = scores.length ? Math.max(...scores) : 0
|
const maxScore = scores.length ? Math.max(...scores) : 0
|
||||||
const evalPercent = projectRankings.length ? Math.round((evaluated.length / projectRankings.length) * 100) : 0
|
const evalPercent = projectRankings.length ? Math.round((evaluated.length / projectRankings.length) * 100) : 0
|
||||||
@@ -281,14 +291,28 @@ function ReportsOverview() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||||
<div className="rounded-lg border p-3 text-center">
|
<div className="rounded-lg border p-3 text-center">
|
||||||
<p className="text-xs text-muted-foreground">Total Projects</p>
|
<p className="text-xs text-muted-foreground">Total Projects</p>
|
||||||
<p className="text-xl font-bold tabular-nums">{projectRankings.length}</p>
|
<p className="text-xl font-bold tabular-nums">{projectRankings.length}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border p-3 text-center">
|
<div
|
||||||
<p className="text-xs text-muted-foreground">Avg Score</p>
|
className="rounded-lg border p-3 text-center"
|
||||||
<p className="text-xl font-bold tabular-nums">{avgScore ? avgScore.toFixed(1) : '-'}</p>
|
title="Unweighted mean of all submitted juror scores"
|
||||||
|
>
|
||||||
|
<p className="text-xs text-muted-foreground">Raw Avg</p>
|
||||||
|
<p className="text-xl font-bold tabular-nums text-muted-foreground">
|
||||||
|
{avgScore ? avgScore.toFixed(1) : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-3 text-center"
|
||||||
|
title="Juror-balanced average: per-juror z-score normalization rescaled to the 1–10 range"
|
||||||
|
>
|
||||||
|
<p className="text-xs text-muted-foreground">Balanced Avg</p>
|
||||||
|
<p className="text-xl font-bold tabular-nums">
|
||||||
|
{avgBalanced == null ? '-' : avgBalanced.toFixed(1)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border p-3 text-center">
|
<div className="rounded-lg border p-3 text-center">
|
||||||
<p className="text-xs text-muted-foreground">Evaluated</p>
|
<p className="text-xs text-muted-foreground">Evaluated</p>
|
||||||
@@ -319,7 +343,7 @@ function ReportsOverview() {
|
|||||||
{/* Top 10 ranked table */}
|
{/* Top 10 ranked table */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1.5">
|
<p className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1.5">
|
||||||
<Trophy className="h-3.5 w-3.5" /> Top 10 by Average Score
|
<Trophy className="h-3.5 w-3.5" /> Top 10 by Balanced Score
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-lg border">
|
<div className="rounded-lg border">
|
||||||
<Table>
|
<Table>
|
||||||
@@ -328,7 +352,18 @@ function ReportsOverview() {
|
|||||||
<TableHead className="w-10">#</TableHead>
|
<TableHead className="w-10">#</TableHead>
|
||||||
<TableHead>Project</TableHead>
|
<TableHead>Project</TableHead>
|
||||||
<TableHead className="hidden sm:table-cell">Team</TableHead>
|
<TableHead className="hidden sm:table-cell">Team</TableHead>
|
||||||
<TableHead className="text-right">Avg</TableHead>
|
<TableHead
|
||||||
|
className="text-right"
|
||||||
|
title="Raw average of juror scores — uncorrected for per-juror harshness"
|
||||||
|
>
|
||||||
|
Raw Avg
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="text-right"
|
||||||
|
title="Juror-balanced average: each juror's contribution is z-score normalized against their own grading distribution, then rescaled to the 1–10 range. Harsh and lenient jurors contribute on equal footing."
|
||||||
|
>
|
||||||
|
Balanced
|
||||||
|
</TableHead>
|
||||||
<TableHead className="text-right">Evals</TableHead>
|
<TableHead className="text-right">Evals</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -345,9 +380,12 @@ function ReportsOverview() {
|
|||||||
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
||||||
{p.teamName || '-'}
|
{p.teamName || '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums">
|
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||||
{p.averageScore === null ? '-' : p.averageScore.toFixed(2)}
|
{p.averageScore === null ? '-' : p.averageScore.toFixed(2)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums font-semibold">
|
||||||
|
{p.balancedScore == null ? '-' : p.balancedScore.toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums">{p.evaluationCount}</TableCell>
|
<TableCell className="text-right tabular-nums">{p.evaluationCount}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">{formatStatusLabel(p.status)}</Badge>
|
<Badge variant="outline">{formatStatusLabel(p.status)}</Badge>
|
||||||
@@ -481,6 +519,140 @@ function ReportsOverview() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NationalityStats = {
|
||||||
|
total: number
|
||||||
|
declared: number
|
||||||
|
notDeclared: number
|
||||||
|
byCountry: Array<{ country: string; count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApplicantNationalitiesCard({
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
scopeLabel,
|
||||||
|
}: {
|
||||||
|
data: NationalityStats | undefined
|
||||||
|
loading: boolean
|
||||||
|
scopeLabel: string
|
||||||
|
}) {
|
||||||
|
const [showAll, setShowAll] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||||
|
<Globe className="h-4 w-4 text-violet-600" />
|
||||||
|
</div>
|
||||||
|
Applicant Nationalities
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Self-declared nationality of team members on projects {scopeLabel}.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
) : !data || data.total === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<Globe className="h-10 w-10 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
No applicants in this scope.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : data.declared === 0 ? (
|
||||||
|
<>
|
||||||
|
<NationalitySummary declared={data.declared} notDeclared={data.notDeclared} />
|
||||||
|
<div className="mt-4 flex flex-col items-center justify-center rounded-lg border border-dashed py-8 text-center">
|
||||||
|
<Globe className="h-8 w-8 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
No nationality data yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<NationalitySummary declared={data.declared} notDeclared={data.notDeclared} />
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Country</TableHead>
|
||||||
|
<TableHead className="text-right w-32">Applicants</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(showAll ? data.byCountry : data.byCountry.slice(0, 10)).map((row) => {
|
||||||
|
const name = getCountryName(row.country)
|
||||||
|
const flag = getCountryFlag(row.country)
|
||||||
|
return (
|
||||||
|
<TableRow key={row.country}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
{flag && <span aria-hidden>{flag}</span>}
|
||||||
|
<span>{name}</span>
|
||||||
|
{name !== row.country && (
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{row.country}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Badge variant="secondary" className="tabular-nums">
|
||||||
|
{row.count}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.byCountry.length > 10 && (
|
||||||
|
<div className="mt-3 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAll((v) => !v)}
|
||||||
|
className="gap-1 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{showAll
|
||||||
|
? 'Show top 10'
|
||||||
|
: `Show all (${data.byCountry.length} countries)`}
|
||||||
|
<ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NationalitySummary({ declared, notDeclared }: { declared: number; notDeclared: number }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg border p-3 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">Declared</p>
|
||||||
|
<p className="text-2xl font-bold tabular-nums">{declared}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-3 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">Not declared</p>
|
||||||
|
<p className="text-2xl font-bold tabular-nums text-muted-foreground">
|
||||||
|
{notDeclared}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Parse selection value: "all:programId" for edition-wide, or roundId
|
// Parse selection value: "all:programId" for edition-wide, or roundId
|
||||||
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
||||||
if (!value) return {}
|
if (!value) return {}
|
||||||
@@ -512,9 +684,7 @@ function findDefaultRound(rounds: Array<{ id: string; status?: string }>): strin
|
|||||||
return rounds[0]?.id
|
return rounds[0]?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
function StageAnalytics() {
|
function StageAnalytics({ scope }: { scope: string | null }) {
|
||||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
|
|
||||||
// Flatten stages from all programs with program name
|
// Flatten stages from all programs with program name
|
||||||
@@ -522,14 +692,7 @@ function StageAnalytics() {
|
|||||||
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
|
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
// Set default selected stage — prefer active round
|
const queryInput = parseSelection(scope)
|
||||||
useEffect(() => {
|
|
||||||
if (rounds.length && !selectedValue) {
|
|
||||||
setSelectedValue(findDefaultRound(rounds) ?? rounds[0].id)
|
|
||||||
}
|
|
||||||
}, [rounds.length, selectedValue])
|
|
||||||
|
|
||||||
const queryInput = parseSelection(selectedValue)
|
|
||||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||||
|
|
||||||
const { data: scoreDistribution, isLoading: scoreLoading } =
|
const { data: scoreDistribution, isLoading: scoreLoading } =
|
||||||
@@ -568,7 +731,7 @@ function StageAnalytics() {
|
|||||||
{ enabled: hasSelection }
|
{ enabled: hasSelection }
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedRound = rounds.find((r) => r.id === selectedValue)
|
const selectedRound = rounds.find((r) => r.id === scope)
|
||||||
const geoInput = queryInput.programId
|
const geoInput = queryInput.programId
|
||||||
? { programId: queryInput.programId }
|
? { programId: queryInput.programId }
|
||||||
: { programId: selectedRound?.programId || '', roundId: queryInput.roundId }
|
: { programId: selectedRound?.programId || '', roundId: queryInput.roundId }
|
||||||
@@ -606,28 +769,6 @@ function StageAnalytics() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Round Selector */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<label className="text-sm font-medium">Select Round:</label>
|
|
||||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
|
||||||
<SelectTrigger className="w-[300px]">
|
|
||||||
<SelectValue placeholder="Select a round" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{programs?.map((p) => (
|
|
||||||
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
|
|
||||||
{p.year} Edition — All Rounds
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
{rounds.map((round) => (
|
|
||||||
<SelectItem key={round.id} value={round.id}>
|
|
||||||
{round.programName} - {round.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasSelection && (
|
{hasSelection && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Row 1: Score Distribution & Status Breakdown */}
|
{/* Row 1: Score Distribution & Status Breakdown */}
|
||||||
@@ -800,22 +941,10 @@ function CrossStageTab() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function JurorConsistencyTab() {
|
function JurorConsistencyTab({ scope }: { scope: string | null }) {
|
||||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
const { isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
|
|
||||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
const queryInput = parseSelection(scope)
|
||||||
|
|
||||||
const stages = programs?.flatMap((p) =>
|
|
||||||
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programId: p.id, programName: `${p.year} Edition` }))
|
|
||||||
) || []
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (stages.length && !selectedValue) {
|
|
||||||
setSelectedValue(findDefaultRound(stages) ?? stages[0].id)
|
|
||||||
}
|
|
||||||
}, [stages.length, selectedValue])
|
|
||||||
|
|
||||||
const queryInput = parseSelection(selectedValue)
|
|
||||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||||
|
|
||||||
const { data: consistency, isLoading: consistencyLoading } =
|
const { data: consistency, isLoading: consistencyLoading } =
|
||||||
@@ -830,27 +959,6 @@ function JurorConsistencyTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<label className="text-sm font-medium">Select Stage:</label>
|
|
||||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
|
||||||
<SelectTrigger className="w-[300px]">
|
|
||||||
<SelectValue placeholder="Select a stage" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{programs?.map((p) => (
|
|
||||||
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
|
|
||||||
{p.year} Edition — All Stages
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
{stages.map((stage) => (
|
|
||||||
<SelectItem key={stage.id} value={stage.id}>
|
|
||||||
{stage.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{consistencyLoading && <Skeleton className="h-[400px]" />}
|
{consistencyLoading && <Skeleton className="h-[400px]" />}
|
||||||
|
|
||||||
{consistency && (
|
{consistency && (
|
||||||
@@ -870,26 +978,154 @@ function JurorConsistencyTab() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{queryInput.roundId && (
|
||||||
|
<JurorCalibrationPanel roundId={queryInput.roundId} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiversityTab() {
|
function JurorCalibrationPanel({ roundId }: { roundId: string }) {
|
||||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
const mutation = trpc.analytics.generateJurorCalibration.useMutation({
|
||||||
|
onError: (err) => toast.error(`Calibration analysis failed: ${err.message}`),
|
||||||
|
})
|
||||||
|
const result = mutation.data
|
||||||
|
|
||||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
const severityStyle: Record<string, string> = {
|
||||||
|
outlier: 'bg-red-50 text-red-700 border-red-200',
|
||||||
const stages = programs?.flatMap((p) =>
|
notable: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||||
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programId: p.id, programName: `${p.year} Edition` }))
|
normal: 'bg-muted text-muted-foreground',
|
||||||
) || []
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (stages.length && !selectedValue) {
|
|
||||||
setSelectedValue(findDefaultRound(stages) ?? stages[0].id)
|
|
||||||
}
|
}
|
||||||
}, [stages.length, selectedValue])
|
|
||||||
|
|
||||||
const queryInput = parseSelection(selectedValue)
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-[#de0f1e]" />
|
||||||
|
AI Juror Calibration Advisory
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Plain-language explanation of the per-juror score balancing already applied to rankings.
|
||||||
|
Describes, does not prescribe — the math runs regardless.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => mutation.mutate({ roundId })}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||||
|
{mutation.isPending ? 'Analyzing…' : result ? 'Regenerate' : 'Analyze jurors'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
{!result && !mutation.isPending && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Run the analysis to see per-juror grading patterns, cohort stats, and the calibration
|
||||||
|
narrative for the selected round.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<div className="rounded-lg border p-3 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">Cohort Mean</p>
|
||||||
|
<p className="text-xl font-bold tabular-nums">{result.cohortMean.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-3 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">Cohort Stddev</p>
|
||||||
|
<p className="text-xl font-bold tabular-nums">{result.cohortStddev.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-3 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">Evaluations</p>
|
||||||
|
<p className="text-xl font-bold tabular-nums">{result.totalEvaluations}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-3 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">Jurors</p>
|
||||||
|
<p className="text-xl font-bold tabular-nums">{result.totalJurors}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-4">
|
||||||
|
<p className="text-sm leading-relaxed">{result.overallSummary}</p>
|
||||||
|
{result.keyTakeaways.length > 0 && (
|
||||||
|
<ul className="mt-3 space-y-1.5 text-sm">
|
||||||
|
{result.keyTakeaways.map((t, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2">
|
||||||
|
<ArrowRight className="mt-1 h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||||
|
<span>{t}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Juror</TableHead>
|
||||||
|
<TableHead className="text-right">Evals</TableHead>
|
||||||
|
<TableHead className="text-right">Mean</TableHead>
|
||||||
|
<TableHead className="text-right">Δ Cohort</TableHead>
|
||||||
|
<TableHead className="text-right" title="Juror's stddev / cohort stddev">
|
||||||
|
Influence
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Severity</TableHead>
|
||||||
|
<TableHead>Notes</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{result.jurors.map((j) => (
|
||||||
|
<TableRow key={j.userId}>
|
||||||
|
<TableCell className="font-medium">{j.name}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{j.evaluationCount}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{j.rawMean.toFixed(2)}</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className={`text-right tabular-nums ${
|
||||||
|
j.deltaFromCohort < -0.5 ? 'text-red-600' : j.deltaFromCohort > 0.5 ? 'text-emerald-600' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{j.deltaFromCohort > 0 ? '+' : ''}
|
||||||
|
{j.deltaFromCohort.toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{j.effectiveInfluence == null ? '-' : j.effectiveInfluence.toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className={severityStyle[j.severity]}>
|
||||||
|
{j.severity === 'outlier' && <AlertTriangle className="mr-1 h-3 w-3" />}
|
||||||
|
{j.severity}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-md text-sm text-muted-foreground">
|
||||||
|
{j.summary}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Generated {result.generatedAt.toLocaleString()} · {result.tokensUsed} tokens · model {result.model}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiversityTab({ scope }: { scope: string | null }) {
|
||||||
|
const { isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
|
|
||||||
|
const queryInput = parseSelection(scope)
|
||||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||||
|
|
||||||
const { data: diversity, isLoading: diversityLoading } =
|
const { data: diversity, isLoading: diversityLoading } =
|
||||||
@@ -904,27 +1140,6 @@ function DiversityTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<label className="text-sm font-medium">Select Stage:</label>
|
|
||||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
|
||||||
<SelectTrigger className="w-[300px]">
|
|
||||||
<SelectValue placeholder="Select a stage" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{programs?.map((p) => (
|
|
||||||
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
|
|
||||||
{p.year} Edition — All Stages
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
{stages.map((stage) => (
|
|
||||||
<SelectItem key={stage.id} value={stage.id}>
|
|
||||||
{stage.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{diversityLoading && <Skeleton className="h-[400px]" />}
|
{diversityLoading && <Skeleton className="h-[400px]" />}
|
||||||
|
|
||||||
{diversity && (
|
{diversity && (
|
||||||
@@ -942,10 +1157,10 @@ function DiversityTab() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoundPipelineTab() {
|
function RoundPipelineTab({ scope }: { scope: string | null }) {
|
||||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
|
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
|
|
||||||
const rounds = programs?.flatMap(p =>
|
const allRounds = programs?.flatMap(p =>
|
||||||
((p.stages ?? []) as Array<{ id: string; name: string; status: string; type?: string }>).map((s) => ({
|
((p.stages ?? []) as Array<{ id: string; name: string; status: string; type?: string }>).map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
programId: p.id,
|
programId: p.id,
|
||||||
@@ -953,6 +1168,16 @@ function RoundPipelineTab() {
|
|||||||
}))
|
}))
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
|
// Pipeline is inherently multi-round. Narrow to the selected program if one
|
||||||
|
// is picked (either via "all:programId" or a specific round whose program we
|
||||||
|
// can resolve). Otherwise show every round across every program.
|
||||||
|
const scopeProgramId = scope?.startsWith('all:')
|
||||||
|
? scope.slice(4)
|
||||||
|
: allRounds.find((r) => r.id === scope)?.programId
|
||||||
|
const rounds = scopeProgramId
|
||||||
|
? allRounds.filter((r) => r.programId === scopeProgramId)
|
||||||
|
: allRounds
|
||||||
|
|
||||||
const roundIds = rounds.map(r => r.id)
|
const roundIds = rounds.map(r => r.id)
|
||||||
|
|
||||||
const { data: comparison, isLoading: comparisonLoading } =
|
const { data: comparison, isLoading: comparisonLoading } =
|
||||||
@@ -1034,20 +1259,48 @@ function RoundPipelineTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const urlRound = searchParams.get('round')
|
||||||
|
|
||||||
|
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
|
|
||||||
|
const stages = useMemo(
|
||||||
|
() => programs?.flatMap((p) =>
|
||||||
|
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map(
|
||||||
|
(s: { id: string; name: string; status: string }) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
status: s.status,
|
||||||
|
programId: p.id,
|
||||||
|
programName: `${p.year} Edition`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
) || [],
|
||||||
|
[programs],
|
||||||
|
)
|
||||||
|
|
||||||
const [pdfStageId, setPdfStageId] = useState<string | null>(null)
|
const [pdfStageId, setPdfStageId] = useState<string | null>(null)
|
||||||
|
|
||||||
const { data: pdfPrograms } = trpc.program.list.useQuery({ includeStages: true })
|
|
||||||
const pdfStages = pdfPrograms?.flatMap((p) =>
|
|
||||||
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programName: `${p.year} Edition` }))
|
|
||||||
) || []
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pdfStages.length && !pdfStageId) {
|
if (stages.length && !pdfStageId) {
|
||||||
setPdfStageId(findDefaultRound(pdfStages) ?? pdfStages[0].id)
|
setPdfStageId(findDefaultRound(stages) ?? stages[0].id)
|
||||||
}
|
}
|
||||||
}, [pdfStages.length, pdfStageId])
|
}, [stages.length, pdfStageId, stages])
|
||||||
|
|
||||||
const selectedPdfStage = pdfStages.find((r) => r.id === pdfStageId)
|
// Top-level selection drives every single-round tab. Persisted to the URL
|
||||||
|
// so reloads and shared links preserve the view. Defaults to the newest
|
||||||
|
// program's "All Rounds" entry.
|
||||||
|
const defaultScope = programs?.length ? `all:${programs[0].id}` : null
|
||||||
|
const scope = urlRound ?? defaultScope
|
||||||
|
|
||||||
|
const setScope = (value: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
params.set('round', value)
|
||||||
|
router.replace(`${pathname}?${params.toString()}`, { scroll: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedPdfStage = stages.find((r) => r.id === pdfStageId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -1059,6 +1312,35 @@ export default function ReportsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Top-level round selector — drives every tab below */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="text-sm font-medium">Viewing:</label>
|
||||||
|
<Select value={scope ?? ''} onValueChange={setScope}>
|
||||||
|
<SelectTrigger className="w-[320px]">
|
||||||
|
<SelectValue placeholder="Select a round or edition" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{programs?.map((p) => (
|
||||||
|
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
|
||||||
|
{p.year} Edition — All Rounds
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{stages.map((stage) => (
|
||||||
|
<SelectItem key={stage.id} value={stage.id}>
|
||||||
|
{stage.programName} — {stage.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This selection applies to every tab except Cross-Round (which compares multiple rounds).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs defaultValue="overview" className="space-y-6">
|
<Tabs defaultValue="overview" className="space-y-6">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
@@ -1094,7 +1376,7 @@ export default function ReportsPage() {
|
|||||||
<SelectValue placeholder="Select stage for PDF" />
|
<SelectValue placeholder="Select stage for PDF" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{pdfStages.map((stage) => (
|
{stages.map((stage) => (
|
||||||
<SelectItem key={stage.id} value={stage.id}>
|
<SelectItem key={stage.id} value={stage.id}>
|
||||||
{stage.name}
|
{stage.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -1112,11 +1394,11 @@ export default function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="overview">
|
<TabsContent value="overview">
|
||||||
<ReportsOverview />
|
<ReportsOverview scope={scope} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="analytics">
|
<TabsContent value="analytics">
|
||||||
<StageAnalytics />
|
<StageAnalytics scope={scope} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="cross-stage">
|
<TabsContent value="cross-stage">
|
||||||
@@ -1124,15 +1406,15 @@ export default function ReportsPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="consistency">
|
<TabsContent value="consistency">
|
||||||
<JurorConsistencyTab />
|
<JurorConsistencyTab scope={scope} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="diversity">
|
<TabsContent value="diversity">
|
||||||
<DiversityTab />
|
<DiversityTab scope={scope} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="pipeline">
|
<TabsContent value="pipeline">
|
||||||
<RoundPipelineTab />
|
<RoundPipelineTab scope={scope} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||||
{isReadOnly ? 'Submitted Evaluation' : 'Fill In Evaluation'}
|
{isReadOnly ? 'Submitted Evaluation' : 'Fill In Evaluation'}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
@@ -404,8 +404,8 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={
|
className={
|
||||||
project.competitionCategory === 'STARTUP'
|
project.competitionCategory === 'STARTUP'
|
||||||
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
? 'bg-violet-100 text-violet-700 border-violet-200'
|
||||||
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
|
: 'bg-sky-100 text-sky-700 border-sky-200'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||||
@@ -415,7 +415,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-l-4 border-l-amber-500 bg-amber-50/40 dark:bg-amber-950/10">
|
<Card className="border-l-4 border-l-amber-500 bg-amber-50/40">
|
||||||
<CardContent className="flex items-start gap-3 p-4">
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
<UserCheck className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
<UserCheck className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
||||||
<div className="flex-1 text-sm">
|
<div className="flex-1 text-sm">
|
||||||
@@ -431,7 +431,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{isReadOnly && (
|
{isReadOnly && (
|
||||||
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50 dark:bg-blue-950/20">
|
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50">
|
||||||
<CardContent className="flex items-start gap-3 p-4">
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
|
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -446,7 +446,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasCOI && !isReadOnly && (
|
{hasCOI && !isReadOnly && (
|
||||||
<Card className="border-l-4 border-l-red-500 bg-red-50/40 dark:bg-red-950/10">
|
<Card className="border-l-4 border-l-red-500 bg-red-50/40">
|
||||||
<CardContent className="flex items-start gap-3 p-4">
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
<Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
|
<Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
|
||||||
<div className="flex-1 text-sm">
|
<div className="flex-1 text-sm">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
FileEdit,
|
Pencil,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -55,7 +55,7 @@ export default function AdminJurorProxyEvaluatePage({ params: paramsPromise }: P
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||||
Proxy Evaluations
|
Proxy Evaluations
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
@@ -184,7 +184,7 @@ function AssignmentRow({ roundId, userId, assignment, mode }: AssignmentRowProps
|
|||||||
evaluation?.status === 'SUBMITTED' || evaluation?.status === 'LOCKED'
|
evaluation?.status === 'SUBMITTED' || evaluation?.status === 'LOCKED'
|
||||||
? CheckCircle2
|
? CheckCircle2
|
||||||
: evaluation?.status === 'DRAFT'
|
: evaluation?.status === 'DRAFT'
|
||||||
? FileEdit
|
? Pencil
|
||||||
: Clock
|
: Clock
|
||||||
|
|
||||||
const href = `/admin/rounds/${roundId}/jurors/${userId}/evaluate/${project.id}` as Route
|
const href = `/admin/rounds/${roundId}/jurors/${userId}/evaluate/${project.id}` as Route
|
||||||
@@ -205,8 +205,8 @@ function AssignmentRow({ roundId, userId, assignment, mode }: AssignmentRowProps
|
|||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0',
|
'shrink-0',
|
||||||
project.competitionCategory === 'STARTUP'
|
project.competitionCategory === 'STARTUP'
|
||||||
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
? 'bg-violet-100 text-violet-700 border-violet-200'
|
||||||
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300',
|
: 'bg-sky-100 text-sky-700 border-sky-200',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ import {
|
|||||||
ListChecks,
|
ListChecks,
|
||||||
FileText,
|
FileText,
|
||||||
Languages,
|
Languages,
|
||||||
|
MonitorPlay,
|
||||||
|
Scale,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -91,6 +93,16 @@ import { ProjectStatesTable } from '@/components/admin/round/project-states-tabl
|
|||||||
// SubmissionWindowManager removed — round dates + file requirements in Config are sufficient
|
// SubmissionWindowManager removed — round dates + file requirements in Config are sufficient
|
||||||
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
|
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
|
||||||
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
||||||
|
import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview'
|
||||||
|
import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table'
|
||||||
|
import { 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 { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
|
||||||
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
||||||
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
||||||
@@ -121,6 +133,7 @@ import { ConfigSectionHeader } from '@/components/admin/rounds/config/config-sec
|
|||||||
import { NotifyAdvancedButton } from '@/components/admin/round/notify-advanced-button'
|
import { NotifyAdvancedButton } from '@/components/admin/round/notify-advanced-button'
|
||||||
import { NotifyRejectedButton } from '@/components/admin/round/notify-rejected-button'
|
import { NotifyRejectedButton } from '@/components/admin/round/notify-rejected-button'
|
||||||
import { BulkInviteButton } from '@/components/admin/round/bulk-invite-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 { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card'
|
||||||
import { FinalizationTab } from '@/components/admin/round/finalization-tab'
|
import { FinalizationTab } from '@/components/admin/round/finalization-tab'
|
||||||
|
|
||||||
@@ -145,6 +158,95 @@ const stateColors: Record<string, string> = Object.fromEntries(
|
|||||||
Object.entries(projectStateConfig).map(([k, v]) => [k, v.bg])
|
Object.entries(projectStateConfig).map(([k, v]) => [k, v.bg])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Mentoring round: Auto-fill remaining toolbar (Projects tab)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function MentoringBulkAssignToolbar({
|
||||||
|
roundId,
|
||||||
|
configJson,
|
||||||
|
}: {
|
||||||
|
roundId: string
|
||||||
|
configJson: Record<string, unknown>
|
||||||
|
}) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const eligibility = (configJson.eligibility as string) ?? 'requested_only'
|
||||||
|
const isAdminSelected = eligibility === 'admin_selected'
|
||||||
|
|
||||||
|
const { data: pending } = trpc.round.getProjectsNeedingMentor.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ enabled: !isAdminSelected, refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
const count = pending?.count ?? 0
|
||||||
|
const eligibleTotal = pending?.eligibleTotal ?? 0
|
||||||
|
const mentorPoolSize = pending?.mentorPoolSize ?? 0
|
||||||
|
const hasNoMentors = mentorPoolSize === 0
|
||||||
|
const hasNoEligible = eligibleTotal === 0
|
||||||
|
|
||||||
|
const bulk = trpc.mentor.autoAssignBulkForRound.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast.success(result.message)
|
||||||
|
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
|
||||||
|
utils.project.list.invalidate()
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const eligibilityLabel = eligibility.replace('_', ' ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between rounded-md border bg-muted/30 px-4 py-2.5">
|
||||||
|
<div className="text-sm">
|
||||||
|
{isAdminSelected ? (
|
||||||
|
<>
|
||||||
|
<span className="font-medium">Eligibility: admin-selected</span>
|
||||||
|
<span className="text-muted-foreground ml-2">
|
||||||
|
— auto-fill is disabled. Assign each project manually.
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : hasNoMentors ? (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
No mentors in the pool yet —{' '}
|
||||||
|
<Link
|
||||||
|
href="/admin/members?tab=mentors"
|
||||||
|
className="text-foreground underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
add mentors
|
||||||
|
</Link>{' '}
|
||||||
|
before auto-filling.
|
||||||
|
</span>
|
||||||
|
) : hasNoEligible ? (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
No projects are eligible for mentorship in this round (
|
||||||
|
{eligibilityLabel}).
|
||||||
|
</span>
|
||||||
|
) : count > 0 ? (
|
||||||
|
<>
|
||||||
|
<span className="font-medium">{count}</span>{' '}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
of {eligibleTotal} project{eligibleTotal === 1 ? '' : 's'} still
|
||||||
|
needs a mentor ({eligibilityLabel})
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
All {eligibleTotal} eligible project{eligibleTotal === 1 ? '' : 's'}{' '}
|
||||||
|
already have a mentor.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => bulk.mutate({ roundId })}
|
||||||
|
disabled={isAdminSelected || count === 0 || hasNoMentors || bulk.isPending}
|
||||||
|
>
|
||||||
|
{bulk.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
Auto-fill remaining
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// Main Page Component
|
// Main Page Component
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -514,6 +616,16 @@ export default function RoundDetailPage() {
|
|||||||
|
|
||||||
const isFiltering = round?.roundType === 'FILTERING'
|
const isFiltering = round?.roundType === 'FILTERING'
|
||||||
const isEvaluation = round?.roundType === 'EVALUATION'
|
const isEvaluation = round?.roundType === 'EVALUATION'
|
||||||
|
const isMentoring = round?.roundType === 'MENTORING'
|
||||||
|
const isGrandFinale = round?.roundType === 'LIVE_FINAL'
|
||||||
|
|
||||||
|
// Mentor pool size — used by Round Details panel below to replace the
|
||||||
|
// always-empty "Jury Group" row on MENTORING rounds.
|
||||||
|
const { data: mentorPool } = trpc.mentor.getMentorPool.useQuery(
|
||||||
|
{},
|
||||||
|
{ enabled: isMentoring },
|
||||||
|
)
|
||||||
|
const mentorPoolSize = mentorPool?.poolSize ?? 0
|
||||||
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
|
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
|
||||||
const hasAwards = roundAwards.length > 0
|
const hasAwards = roundAwards.length > 0
|
||||||
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
|
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
|
||||||
@@ -589,7 +701,8 @@ export default function RoundDetailPage() {
|
|||||||
action: undefined as Route | undefined,
|
action: undefined as Route | undefined,
|
||||||
actionLabel: undefined as string | undefined,
|
actionLabel: undefined as string | undefined,
|
||||||
},
|
},
|
||||||
...((isEvaluation && !(config.requireDocumentUpload as boolean))
|
...((isEvaluation && !(config.requireDocumentUpload as boolean)) ||
|
||||||
|
(isMentoring && !(config.filePromotionEnabled as boolean) && !config.promotionTargetWindowId)
|
||||||
? []
|
? []
|
||||||
: [{
|
: [{
|
||||||
label: 'File requirements set',
|
label: 'File requirements set',
|
||||||
@@ -864,6 +977,10 @@ export default function RoundDetailPage() {
|
|||||||
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
|
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
|
||||||
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []),
|
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []),
|
||||||
...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []),
|
...(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 }] : []),
|
...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
|
||||||
...(showFinalization ? [{ value: 'finalization', label: 'Finalization', icon: ListChecks }] : []),
|
...(showFinalization ? [{ value: 'finalization', label: 'Finalization', icon: ListChecks }] : []),
|
||||||
{ value: 'config', label: 'Config', icon: Settings },
|
{ value: 'config', label: 'Config', icon: Settings },
|
||||||
@@ -1161,6 +1278,20 @@ export default function RoundDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Project Management</p>
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Project Management</p>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{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}>
|
<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">
|
<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" />
|
<Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
|
||||||
@@ -1172,6 +1303,7 @@ export default function RoundDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('projects')}
|
onClick={() => setActiveTab('projects')}
|
||||||
@@ -1323,6 +1455,7 @@ export default function RoundDetailPage() {
|
|||||||
<NotifyAdvancedButton roundId={roundId} />
|
<NotifyAdvancedButton roundId={roundId} />
|
||||||
<NotifyRejectedButton roundId={roundId} />
|
<NotifyRejectedButton roundId={roundId} />
|
||||||
<BulkInviteButton roundId={roundId} />
|
<BulkInviteButton roundId={roundId} />
|
||||||
|
{isMentoring && <SendMentorshipWelcomeButton roundId={roundId} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1400,6 +1533,33 @@ export default function RoundDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mentoring-specific stats \u2014 only on MENTORING rounds */}
|
||||||
|
{isMentoring && <MentoringRoundOverview roundId={roundId} />}
|
||||||
|
|
||||||
|
{/* Grand-finale logistics \u2014 only on LIVE_FINAL rounds */}
|
||||||
|
{isGrandFinale && programId && (
|
||||||
|
<>
|
||||||
|
<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 */}
|
{/* Round Info + Project Breakdown */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<AnimatedCard index={2}>
|
<AnimatedCard index={2}>
|
||||||
@@ -1413,7 +1573,9 @@ export default function RoundDetailPage() {
|
|||||||
{ label: 'Status', value: <span className="font-medium">{statusCfg.label}</span> },
|
{ label: 'Status', value: <span className="font-medium">{statusCfg.label}</span> },
|
||||||
{ label: 'Position', value: <span className="font-medium">{`Round ${(round.sortOrder ?? 0) + 1}${competition?.rounds ? ` of ${competition.rounds.length}` : ''}`}</span> },
|
{ label: 'Position', value: <span className="font-medium">{`Round ${(round.sortOrder ?? 0) + 1}${competition?.rounds ? ` of ${competition.rounds.length}` : ''}`}</span> },
|
||||||
...(round.purposeKey ? [{ label: 'Purpose', value: <span className="font-medium">{round.purposeKey}</span> }] : []),
|
...(round.purposeKey ? [{ label: 'Purpose', value: <span className="font-medium">{round.purposeKey}</span> }] : []),
|
||||||
{ label: 'Jury Group', value: <span className="font-medium">{juryGroup ? juryGroup.name : '\u2014'}</span> },
|
isMentoring
|
||||||
|
? { label: 'Mentor Pool', value: <Link href="/admin/mentors" className="font-medium hover:underline">{mentorPoolSize} member{mentorPoolSize === 1 ? '' : 's'}</Link> }
|
||||||
|
: { label: 'Jury Group', value: <span className="font-medium">{juryGroup ? juryGroup.name : '\u2014'}</span> },
|
||||||
{ label: 'Opens', value: <span className="font-medium">{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}</span> },
|
{ label: 'Opens', value: <span className="font-medium">{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}</span> },
|
||||||
{ label: 'Closes', value: <span className="font-medium">{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}</span> },
|
{ label: 'Closes', value: <span className="font-medium">{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}</span> },
|
||||||
].map((row, i) => (
|
].map((row, i) => (
|
||||||
@@ -1475,6 +1637,18 @@ export default function RoundDetailPage() {
|
|||||||
|
|
||||||
{/* ═══════════ PROJECTS TAB ═══════════ */}
|
{/* ═══════════ PROJECTS TAB ═══════════ */}
|
||||||
<TabsContent value="projects" className="space-y-4">
|
<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
|
<ProjectStatesTable
|
||||||
competitionId={competitionId}
|
competitionId={competitionId}
|
||||||
roundId={roundId}
|
roundId={roundId}
|
||||||
@@ -1486,6 +1660,7 @@ export default function RoundDetailPage() {
|
|||||||
setTimeout(() => setPreviewSheetOpen(true), 100)
|
setTimeout(() => setPreviewSheetOpen(true), 100)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ═══════════ FILTERING TAB ═══════════ */}
|
{/* ═══════════ FILTERING TAB ═══════════ */}
|
||||||
@@ -1495,6 +1670,20 @@ export default function RoundDetailPage() {
|
|||||||
</TabsContent>
|
</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) ═══════════ */}
|
{/* ═══════════ JURY TAB (non-EVALUATION jury rounds: LIVE_FINAL, DELIBERATION) ═══════════ */}
|
||||||
{hasJury && !isEvaluation && (
|
{hasJury && !isEvaluation && (
|
||||||
<TabsContent value="jury" className="space-y-6">
|
<TabsContent value="jury" className="space-y-6">
|
||||||
@@ -1977,39 +2166,39 @@ export default function RoundDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{aiAssignmentMutation.isPending && (
|
{aiAssignmentMutation.isPending && (
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-violet-50 border border-violet-200 dark:bg-violet-950/20 dark:border-violet-800">
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-violet-50 border border-violet-200">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="h-8 w-8 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
|
<div className="h-8 w-8 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-violet-800 dark:text-violet-200">AI is analyzing projects and jurors...</p>
|
<p className="text-sm font-medium text-violet-800">AI is analyzing projects and jurors...</p>
|
||||||
<p className="text-xs text-violet-600 dark:text-violet-400">
|
<p className="text-xs text-violet-600">
|
||||||
Matching expertise, reviewing bios, and balancing workloads
|
Matching expertise, reviewing bios, and balancing workloads
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{aiAssignmentMutation.error && !aiAssignmentMutation.isPending && (
|
{aiAssignmentMutation.error && !aiAssignmentMutation.isPending && (
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-red-50 border border-red-200 dark:bg-red-950/20 dark:border-red-800">
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-red-50 border border-red-200">
|
||||||
<AlertTriangle className="h-5 w-5 text-red-600 shrink-0" />
|
<AlertTriangle className="h-5 w-5 text-red-600 shrink-0" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-red-800 dark:text-red-200">
|
<p className="text-sm font-medium text-red-800">
|
||||||
AI generation failed
|
AI generation failed
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-red-600 dark:text-red-400">
|
<p className="text-xs text-red-600">
|
||||||
{aiAssignmentMutation.error.message}
|
{aiAssignmentMutation.error.message}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
|
{aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 border border-emerald-200 dark:bg-emerald-950/20 dark:border-emerald-800">
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 border border-emerald-200">
|
||||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
|
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-200">
|
<p className="text-sm font-medium text-emerald-800">
|
||||||
{aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated
|
{aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-emerald-600 dark:text-emerald-400">
|
<p className="text-xs text-emerald-600">
|
||||||
{aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects
|
{aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects
|
||||||
{aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'}
|
{aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'}
|
||||||
</p>
|
</p>
|
||||||
@@ -2198,7 +2387,8 @@ export default function RoundDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* General Round Settings */}
|
{/* General Round Settings — hidden on MENTORING rounds (no advancement targets apply) */}
|
||||||
|
{!isMentoring && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="border-b">
|
<CardHeader className="border-b">
|
||||||
<ConfigSectionHeader
|
<ConfigSectionHeader
|
||||||
@@ -2321,6 +2511,7 @@ export default function RoundDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Round-type-specific config */}
|
{/* Round-type-specific config */}
|
||||||
<RoundConfigForm
|
<RoundConfigForm
|
||||||
@@ -2489,9 +2680,9 @@ export default function RoundDetailPage() {
|
|||||||
|
|
||||||
{/* Autosave error bar — only shows when save fails */}
|
{/* Autosave error bar — only shows when save fails */}
|
||||||
{autosaveStatus === 'error' && (
|
{autosaveStatus === 'error' && (
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-50 border-t bg-red-50 dark:bg-red-950/50 shadow-[0_-4px_12px_rgba(0,0,0,0.1)]">
|
<div className="fixed bottom-0 left-0 right-0 z-50 border-t bg-red-50 shadow-[0_-4px_12px_rgba(0,0,0,0.1)]">
|
||||||
<div className="container flex items-center justify-between py-3 px-4 max-w-5xl mx-auto">
|
<div className="container flex items-center justify-between py-3 px-4 max-w-5xl mx-auto">
|
||||||
<div className="flex items-center gap-2 text-sm text-red-700 dark:text-red-300">
|
<div className="flex items-center gap-2 text-sm text-red-700">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<span>Auto-save failed</span>
|
<span>Auto-save failed</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma'
|
|||||||
import { requireRole } from '@/lib/auth-redirect'
|
import { requireRole } from '@/lib/auth-redirect'
|
||||||
import { AdminSidebar } from '@/components/layouts/admin-sidebar'
|
import { AdminSidebar } from '@/components/layouts/admin-sidebar'
|
||||||
import { AdminEditionWrapper } from '@/components/layouts/admin-edition-wrapper'
|
import { AdminEditionWrapper } from '@/components/layouts/admin-edition-wrapper'
|
||||||
|
import { RoleSwitcherPill } from '@/components/layouts/role-switcher'
|
||||||
|
|
||||||
export default async function AdminLayout({
|
export default async function AdminLayout({
|
||||||
children,
|
children,
|
||||||
@@ -34,6 +35,12 @@ export default async function AdminLayout({
|
|||||||
<main className="lg:pl-64">
|
<main className="lg:pl-64">
|
||||||
{/* Spacer for mobile header */}
|
{/* Spacer for mobile header */}
|
||||||
<div className="h-16 lg:hidden" />
|
<div className="h-16 lg:hidden" />
|
||||||
|
{/* Top-bar — hosts the RoleSwitcherPill so multi-role admins
|
||||||
|
can switch dashboards from the same screen position used on
|
||||||
|
every other layout. Pill auto-hides for single-role users. */}
|
||||||
|
<div className="sticky top-0 z-30 flex h-12 items-center justify-end gap-2 border-b bg-card/80 backdrop-blur px-4">
|
||||||
|
<RoleSwitcherPill currentBasePath="/admin" />
|
||||||
|
</div>
|
||||||
<div className="container-app py-6 lg:py-8">{children}</div>
|
<div className="container-app py-6 lg:py-8">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,69 +8,72 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Progress } from '@/components/ui/progress'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import {
|
import { Star, MessageSquare, Trophy, Vote, ShieldCheck } from 'lucide-react'
|
||||||
Star,
|
|
||||||
MessageSquare,
|
|
||||||
Trophy,
|
|
||||||
Vote,
|
|
||||||
TrendingUp,
|
|
||||||
BarChart3,
|
|
||||||
Award,
|
|
||||||
ShieldCheck,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type EvaluationRound = {
|
type Criterion = {
|
||||||
roundId: string
|
id?: string
|
||||||
roundName: string
|
type?: string
|
||||||
roundType: string
|
label?: string
|
||||||
evaluationCount: number
|
name?: string
|
||||||
evaluations: Array<{
|
scale?: string
|
||||||
|
maxScore?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Evaluation = {
|
||||||
id: string
|
id: string
|
||||||
submittedAt: Date | null
|
submittedAt: Date | null
|
||||||
globalScore: number | null
|
globalScore: number | null
|
||||||
criterionScores: unknown
|
criterionScores: unknown
|
||||||
feedbackText: string | null
|
feedbackText: string | null
|
||||||
criteria: unknown
|
criteria: unknown
|
||||||
}>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeRoundStats(round: EvaluationRound) {
|
type EvaluationRound = {
|
||||||
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100
|
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
|
const scores = round.evaluations
|
||||||
.map((ev) => ev.globalScore)
|
.map((ev) => ev.globalScore)
|
||||||
.filter((s): s is number => s !== null)
|
.filter((s): s is number => s !== null)
|
||||||
const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null
|
if (scores.length === 0) return null
|
||||||
const highest = scores.length > 0 ? Math.max(...scores) : null
|
const max = 10
|
||||||
const lowest = scores.length > 0 ? Math.min(...scores) : null
|
const avg = scores.reduce((a, b) => a + b, 0) / scores.length
|
||||||
return { maxScore, avg, highest, lowest, scores }
|
const lowest = Math.min(...scores)
|
||||||
}
|
const highest = Math.max(...scores)
|
||||||
|
return { avg, lowest, highest, max }
|
||||||
function ScoreBar({ score, maxScore, color }: { score: number; maxScore: number; color: string }) {
|
|
||||||
const pct = (score / maxScore) * 100
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 flex-1">
|
|
||||||
<div className="flex-1 overflow-hidden rounded-full bg-muted" style={{ height: 10 }}>
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${pct}%`, backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-semibold tabular-nums w-8 text-right">{score}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getScoreColor(score: number, maxScore: number): string {
|
|
||||||
const pct = score / maxScore
|
|
||||||
if (pct >= 0.8) return '#053d57'
|
|
||||||
if (pct >= 0.6) return '#1e7a8a'
|
|
||||||
if (pct >= 0.4) return '#557f8c'
|
|
||||||
if (pct >= 0.2) return '#c4453a'
|
|
||||||
return '#de0f1e'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoundIcon({ roundType, className }: { roundType: string; className?: string }) {
|
function RoundIcon({ roundType, className }: { roundType: string; className?: string }) {
|
||||||
@@ -85,6 +88,48 @@ function roundIconBg(roundType: string) {
|
|||||||
return 'bg-yellow-500/10'
|
return 'bg-yellow-500/10'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CriterionBar({ value, max }: { value: number; max: number }) {
|
||||||
|
const pct = Math.max(0, Math.min(100, (value / max) * 100))
|
||||||
|
return (
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-brand-blue transition-all"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumericCriterion({ label, score, max }: { label: string; score: number | undefined; max: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-semibold tabular-nums">
|
||||||
|
{score !== undefined ? score : '—'}
|
||||||
|
<span className="text-muted-foreground font-normal text-xs"> / {max}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{score !== undefined && <CriterionBar value={score} max={max} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextCriterion({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/40 px-4 py-3 border-l-3 border-brand-teal">
|
||||||
|
<p className="text-sm italic text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ApplicantEvaluationsPage() {
|
export default function ApplicantEvaluationsPage() {
|
||||||
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
|
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
|
||||||
|
|
||||||
@@ -95,14 +140,6 @@ export default function ApplicantEvaluationsPage() {
|
|||||||
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
||||||
<p className="text-muted-foreground">Anonymous evaluations from jury members</p>
|
<p className="text-muted-foreground">Anonymous evaluations from jury members</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-px overflow-hidden rounded-lg border bg-border">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className="bg-card p-4">
|
|
||||||
<Skeleton className="h-5 w-20 mb-2" />
|
|
||||||
<Skeleton className="h-8 w-16" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{[1, 2].map((i) => (
|
{[1, 2].map((i) => (
|
||||||
<Card key={i}>
|
<Card key={i}>
|
||||||
@@ -119,34 +156,14 @@ export default function ApplicantEvaluationsPage() {
|
|||||||
|
|
||||||
const hasEvaluations = rounds && rounds.length > 0
|
const hasEvaluations = rounds && rounds.length > 0
|
||||||
|
|
||||||
// Compute global stats
|
|
||||||
const allScores: number[] = []
|
|
||||||
let totalEvaluations = 0
|
|
||||||
if (rounds) {
|
|
||||||
for (const round of rounds) {
|
|
||||||
totalEvaluations += round.evaluationCount
|
|
||||||
for (const ev of round.evaluations) {
|
|
||||||
if (ev.globalScore !== null && round.roundType !== 'DELIBERATION') {
|
|
||||||
// Normalize to 0-100 for live final scores
|
|
||||||
const normalized = round.roundType === 'LIVE_FINAL'
|
|
||||||
? ev.globalScore * 10
|
|
||||||
: ev.globalScore
|
|
||||||
allScores.push(normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const globalAvg = allScores.length > 0
|
|
||||||
? allScores.reduce((a, b) => a + b, 0) / allScores.length
|
|
||||||
: null
|
|
||||||
const globalHighest = allScores.length > 0 ? Math.max(...allScores) : null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Anonymous evaluations from jury members
|
{hasEvaluations
|
||||||
|
? `Anonymous evaluations from jury members across ${rounds.length} round${rounds.length === 1 ? '' : 's'}.`
|
||||||
|
: 'Anonymous evaluations from jury members.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,105 +181,44 @@ export default function ApplicantEvaluationsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats Summary Strip */}
|
|
||||||
<AnimatedCard index={0}>
|
|
||||||
<Card className="p-0 overflow-hidden">
|
|
||||||
<div className="grid grid-cols-3 divide-x divide-border">
|
|
||||||
<div className="p-4 text-center">
|
|
||||||
<div className="flex items-center justify-center gap-1.5 mb-1">
|
|
||||||
<BarChart3 className="h-3.5 w-3.5 text-blue-500" />
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Reviews</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold tabular-nums">{totalEvaluations}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 text-center">
|
|
||||||
<div className="flex items-center justify-center gap-1.5 mb-1">
|
|
||||||
<TrendingUp className="h-3.5 w-3.5 text-emerald-500" />
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Avg Score</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold tabular-nums">
|
|
||||||
{globalAvg !== null ? globalAvg.toFixed(1) : '—'}
|
|
||||||
{globalAvg !== null && <span className="text-sm font-normal text-muted-foreground"> / 100</span>}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 text-center">
|
|
||||||
<div className="flex items-center justify-center gap-1.5 mb-1">
|
|
||||||
<Award className="h-3.5 w-3.5 text-amber-500" />
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Highest</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold tabular-nums">
|
|
||||||
{globalHighest !== null ? globalHighest : '—'}
|
|
||||||
{globalHighest !== null && <span className="text-sm font-normal text-muted-foreground"> / 100</span>}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
|
|
||||||
{/* Per-Round Cards */}
|
|
||||||
{rounds.map((round, roundIdx) => {
|
{rounds.map((round, roundIdx) => {
|
||||||
const { maxScore, avg, highest, lowest } = computeRoundStats(round)
|
const summary = globalScoreSummary(round)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedCard key={round.roundId} index={roundIdx + 1}>
|
<AnimatedCard key={round.roundId} index={roundIdx}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<CardTitle className="flex items-center gap-2.5">
|
<CardTitle className="flex items-center gap-2.5">
|
||||||
<div className={cn('rounded-lg p-1.5', roundIconBg(round.roundType))}>
|
<div className={cn('rounded-lg p-1.5', roundIconBg(round.roundType))}>
|
||||||
<RoundIcon roundType={round.roundType} />
|
<RoundIcon roundType={round.roundType} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>{round.roundName}</span>
|
<span>{round.roundName}</span>
|
||||||
{avg !== null && round.roundType !== 'DELIBERATION' && (
|
{summary && (
|
||||||
<p className="text-sm font-normal text-muted-foreground mt-0.5">
|
<p className="text-sm font-normal text-muted-foreground mt-0.5">
|
||||||
Average: <span className="font-semibold text-foreground">{avg.toFixed(1)}</span> / {maxScore}
|
Average <span className="font-semibold text-foreground">{summary.avg.toFixed(1)}</span> / {summary.max}
|
||||||
{highest !== null && lowest !== null && highest !== lowest && (
|
{summary.lowest !== summary.highest && (
|
||||||
<span className="ml-2">
|
<span className="ml-2">· Lowest {summary.lowest} · Highest {summary.highest}</span>
|
||||||
Range: {lowest}–{highest}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary" className="shrink-0">
|
||||||
{round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'evaluation'}{round.evaluationCount !== 1 ? 's' : ''}
|
{round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'review'}{round.evaluationCount !== 1 ? 's' : ''}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{/* Score Overview Bar — visual comparison across evaluators */}
|
|
||||||
{round.roundType !== 'DELIBERATION' && round.evaluations.some((ev) => ev.globalScore !== null) && (
|
|
||||||
<div className="px-6 pb-3">
|
|
||||||
<div className="rounded-lg bg-muted/40 p-3 space-y-2">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Score Comparison</p>
|
|
||||||
{round.evaluations.map((ev, idx) => {
|
|
||||||
if (ev.globalScore === null) return null
|
|
||||||
return (
|
|
||||||
<div key={ev.id} className="flex items-center gap-3">
|
|
||||||
<span className="text-xs text-muted-foreground w-6 text-right shrink-0 tabular-nums">
|
|
||||||
#{idx + 1}
|
|
||||||
</span>
|
|
||||||
<ScoreBar
|
|
||||||
score={ev.globalScore}
|
|
||||||
maxScore={maxScore}
|
|
||||||
color={getScoreColor(ev.globalScore, maxScore)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{round.evaluations.map((ev, idx) => (
|
{round.evaluations.map((ev, idx) => {
|
||||||
<div
|
const criteria = visibleCriteria(ev.criteria)
|
||||||
key={ev.id}
|
const scores = (ev.criterionScores ?? {}) as Record<string, unknown>
|
||||||
className="px-6 py-4 space-y-3"
|
|
||||||
>
|
return (
|
||||||
|
<div key={ev.id} className="px-6 py-4 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium text-sm">
|
<span className="font-medium text-sm">
|
||||||
{round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`}
|
{round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`}
|
||||||
@@ -272,7 +228,7 @@ export default function ApplicantEvaluationsPage() {
|
|||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Star className="h-3.5 w-3.5 text-yellow-500" />
|
<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-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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{ev.submittedAt && (
|
{ev.submittedAt && (
|
||||||
@@ -283,37 +239,23 @@ export default function ApplicantEvaluationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ev.criterionScores && ev.criteria && (
|
{criteria.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Criteria Breakdown</p>
|
{criteria.map((c, ci) => {
|
||||||
<div className="grid gap-2">
|
|
||||||
{(() => {
|
|
||||||
const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
|
|
||||||
const scores = ev.criterionScores as Record<string, number>
|
|
||||||
return criteria
|
|
||||||
.filter((c) => c.id || c.label || c.name)
|
|
||||||
.map((c, ci) => {
|
|
||||||
const key = c.id || String(ci)
|
const key = c.id || String(ci)
|
||||||
const score = scores[key]
|
const label = c.label || c.name || `Criterion ${ci + 1}`
|
||||||
const cMax = c.maxScore || 10
|
const raw = scores[key]
|
||||||
const pct = score !== undefined ? (score / cMax) * 100 : 0
|
|
||||||
return (
|
if (c.type === 'text') {
|
||||||
<div key={ci} className="space-y-1">
|
if (typeof raw !== 'string' || raw.trim() === '') return null
|
||||||
<div className="flex items-center justify-between text-sm">
|
return <TextCriterion key={key} label={label} value={raw} />
|
||||||
<span className="text-muted-foreground">{c.label || c.name || `Criterion ${ci + 1}`}</span>
|
}
|
||||||
<span className="font-semibold tabular-nums">
|
|
||||||
{score !== undefined ? score : '—'}
|
// numeric (default)
|
||||||
<span className="text-muted-foreground font-normal text-xs"> / {cMax}</span>
|
const score = typeof raw === 'number' ? raw : undefined
|
||||||
</span>
|
const max = getCriterionMax(c)
|
||||||
</div>
|
return <NumericCriterion key={key} label={label} score={score} max={max} />
|
||||||
{score !== undefined && (
|
})}
|
||||||
<Progress value={pct} className="h-1.5" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -324,14 +266,15 @@ export default function ApplicantEvaluationsPage() {
|
|||||||
{round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'}
|
{round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-muted/40 px-4 py-3 border-l-3 border-brand-teal">
|
<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}
|
{ev.feedbackText}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -339,7 +282,6 @@ export default function ApplicantEvaluationsPage() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Confidentiality Footer */}
|
|
||||||
<div className="flex items-center justify-center gap-2 py-2">
|
<div className="flex items-center justify-center gap-2 py-2">
|
||||||
<ShieldCheck className="h-4 w-4 text-muted-foreground/60" />
|
<ShieldCheck className="h-4 w-4 text-muted-foreground/60" />
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
|
import { format } from 'date-fns'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -9,12 +11,18 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||||
|
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
||||||
|
import { FinalDocumentsPanel } from '@/components/applicant/final-documents-panel'
|
||||||
|
import { RequestChangeDialog } from './request-change-dialog'
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
FileText,
|
FileText,
|
||||||
|
UserCog,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export default function ApplicantMentorPage() {
|
export default function ApplicantMentorPage() {
|
||||||
@@ -40,6 +48,8 @@ export default function ApplicantMentorPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [isChangeOpen, setIsChangeOpen] = useState(false)
|
||||||
|
|
||||||
if (dashLoading) {
|
if (dashLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -71,7 +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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -82,23 +105,72 @@ export default function ApplicantMentorPage() {
|
|||||||
Mentor Communication
|
Mentor Communication
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Chat with your assigned mentor
|
{assignments.length > 1
|
||||||
|
? 'Chat with your assigned mentor team'
|
||||||
|
: 'Chat with your assigned mentor'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mentor info */}
|
{/* Mentor list */}
|
||||||
{mentor ? (
|
{hasMentors ? (
|
||||||
<Card className="bg-muted/50">
|
<section className="space-y-3">
|
||||||
<CardContent className="p-4">
|
<h2 className="text-lg font-semibold tracking-tight">{teamHeading}</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<UserCircle className="h-10 w-10 text-muted-foreground" />
|
{assignments.map((assignment) => {
|
||||||
<div>
|
const mentor = assignment.mentor
|
||||||
<p className="font-medium">{mentor.name || 'Mentor'}</p>
|
if (!mentor) return null
|
||||||
<p className="text-sm text-muted-foreground">{mentor.email}</p>
|
const expertise = mentor.expertiseTags ?? []
|
||||||
|
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>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Request change action */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pt-1">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{hasPendingChangeRequest
|
||||||
|
? "You have a pending mentor change request — admins will follow up soon."
|
||||||
|
: 'Need a different match? Let the program admins know.'}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsChangeOpen(true)}
|
||||||
|
disabled={hasPendingChangeRequest}
|
||||||
|
>
|
||||||
|
<UserCog className="mr-2 h-4 w-4" />
|
||||||
|
{hasPendingChangeRequest ? 'Change requested' : 'Request a mentor change'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<Card className="bg-muted/50">
|
<Card className="bg-muted/50">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||||
@@ -112,12 +184,14 @@ export default function ApplicantMentorPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chat */}
|
{/* Chat */}
|
||||||
{mentor && (
|
{primaryMentor && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Messages</CardTitle>
|
<CardTitle>Messages</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Your conversation history with {mentor.name || 'your mentor'}
|
{assignments.length > 1
|
||||||
|
? 'Your conversation history with your mentor team'
|
||||||
|
: `Your conversation history with ${primaryMentor.name || 'your mentor'}`}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -133,6 +207,28 @@ export default function ApplicantMentorPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Files */}
|
||||||
|
{primaryAssignment?.id && projectId && (
|
||||||
|
<WorkspaceFilesPanel
|
||||||
|
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>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,6 +17,12 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
|
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
|
||||||
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
|
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
|
||||||
|
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card'
|
||||||
|
import { AttendingMembersCard } from '@/components/applicant/attending-members-card'
|
||||||
|
import { 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'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
@@ -201,6 +207,9 @@ export default function ApplicantDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Grand Final document upload banner (auto-hides for non-finalists) */}
|
||||||
|
<FinalDocumentsBanner />
|
||||||
|
|
||||||
{/* Active round deadline banner */}
|
{/* Active round deadline banner */}
|
||||||
{!isRejected && openRounds.length > 0 && (() => {
|
{!isRejected && openRounds.length > 0 && (() => {
|
||||||
const submissionTypes = new Set(['INTAKE', 'SUBMISSION', 'MENTORING'])
|
const submissionTypes = new Set(['INTAKE', 'SUBMISSION', 'MENTORING'])
|
||||||
@@ -215,12 +224,12 @@ export default function ApplicantDashboardPage() {
|
|||||||
key={round.id}
|
key={round.id}
|
||||||
className={`flex flex-col sm:flex-row items-start sm:items-center gap-3 rounded-lg border px-4 py-3 ${
|
className={`flex flex-col sm:flex-row items-start sm:items-center gap-3 rounded-lg border px-4 py-3 ${
|
||||||
isUrgent
|
isUrgent
|
||||||
? 'border-amber-500/50 bg-amber-50 dark:bg-amber-950/20'
|
? 'border-amber-500/50 bg-amber-50'
|
||||||
: 'border-primary/20 bg-primary/5'
|
: 'border-primary/20 bg-primary/5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Clock className={`h-4 w-4 shrink-0 ${isUrgent ? 'text-amber-600 dark:text-amber-400' : 'text-primary'}`} />
|
<Clock className={`h-4 w-4 shrink-0 ${isUrgent ? 'text-amber-600' : 'text-primary'}`} />
|
||||||
<span className="font-medium text-sm truncate">{round.name}</span>
|
<span className="font-medium text-sm truncate">{round.name}</span>
|
||||||
<Badge variant={isUrgent ? 'warning' : 'default'} className="shrink-0">
|
<Badge variant={isUrgent ? 'warning' : 'default'} className="shrink-0">
|
||||||
{remaining > 0 ? formatCountdown(remaining) + ' left' : 'Closed'}
|
{remaining > 0 ? formatCountdown(remaining) + ' left' : 'Closed'}
|
||||||
@@ -401,6 +410,22 @@ export default function ApplicantDashboardPage() {
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Lunch banner (auto-hides when lunch event disabled or unconfigured) */}
|
||||||
|
<LunchBanner programId={project.programId} />
|
||||||
|
|
||||||
|
{/* External lunch attendees attached to this team (auto-hides if none) */}
|
||||||
|
<ExternalAttendeesStrip projectId={project.id} />
|
||||||
|
|
||||||
|
{/* Grand finale attendee roster (auto-hides until confirmation status is CONFIRMED) */}
|
||||||
|
<AttendingMembersCard />
|
||||||
|
|
||||||
|
{/* 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} />
|
||||||
|
|
||||||
|
|
||||||
{/* Jury Feedback Card */}
|
{/* Jury Feedback Card */}
|
||||||
{totalEvaluations > 0 && (
|
{totalEvaluations > 0 && (
|
||||||
<AnimatedCard index={4}>
|
<AnimatedCard index={4}>
|
||||||
@@ -422,13 +447,14 @@ export default function ApplicantDashboardPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{evaluations?.map((round) => {
|
{evaluations?.map((round) => {
|
||||||
|
const showScore = round.roundType !== 'DELIBERATION'
|
||||||
const scores = round.evaluations
|
const scores = round.evaluations
|
||||||
.map((ev) => ev.globalScore)
|
.map((ev) => ev.globalScore)
|
||||||
.filter((s): s is number => s !== null)
|
.filter((s): s is number => s !== null)
|
||||||
const avgScore = scores.length > 0
|
const avgScore = showScore && scores.length > 0
|
||||||
? scores.reduce((a, b) => a + b, 0) / scores.length
|
? scores.reduce((a, b) => a + b, 0) / scores.length
|
||||||
: null
|
: null
|
||||||
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100
|
const maxScore = 10
|
||||||
const pct = avgScore !== null ? (avgScore / maxScore) * 100 : 0
|
const pct = avgScore !== null ? (avgScore / maxScore) * 100 : 0
|
||||||
const roundIcon = round.roundType === 'LIVE_FINAL'
|
const roundIcon = round.roundType === 'LIVE_FINAL'
|
||||||
? <Trophy className="h-3.5 w-3.5 text-amber-500" />
|
? <Trophy className="h-3.5 w-3.5 text-amber-500" />
|
||||||
|
|||||||
@@ -357,15 +357,33 @@ export default function ApplicantProjectPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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 font-medium mb-1">
|
||||||
<p className="text-sm text-muted-foreground">
|
{active.length === 1 ? 'Assigned Mentor' : `Assigned Mentors (${active.length})`}
|
||||||
{project.mentorAssignment.mentor.name} ({project.mentorAssignment.mentor.email})
|
|
||||||
</p>
|
</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 */}
|
{/* Tags */}
|
||||||
{project.tags && project.tags.length > 0 && (
|
{project.tags && project.tags.length > 0 && (
|
||||||
|
|||||||
@@ -160,8 +160,12 @@ function AcceptInviteContent() {
|
|||||||
setState('error')
|
setState('error')
|
||||||
setErrorType('AUTH_FAILED')
|
setErrorType('AUTH_FAILED')
|
||||||
} else if (result?.ok) {
|
} else if (result?.ok) {
|
||||||
// Redirect to set-password (middleware will enforce this since mustSetPassword=true)
|
// Let app/page.tsx route by role. Middleware will detour to
|
||||||
window.location.href = '/set-password'
|
// /set-password if the user still needs to set one (first-time
|
||||||
|
// setup); for users who already had a password (admin-issued
|
||||||
|
// access link, magic-login style) it'll go straight to their
|
||||||
|
// dashboard.
|
||||||
|
window.location.href = '/'
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setState('error')
|
setState('error')
|
||||||
|
|||||||
@@ -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'
|
} from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Trophy,
|
Trophy,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Loader2,
|
Loader2,
|
||||||
GripVertical,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Users,
|
Users,
|
||||||
Tag,
|
Tag,
|
||||||
|
Star,
|
||||||
|
Gavel,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { CountryDisplay } from '@/components/shared/country-display'
|
import { CountryDisplay } from '@/components/shared/country-display'
|
||||||
@@ -50,11 +63,19 @@ export default function JuryAwardVotingPage({
|
|||||||
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
|
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const confirmWinner = trpc.specialAward.confirmWinner.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
|
||||||
|
toast.success('Winner confirmed and award closed')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
const [rankedIds, setRankedIds] = useState<string[]>([])
|
const [rankedIds, setRankedIds] = useState<string[]>([])
|
||||||
|
const [justification, setJustification] = useState('')
|
||||||
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set())
|
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const toggleExpanded = (projectId: string) => {
|
const toggleExpanded = (projectId: string) => {
|
||||||
@@ -71,6 +92,9 @@ export default function JuryAwardVotingPage({
|
|||||||
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
|
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
|
||||||
if (data.award.scoringMode === 'PICK_WINNER') {
|
if (data.award.scoringMode === 'PICK_WINNER') {
|
||||||
setSelectedProjectId(data.myVotes[0]?.projectId || null)
|
setSelectedProjectId(data.myVotes[0]?.projectId || null)
|
||||||
|
if (data.myVotes[0]?.justification) {
|
||||||
|
setJustification(data.myVotes[0].justification)
|
||||||
|
}
|
||||||
} else if (data.award.scoringMode === 'RANKED') {
|
} else if (data.award.scoringMode === 'RANKED') {
|
||||||
const sorted = [...data.myVotes]
|
const sorted = [...data.myVotes]
|
||||||
.sort((a, b) => (a.rank || 0) - (b.rank || 0))
|
.sort((a, b) => (a.rank || 0) - (b.rank || 0))
|
||||||
@@ -84,7 +108,10 @@ export default function JuryAwardVotingPage({
|
|||||||
try {
|
try {
|
||||||
await submitVote.mutateAsync({
|
await submitVote.mutateAsync({
|
||||||
awardId,
|
awardId,
|
||||||
votes: [{ projectId: selectedProjectId }],
|
votes: [{
|
||||||
|
projectId: selectedProjectId,
|
||||||
|
justification: justification.trim() || undefined,
|
||||||
|
}],
|
||||||
})
|
})
|
||||||
toast.success('Vote submitted')
|
toast.success('Vote submitted')
|
||||||
refetch()
|
refetch()
|
||||||
@@ -136,9 +163,10 @@ export default function JuryAwardVotingPage({
|
|||||||
|
|
||||||
if (!data) return null
|
if (!data) return null
|
||||||
|
|
||||||
const { award, projects, myVotes } = data
|
const { award, projects, myVotes, isChair, otherVotes, totalJurors } = data
|
||||||
const hasVoted = myVotes.length > 0
|
const hasVoted = myVotes.length > 0
|
||||||
const isVotingOpen = award.status === 'VOTING_OPEN'
|
const isVotingOpen = award.status === 'VOTING_OPEN'
|
||||||
|
const isClosed = award.status === 'CLOSED'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -197,10 +225,30 @@ export default function JuryAwardVotingPage({
|
|||||||
isExpanded={expandedProjects.has(project.id)}
|
isExpanded={expandedProjects.has(project.id)}
|
||||||
onSelect={() => setSelectedProjectId(project.id)}
|
onSelect={() => setSelectedProjectId(project.id)}
|
||||||
onToggleExpand={() => toggleExpanded(project.id)}
|
onToggleExpand={() => toggleExpanded(project.id)}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedProjectId && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Justification (optional)</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Visible to the jury chair when they finalize the award.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder="Why this project? (optional)"
|
||||||
|
value={justification}
|
||||||
|
onChange={(e) => setJustification(e.target.value)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmitPickWinner}
|
onClick={handleSubmitPickWinner}
|
||||||
@@ -214,6 +262,18 @@ export default function JuryAwardVotingPage({
|
|||||||
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isChair && totalJurors > 1 && (
|
||||||
|
<ChairPanel
|
||||||
|
award={award}
|
||||||
|
projects={projects}
|
||||||
|
otherVotes={otherVotes}
|
||||||
|
totalJurors={totalJurors}
|
||||||
|
hasVoted={hasVoted}
|
||||||
|
onConfirm={() => confirmWinner.mutate({ awardId })}
|
||||||
|
isPending={confirmWinner.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : award.scoringMode === 'RANKED' ? (
|
) : award.scoringMode === 'RANKED' ? (
|
||||||
/* RANKED Mode */
|
/* RANKED Mode */
|
||||||
@@ -332,6 +392,7 @@ type ProjectData = {
|
|||||||
tags: string[]
|
tags: string[]
|
||||||
logoKey?: string | null
|
logoKey?: string | null
|
||||||
logoUrl?: string | null
|
logoUrl?: string | null
|
||||||
|
evaluationScore?: { avg: number; count: number } | null
|
||||||
files: Array<{
|
files: Array<{
|
||||||
id: string
|
id: string
|
||||||
fileName: string
|
fileName: string
|
||||||
@@ -355,9 +416,31 @@ type ProjectData = {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OtherVote = {
|
||||||
|
userId: string
|
||||||
|
userName: string | null
|
||||||
|
projectId: string
|
||||||
|
justification: string | null
|
||||||
|
}
|
||||||
|
|
||||||
function ProjectDetails({ project }: { project: ProjectData }) {
|
function ProjectDetails({ project }: { project: ProjectData }) {
|
||||||
return (
|
return (
|
||||||
<div className="px-4 pb-4 pt-2 space-y-4 border-t mt-2">
|
<div className="px-4 pb-4 pt-2 space-y-4 border-t mt-2">
|
||||||
|
{project.evaluationScore && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-blue-50/50 px-3 py-2">
|
||||||
|
<Star className="h-4 w-4 text-blue-600 shrink-0" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-semibold text-blue-700">
|
||||||
|
{project.evaluationScore.avg.toFixed(1)} / 10
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground ml-2">
|
||||||
|
from {project.evaluationScore.count}{' '}
|
||||||
|
{project.evaluationScore.count === 1 ? 'evaluation' : 'evaluations'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{project.description && (
|
{project.description && (
|
||||||
<p className="text-sm text-muted-foreground whitespace-pre-line">{project.description}</p>
|
<p className="text-sm text-muted-foreground whitespace-pre-line">{project.description}</p>
|
||||||
)}
|
)}
|
||||||
@@ -435,7 +518,7 @@ function ProjectCard({
|
|||||||
isExpanded && 'rotate-180'
|
isExpanded && 'rotate-180'
|
||||||
)} />
|
)} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">
|
<h3 className="font-semibold text-sm group-hover:text-brand-blue transition-colors">
|
||||||
{project.title}
|
{project.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
@@ -469,3 +552,139 @@ function ProjectCard({
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChairPanel({
|
||||||
|
award,
|
||||||
|
projects,
|
||||||
|
otherVotes,
|
||||||
|
totalJurors,
|
||||||
|
hasVoted,
|
||||||
|
onConfirm,
|
||||||
|
isPending,
|
||||||
|
}: {
|
||||||
|
award: { id: string; status: string }
|
||||||
|
projects: ProjectData[]
|
||||||
|
otherVotes: OtherVote[]
|
||||||
|
totalJurors: number
|
||||||
|
hasVoted: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
isPending: boolean
|
||||||
|
}) {
|
||||||
|
const projectMap = new Map(projects.map((p) => [p.id, p]))
|
||||||
|
const tally = new Map<string, number>()
|
||||||
|
for (const v of otherVotes) {
|
||||||
|
tally.set(v.projectId, (tally.get(v.projectId) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
const ranked = Array.from(tally.entries())
|
||||||
|
.map(([projectId, votes]) => ({
|
||||||
|
project: projectMap.get(projectId),
|
||||||
|
votes,
|
||||||
|
}))
|
||||||
|
.filter((r) => r.project)
|
||||||
|
.sort((a, b) => b.votes - a.votes)
|
||||||
|
|
||||||
|
const votedCount = new Set(otherVotes.map((v) => v.userId)).size + (hasVoted ? 1 : 0)
|
||||||
|
const isClosed = award.status === 'CLOSED'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-amber-200">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Gavel className="h-5 w-5 text-amber-600" />
|
||||||
|
<CardTitle className="text-base">Chair tools</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
{votedCount} of {totalJurors} jurors have voted. As the chair you
|
||||||
|
can review their picks and finalize the award.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{ranked.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
No other juror votes yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Tally so far
|
||||||
|
</p>
|
||||||
|
{ranked.map(({ project, votes }) => (
|
||||||
|
<div key={project!.id} className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||||
|
<span className="text-sm font-medium truncate">{project!.title}</span>
|
||||||
|
<Badge variant="secondary">{votes} {votes === 1 ? 'vote' : 'votes'}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{otherVotes.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Justifications
|
||||||
|
</p>
|
||||||
|
{otherVotes.map((v) => {
|
||||||
|
const project = projectMap.get(v.projectId)
|
||||||
|
return (
|
||||||
|
<div key={v.userId} className="rounded-md border p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{v.userName || 'Anonymous juror'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
→ {project?.title || 'Unknown project'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{v.justification && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2 whitespace-pre-line">
|
||||||
|
{v.justification}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isClosed && (
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button disabled={!hasVoted || isPending}>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trophy className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Confirm winner & close award
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Finalize the award?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
The project with the most votes will be set as the
|
||||||
|
winner. If there'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'
|
'use client'
|
||||||
|
|
||||||
import { use, useState } from 'react'
|
import { use, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
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 { 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'
|
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 }> }) {
|
export default function JuryLivePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
|
||||||
const params = use(paramsPromise)
|
const params = use(paramsPromise)
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const [notes, setNotes] = useState('')
|
|
||||||
const [priorDataOpen, setPriorDataOpen] = useState(false)
|
|
||||||
|
|
||||||
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId })
|
const { data: cursor } = trpc.live.getCursor.useQuery(
|
||||||
|
{ roundId: params.roundId },
|
||||||
// Fetch live voting session data
|
{ refetchInterval: 2000 }
|
||||||
const { data: sessionData } = trpc.liveVoting.getSessionForVoting.useQuery(
|
|
||||||
{ sessionId: params.roundId },
|
|
||||||
{ enabled: !!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
|
// ── Persisted notes (autosave, keyed per project) ────────────────────────
|
||||||
const priorData = null as { averageScore?: number; evaluationCount?: number; strengths?: string; weaknesses?: string } | null
|
const [noteDrafts, setNoteDrafts] = useState<Record<string, string>>({})
|
||||||
|
const [noteStatus, setNoteStatus] = useState<'idle' | 'saving' | 'saved'>('idle')
|
||||||
const submitVoteMutation = trpc.liveVoting.vote.useMutation({
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
onSuccess: () => {
|
const saveNote = trpc.live.saveNote.useMutation({
|
||||||
utils.liveVoting.getSessionForVoting.invalidate()
|
onSuccess: () => setNoteStatus('saved'),
|
||||||
toast.success('Vote submitted successfully')
|
onError: () => setNoteStatus('idle'),
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
toast.error(err.message)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleVoteSubmit = (vote: { score: number; criterionScores?: Record<string, number> }) => {
|
const activeProject = cursor?.activeProject ?? null
|
||||||
const projectId = cursor?.activeProject?.id || sessionData?.currentProject?.id
|
const activeProjectId = activeProject?.id ?? null
|
||||||
if (!projectId) return
|
|
||||||
|
|
||||||
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({
|
submitVoteMutation.mutate({
|
||||||
sessionId,
|
sessionId: sessionData.session.id,
|
||||||
projectId,
|
projectId: activeProjectId,
|
||||||
score: vote.score,
|
score: vote.score,
|
||||||
criterionScores: vote.criterionScores,
|
criterionScores: vote.criterionScores,
|
||||||
|
comment: vote.comment,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract voting mode and criteria from session
|
|
||||||
const votingMode = (sessionData?.session?.votingMode ?? 'simple') as 'simple' | 'criteria'
|
const votingMode = (sessionData?.session?.votingMode ?? 'simple') as 'simple' | 'criteria'
|
||||||
const criteria = (sessionData?.session?.criteriaJson as Array<{
|
const criteria = sessionData?.session?.criteriaJson as
|
||||||
id: string
|
| Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
|
||||||
label: string
|
| undefined
|
||||||
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) {
|
if (!activeProject) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<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">
|
<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>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 (
|
return (
|
||||||
<div className="space-y-6">
|
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between">
|
<CardTitle className="text-base">About this project</CardTitle>
|
||||||
<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>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{activeProject.description && (
|
<p className="text-sm text-muted-foreground">{activeProject.description}</p>
|
||||||
<p className="text-muted-foreground">{activeProject.description}</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{/* Prior Jury Data (Collapsible) */}
|
const phaseMeta = PHASE_META[phase] ?? PHASE_META.PRESENTING
|
||||||
{priorData && (
|
const PhaseIcon = phaseMeta.icon
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Notes Section */}
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Current Project + phase */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<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>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={notes}
|
value={currentDraft}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => handleNoteChange(e.target.value)}
|
||||||
placeholder="Add your observations and comments..."
|
placeholder="Observations during the presentation and Q&A…"
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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
|
<LiveVotingForm
|
||||||
|
key={`${activeProject.id}-${sessionData?.userVote?.votedAt ?? 'fresh'}`}
|
||||||
projectId={activeProject.id}
|
projectId={activeProject.id}
|
||||||
votingMode={votingMode}
|
votingMode={votingMode}
|
||||||
criteria={criteria}
|
criteria={criteria}
|
||||||
existingVote={sessionData?.userVote ? {
|
existingVote={
|
||||||
|
sessionData?.userVote
|
||||||
|
? {
|
||||||
score: sessionData.userVote.score,
|
score: sessionData.userVote.score,
|
||||||
criterionScoresJson: sessionData.userVote.criterionScoresJson as Record<string, number> | undefined
|
criterionScoresJson: sessionData.userVote.criterionScoresJson as
|
||||||
} : null}
|
| Record<string, number>
|
||||||
|
| undefined,
|
||||||
|
comment: sessionData.userVote.comment,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
onVoteSubmit={handleVoteSubmit}
|
onVoteSubmit={handleVoteSubmit}
|
||||||
disabled={submitVoteMutation.isPending}
|
disabled={submitVoteMutation.isPending}
|
||||||
|
highlighted={phase === 'SCORING'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function JuryRoundDetailPage() {
|
|||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||||
{round?.name || 'Round Details'}
|
{round?.name || 'Round Details'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<Card className="border-l-4 border-l-amber-500">
|
<Card className="border-l-4 border-l-amber-500">
|
||||||
<CardContent className="flex items-start gap-4 p-6">
|
<CardContent className="flex items-start gap-4 p-6">
|
||||||
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3">
|
<div className="rounded-xl bg-amber-50 p-3">
|
||||||
<Clock className="h-6 w-6 text-amber-600" />
|
<Clock className="h-6 w-6 text-amber-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -495,7 +495,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||||
Evaluate Project
|
Evaluate Project
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">{project.title}</p>
|
<p className="text-muted-foreground mt-1">{project.title}</p>
|
||||||
@@ -526,7 +526,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||||
Evaluate Project
|
Evaluate Project
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">{project.title}</p>
|
<p className="text-muted-foreground mt-1">{project.title}</p>
|
||||||
@@ -534,7 +534,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<Card className="border-l-4 border-l-amber-500">
|
<Card className="border-l-4 border-l-amber-500">
|
||||||
<CardContent className="flex items-start gap-4 p-6">
|
<CardContent className="flex items-start gap-4 p-6">
|
||||||
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3">
|
<div className="rounded-xl bg-amber-50 p-3">
|
||||||
<ShieldAlert className="h-6 w-6 text-amber-600" />
|
<ShieldAlert className="h-6 w-6 text-amber-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -573,7 +573,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||||
{isReadOnly ? 'Submitted Evaluation' : 'Evaluate Project'}
|
{isReadOnly ? 'Submitted Evaluation' : 'Evaluate Project'}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
@@ -583,8 +583,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={
|
className={
|
||||||
project.competitionCategory === 'STARTUP'
|
project.competitionCategory === 'STARTUP'
|
||||||
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
? 'bg-violet-100 text-violet-700 border-violet-200'
|
||||||
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
|
: 'bg-sky-100 text-sky-700 border-sky-200'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||||
@@ -595,7 +595,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isReadOnly && (
|
{isReadOnly && (
|
||||||
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50 dark:bg-blue-950/20">
|
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50">
|
||||||
<CardContent className="flex items-start gap-3 p-4">
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
|
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ export default function JuryProjectDetailPage() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={
|
className={
|
||||||
project.competitionCategory === 'STARTUP'
|
project.competitionCategory === 'STARTUP'
|
||||||
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
? 'bg-violet-100 text-violet-700 border-violet-200'
|
||||||
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
|
: 'bg-sky-100 text-sky-700 border-sky-200'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||||
|
|||||||
@@ -1,150 +1,326 @@
|
|||||||
'use client';
|
'use client'
|
||||||
|
|
||||||
import { use } from 'react';
|
import { use, useState } from 'react'
|
||||||
import { trpc } from '@/lib/trpc/client';
|
import Link from 'next/link'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { DeliberationRankingForm } from '@/components/jury/deliberation-ranking-form';
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { CheckCircle2 } from 'lucide-react';
|
import { Button } from '@/components/ui/button'
|
||||||
import { toast } from 'sonner';
|
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 CATEGORY_LABEL: Record<string, string> = {
|
||||||
const params = use(paramsPromise);
|
BUSINESS_CONCEPT: 'Business Concepts',
|
||||||
const utils = trpc.useUtils();
|
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(
|
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
|
||||||
{ sessionId: params.sessionId },
|
{ 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({
|
const [submitting, setSubmitting] = useState(false)
|
||||||
onSuccess: () => {
|
const submitVoteMutation = trpc.deliberation.submitVote.useMutation()
|
||||||
utils.deliberation.getSession.invalidate();
|
|
||||||
toast.success('Vote submitted successfully');
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmitVote = (votes: Array<{ projectId: string; rank?: number; isWinnerPick?: boolean }>) => {
|
const handleSubmitVote = async (
|
||||||
votes.forEach((vote) => {
|
votes: Array<{ projectId: string; rank?: number; isWinnerPick?: boolean }>
|
||||||
submitVoteMutation.mutate({
|
) => {
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
for (const vote of votes) {
|
||||||
|
await submitVoteMutation.mutateAsync({
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
juryMemberId: '', // TODO: resolve current user's jury member ID from session participants
|
|
||||||
projectId: vote.projectId,
|
projectId: vote.projectId,
|
||||||
rank: vote.rank,
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center justify-center py-12">
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
)
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center justify-center py-12">
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
<p className="text-muted-foreground">Session not found</p>
|
<p className="text-muted-foreground">Session not found</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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') {
|
const header = (
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Deliberation Session</CardTitle>
|
<div className="flex items-start justify-between">
|
||||||
<CardDescription>
|
<div>
|
||||||
{session.round?.name} - {session.category}
|
<CardTitle>Deliberation — {CATEGORY_LABEL[session.category] ?? session.category}</CardTitle>
|
||||||
</CardDescription>
|
<CardDescription className="mt-1">{session.round?.name}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge>{session.status}</Badge>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</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">
|
<p className="text-muted-foreground">
|
||||||
{session.status === 'DELIB_OPEN'
|
{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'
|
: session.status === 'TALLYING'
|
||||||
? 'Voting is closed. Results are being tallied.'
|
? 'Voting is closed. Results are being tallied.'
|
||||||
: 'This session is locked.'}
|
: 'This session is locked.'}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
{session.status === 'DELIB_OPEN' && reviewSection}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasVoted) {
|
if (!isParticipant) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{header}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent className="flex flex-col items-center justify-center py-10">
|
||||||
<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>
|
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{session.mode === 'SINGLE_WINNER_VOTE'
|
You are not a participant of this deliberation session.
|
||||||
? 'Select your top choice for this category.'
|
|
||||||
: 'Rank all projects from best to least preferred.'}
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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
|
<DeliberationRankingForm
|
||||||
projects={session.results?.map((r) => r.project) ?? []}
|
projects={projects}
|
||||||
mode={session.mode}
|
mode={session.mode}
|
||||||
onSubmit={handleSubmitVote}
|
onSubmit={handleSubmitVote}
|
||||||
disabled={submitVoteMutation.isPending}
|
disabled={submitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function JuryAssignmentsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||||
My Assignments
|
My Assignments
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
|
|||||||
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,
|
Waves,
|
||||||
Send,
|
Send,
|
||||||
Trophy,
|
Trophy,
|
||||||
|
FileText,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { userCanReviewFinals, getOpenFinaleRound } from '@/server/services/final-documents'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
@@ -42,6 +44,70 @@ function getGreeting(): string {
|
|||||||
return 'Good evening'
|
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() {
|
async function JuryDashboardContent() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
const userId = session?.user?.id
|
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" />
|
<div className="h-1 w-full bg-gradient-to-r from-brand-teal/40 via-brand-blue/40 to-brand-teal/40" />
|
||||||
<CardContent className="py-8 px-6">
|
<CardContent className="py-8 px-6">
|
||||||
<div className="flex flex-col items-center text-center mb-6">
|
<div className="flex flex-col items-center text-center mb-6">
|
||||||
<div className="rounded-2xl bg-gradient-to-br from-brand-teal/10 to-brand-blue/10 p-4 mb-3 dark:from-brand-teal/20 dark:to-brand-blue/20">
|
<div className="rounded-2xl bg-gradient-to-br from-brand-teal/10 to-brand-blue/10 p-4 mb-3">
|
||||||
<ClipboardList className="h-8 w-8 text-brand-teal/60" />
|
<ClipboardList className="h-8 w-8 text-brand-teal/60" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-semibold">No assignments yet</p>
|
<p className="text-lg font-semibold">No assignments yet</p>
|
||||||
@@ -273,13 +339,13 @@ async function JuryDashboardContent() {
|
|||||||
<div className={`grid gap-3 max-w-md mx-auto ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
|
<div className={`grid gap-3 max-w-md mx-auto ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
|
||||||
<Link
|
<Link
|
||||||
href="/jury/competitions"
|
href="/jury/competitions"
|
||||||
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
|
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40">
|
<div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100">
|
||||||
<ClipboardList className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
<ClipboardList className="h-4 w-4 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">All Assignments</p>
|
<p className="font-semibold text-sm group-hover:text-brand-blue transition-colors">All Assignments</p>
|
||||||
<p className="text-xs text-muted-foreground">View evaluations</p>
|
<p className="text-xs text-muted-foreground">View evaluations</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -288,7 +354,7 @@ async function JuryDashboardContent() {
|
|||||||
href="/jury/competitions"
|
href="/jury/competitions"
|
||||||
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40">
|
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100">
|
||||||
<GitCompare className="h-4 w-4 text-brand-teal" />
|
<GitCompare className="h-4 w-4 text-brand-teal" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
@@ -314,8 +380,8 @@ async function JuryDashboardContent() {
|
|||||||
<div className="rounded-[7px] bg-background">
|
<div className="rounded-[7px] bg-background">
|
||||||
<CardHeader className="pb-2 pt-4 px-5">
|
<CardHeader className="pb-2 pt-4 px-5">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/40">
|
<div className="rounded-lg bg-amber-100 p-1.5">
|
||||||
<Trophy className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
<Trophy className="h-4 w-4 text-amber-600" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-lg">Special Awards — Voting Open</CardTitle>
|
<CardTitle className="text-lg">Special Awards — Voting Open</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,27 +399,27 @@ async function JuryDashboardContent() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||||
hasVoted
|
hasVoted
|
||||||
? 'border-green-200/60 bg-green-50/30 dark:border-green-800/40 dark:bg-green-950/10'
|
? 'border-green-200/60 bg-green-50/30'
|
||||||
: isUrgent
|
: isUrgent
|
||||||
? 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20'
|
? 'border-red-200 bg-red-50/50'
|
||||||
: 'border-amber-200/60 bg-amber-50/30 dark:border-amber-800/40 dark:bg-amber-950/10'
|
: 'border-amber-200/60 bg-amber-50/30'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className={cn('font-semibold', hasVoted ? 'text-green-700 dark:text-green-400' : 'text-amber-700 dark:text-amber-400')}>{award.name}</h3>
|
<h3 className={cn('font-semibold', hasVoted ? 'text-green-700' : 'text-amber-700')}>{award.name}</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{award._count.eligibilities} project{award._count.eligibilities !== 1 ? 's' : ''} to review
|
{award._count.eligibilities} project{award._count.eligibilities !== 1 ? 's' : ''} to review
|
||||||
{record.isChair && ' · You are the Chair'}
|
{record.isChair && ' · You are the Chair'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{hasVoted ? (
|
{hasVoted ? (
|
||||||
<Badge className="bg-green-100 text-green-800 border-green-300 dark:bg-green-950 dark:text-green-300 dark:border-green-700">
|
<Badge className="bg-green-100 text-green-800 border-green-300">
|
||||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
Submitted
|
Submitted
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge className="bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-700">
|
<Badge className="bg-amber-100 text-amber-800 border-amber-300">
|
||||||
Vote Now
|
Vote Now
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -452,8 +518,8 @@ async function JuryDashboardContent() {
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="rounded-lg bg-brand-blue/10 p-1.5 dark:bg-brand-blue/20">
|
<div className="rounded-lg bg-brand-blue/10 p-1.5">
|
||||||
<ClipboardList className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
|
<ClipboardList className="h-4 w-4 text-brand-blue" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-lg">My Assignments</CardTitle>
|
<CardTitle className="text-lg">My Assignments</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
@@ -487,14 +553,14 @@ async function JuryDashboardContent() {
|
|||||||
href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}`}
|
href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}`}
|
||||||
className="flex-1 min-w-0 group"
|
className="flex-1 min-w-0 group"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium truncate group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">
|
<p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
|
||||||
{assignment.project.title}
|
{assignment.project.title}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
{assignment.project.teamName}
|
{assignment.project.teamName}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-brand-blue/5 text-brand-blue/80 dark:bg-brand-teal/10 dark:text-brand-teal/80 border-0">
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-brand-blue/5 text-brand-blue/80 border-0">
|
||||||
{assignment.round.name}
|
{assignment.round.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -506,7 +572,7 @@ async function JuryDashboardContent() {
|
|||||||
Done
|
Done
|
||||||
</Badge>
|
</Badge>
|
||||||
) : isDraft && isVotingOpen ? (
|
) : isDraft && isVotingOpen ? (
|
||||||
<Badge className="text-xs bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-700 animate-pulse">
|
<Badge className="text-xs bg-amber-100 text-amber-800 border-amber-300 animate-pulse">
|
||||||
<Send className="mr-1 h-3 w-3" />
|
<Send className="mr-1 h-3 w-3" />
|
||||||
Ready to submit
|
Ready to submit
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -571,7 +637,7 @@ async function JuryDashboardContent() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20">
|
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
||||||
<Zap className="h-4 w-4 text-brand-teal" />
|
<Zap className="h-4 w-4 text-brand-teal" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
||||||
@@ -581,13 +647,13 @@ async function JuryDashboardContent() {
|
|||||||
<div className={`grid gap-3 ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
|
<div className={`grid gap-3 ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
|
||||||
<Link
|
<Link
|
||||||
href="/jury/competitions"
|
href="/jury/competitions"
|
||||||
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
|
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="rounded-xl bg-blue-50 p-3 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40 dark:group-hover:bg-blue-950/60">
|
<div className="rounded-xl bg-blue-50 p-3 transition-colors group-hover:bg-blue-100">
|
||||||
<ClipboardList className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
<ClipboardList className="h-5 w-5 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">All Assignments</p>
|
<p className="font-semibold text-sm group-hover:text-brand-blue transition-colors">All Assignments</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">View and manage evaluations</p>
|
<p className="text-xs text-muted-foreground mt-0.5">View and manage evaluations</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -596,7 +662,7 @@ async function JuryDashboardContent() {
|
|||||||
href="/jury/competitions"
|
href="/jury/competitions"
|
||||||
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40 dark:group-hover:bg-teal-950/60">
|
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100">
|
||||||
<GitCompare className="h-5 w-5 text-brand-teal" />
|
<GitCompare className="h-5 w-5 text-brand-teal" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
@@ -620,8 +686,8 @@ async function JuryDashboardContent() {
|
|||||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="rounded-lg bg-brand-blue/10 p-1.5 dark:bg-brand-blue/20">
|
<div className="rounded-lg bg-brand-blue/10 p-1.5">
|
||||||
<Waves className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
|
<Waves className="h-4 w-4 text-brand-blue" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg">Active Voting Stages</CardTitle>
|
<CardTitle className="text-lg">Active Voting Stages</CardTitle>
|
||||||
@@ -650,13 +716,13 @@ async function JuryDashboardContent() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||||
isUrgent
|
isUrgent
|
||||||
? 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20'
|
? 'border-red-200 bg-red-50/50'
|
||||||
: 'border-border/60 bg-muted/20 dark:bg-muted/10'
|
: 'border-border/60 bg-muted/20'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{round.name}</h3>
|
<h3 className="font-semibold text-brand-blue">{round.name}</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{program.name} · {program.year}
|
{program.name} · {program.year}
|
||||||
</p>
|
</p>
|
||||||
@@ -716,7 +782,7 @@ async function JuryDashboardContent() {
|
|||||||
<AnimatedCard index={8}>
|
<AnimatedCard index={8}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-6 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
<div className="rounded-2xl bg-brand-teal/10 p-3 mb-2 dark:bg-brand-teal/20">
|
<div className="rounded-2xl bg-brand-teal/10 p-3 mb-2">
|
||||||
<Clock className="h-6 w-6 text-brand-teal/70" />
|
<Clock className="h-6 w-6 text-brand-teal/70" />
|
||||||
</div>
|
</div>
|
||||||
<p className="font-semibold text-sm">No active voting stages</p>
|
<p className="font-semibold text-sm">No active voting stages</p>
|
||||||
@@ -734,7 +800,7 @@ async function JuryDashboardContent() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20">
|
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
||||||
<BarChart3 className="h-4 w-4 text-brand-teal" />
|
<BarChart3 className="h-4 w-4 text-brand-teal" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-lg">Round Summary</CardTitle>
|
<CardTitle className="text-lg">Round Summary</CardTitle>
|
||||||
@@ -750,7 +816,7 @@ async function JuryDashboardContent() {
|
|||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="font-medium truncate">{round.name}</span>
|
<span className="font-medium truncate">{round.name}</span>
|
||||||
<div className="flex items-baseline gap-1 shrink-0 ml-2">
|
<div className="flex items-baseline gap-1 shrink-0 ml-2">
|
||||||
<span className="font-bold tabular-nums text-brand-blue dark:text-brand-teal">{pct}%</span>
|
<span className="font-bold tabular-nums text-brand-blue">{pct}%</span>
|
||||||
<span className="text-xs text-muted-foreground">({done}/{total})</span>
|
<span className="text-xs text-muted-foreground">({done}/{total})</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -852,7 +918,7 @@ export default async function JuryDashboardPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||||
{getGreeting()}, {session?.user?.name || 'Juror'}
|
{getGreeting()}, {session?.user?.name || 'Juror'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-0.5">
|
<p className="text-muted-foreground mt-0.5">
|
||||||
@@ -863,6 +929,11 @@ export default async function JuryDashboardPage() {
|
|||||||
{/* Preferences banner (shown when juror has unconfirmed preferences) */}
|
{/* Preferences banner (shown when juror has unconfirmed preferences) */}
|
||||||
<JuryPreferencesBanner />
|
<JuryPreferencesBanner />
|
||||||
|
|
||||||
|
{/* Grand-Final finalist documents — prominent entry for finals jurors */}
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<FinalsJuryBanner />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Suspense fallback={<DashboardSkeleton />}>
|
<Suspense fallback={<DashboardSkeleton />}>
|
||||||
<JuryDashboardContent />
|
<JuryDashboardContent />
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { CountryDisplay } from '@/components/shared/country-display'
|
import { CountryDisplay } from '@/components/shared/country-display'
|
||||||
|
import { RecentMessagesCard } from '@/components/mentor/recent-messages-card'
|
||||||
|
|
||||||
// Status badge colors
|
// Status badge colors
|
||||||
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
@@ -117,6 +118,9 @@ export default function MentorDashboard() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Recent unread messages from teams */}
|
||||||
|
<RecentMessagesCard />
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<AnimatedCard index={0}>
|
<AnimatedCard index={0}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Suspense, use, useState, useEffect } from 'react'
|
import { Suspense, use, useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -31,6 +32,7 @@ import { AnimatedCard } from '@/components/shared/animated-container'
|
|||||||
import { FileViewer } from '@/components/shared/file-viewer'
|
import { FileViewer } from '@/components/shared/file-viewer'
|
||||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||||
|
import { DropAssignmentDialog } from '@/components/mentor/drop-assignment-dialog'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -76,6 +78,7 @@ const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'ou
|
|||||||
|
|
||||||
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { data: session } = useSession()
|
||||||
const { data: project, isLoading, error } = trpc.mentor.getProjectDetail.useQuery({
|
const { data: project, isLoading, error } = trpc.mentor.getProjectDetail.useQuery({
|
||||||
projectId,
|
projectId,
|
||||||
})
|
})
|
||||||
@@ -91,14 +94,18 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// TODO(PR8 Task 9): show co-mentors. For now we pick the first assignment
|
||||||
|
// to keep tracking + chat working unchanged.
|
||||||
|
const primaryAssignment = project?.mentorAssignments?.[0] ?? null
|
||||||
|
|
||||||
// Track view when project loads
|
// Track view when project loads
|
||||||
const trackView = trpc.mentor.trackView.useMutation()
|
const trackView = trpc.mentor.trackView.useMutation()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (project?.mentorAssignment?.id) {
|
if (primaryAssignment?.id) {
|
||||||
trackView.mutate({ mentorAssignmentId: project.mentorAssignment.id })
|
trackView.mutate({ mentorAssignmentId: primaryAssignment.id })
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [project?.mentorAssignment?.id])
|
}, [primaryAssignment?.id])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <ProjectDetailSkeleton />
|
return <ProjectDetailSkeleton />
|
||||||
@@ -132,8 +139,15 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
|
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
|
||||||
const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || []
|
const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || []
|
||||||
const mentorAssignmentId = project.mentorAssignment?.id
|
const mentorAssignment = primaryAssignment
|
||||||
|
const mentorAssignmentId = mentorAssignment?.id
|
||||||
const programId = project.program?.id
|
const programId = project.program?.id
|
||||||
|
const viewerIsAssignedMentor =
|
||||||
|
!!mentorAssignment && session?.user?.id === mentorAssignment.mentor?.id
|
||||||
|
const canDrop =
|
||||||
|
viewerIsAssignedMentor &&
|
||||||
|
!mentorAssignment.droppedAt &&
|
||||||
|
mentorAssignment.completionStatus !== 'completed'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -179,6 +193,12 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{canDrop && mentorAssignmentId && (
|
||||||
|
<DropAssignmentDialog
|
||||||
|
assignmentId={mentorAssignmentId}
|
||||||
|
projectTitle={project.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{project.assignedAt && (
|
{project.assignedAt && (
|
||||||
@@ -324,6 +344,25 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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 */}
|
{/* Team Lead */}
|
||||||
{teamLead && (
|
{teamLead && (
|
||||||
<div className="p-4 rounded-lg border bg-muted/30">
|
<div className="p-4 rounded-lg border bg-muted/30">
|
||||||
@@ -461,7 +500,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<MentorChat
|
<MentorChat
|
||||||
messages={mentorMessages || []}
|
messages={mentorMessages || []}
|
||||||
currentUserId={project.mentorAssignment?.mentor?.id || ''}
|
currentUserId={primaryAssignment?.mentor?.id || ''}
|
||||||
onSendMessage={async (message) => {
|
onSendMessage={async (message) => {
|
||||||
await sendMessage.mutateAsync({ projectId, message })
|
await sendMessage.mutateAsync({ projectId, message })
|
||||||
}}
|
}}
|
||||||
@@ -576,7 +615,7 @@ function MilestonesSection({
|
|||||||
<div
|
<div
|
||||||
key={milestone.id}
|
key={milestone.id}
|
||||||
className={`flex items-start gap-3 p-3 rounded-lg border transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm ${
|
className={`flex items-start gap-3 p-3 rounded-lg border transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm ${
|
||||||
isCompleted ? 'bg-green-50/50 border-green-200 dark:bg-green-950/20 dark:border-green-900' : ''
|
isCompleted ? 'bg-green-50/50 border-green-200' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
import { WorkspaceChat } from '@/components/mentor/workspace-chat'
|
import { WorkspaceChat } from '@/components/mentor/workspace-chat'
|
||||||
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
|
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
|
||||||
import { ArrowLeft, MessageSquare, FileText, Upload } from 'lucide-react'
|
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
||||||
|
import { FinalDocumentsPanel } from '@/components/applicant/final-documents-panel'
|
||||||
|
import { ArrowLeft, MessageSquare, FileText, Upload, Users } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export default function MentorWorkspaceDetailPage() {
|
export default function MentorWorkspaceDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { data: session } = useSession()
|
||||||
const projectId = params.projectId as string
|
const projectId = params.projectId as string
|
||||||
|
|
||||||
// Get mentor assignment for this project
|
// Get mentor assignment for this project
|
||||||
@@ -26,6 +36,22 @@ export default function MentorWorkspaceDetailPage() {
|
|||||||
{ enabled: !!projectId }
|
{ enabled: !!projectId }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Co-mentor visibility (PR8 multi-mentor): show who else is on the team.
|
||||||
|
// Gracefully tolerates stale tabs where the caller no longer has access
|
||||||
|
// (assignment dropped) — query just returns nothing in that case.
|
||||||
|
const { data: projectMentors } = trpc.mentor.getProjectMentors.useQuery(
|
||||||
|
{ projectId },
|
||||||
|
{ enabled: !!projectId, retry: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentUserId = session?.user?.id
|
||||||
|
const coMentors = (projectMentors ?? []).filter(
|
||||||
|
a => a.mentor.id !== currentUserId
|
||||||
|
)
|
||||||
|
const coMentorNames = coMentors.map(a => a.mentor.name ?? 'Unnamed mentor')
|
||||||
|
const visibleCoMentors = coMentorNames.slice(0, 3)
|
||||||
|
const hiddenCoMentors = coMentorNames.slice(3)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -69,6 +95,37 @@ export default function MentorWorkspaceDetailPage() {
|
|||||||
{project.teamName && (
|
{project.teamName && (
|
||||||
<p className="text-muted-foreground mt-1">{project.teamName}</p>
|
<p className="text-muted-foreground mt-1">{project.teamName}</p>
|
||||||
)}
|
)}
|
||||||
|
{coMentors.length > 0 && (
|
||||||
|
<div className="mt-2 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
You + {coMentors.length} co-mentor
|
||||||
|
{coMentors.length === 1 ? '' : 's'}:{' '}
|
||||||
|
<span className="text-foreground">
|
||||||
|
{visibleCoMentors.join(', ')}
|
||||||
|
</span>
|
||||||
|
{hiddenCoMentors.length > 0 && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="cursor-help underline decoration-dotted underline-offset-2">
|
||||||
|
+{hiddenCoMentors.length} more
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<div className="text-xs">
|
||||||
|
{hiddenCoMentors.join(', ')}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -102,25 +159,24 @@ export default function MentorWorkspaceDetailPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="files" className="mt-6">
|
<TabsContent value="files" className="mt-6">
|
||||||
|
{assignment ? (
|
||||||
|
<WorkspaceFilesPanel
|
||||||
|
projectId={projectId}
|
||||||
|
mentorAssignmentId={assignment.id}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Workspace Files</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Files shared in the mentor workspace
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="text-center py-8">
|
<CardContent className="text-center py-8">
|
||||||
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">Loading workspace…</p>
|
||||||
File listing feature coming soon
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="promotion" className="mt-6">
|
<TabsContent value="promotion" className="mt-6">
|
||||||
{assignment ? (
|
{assignment ? (
|
||||||
<FilePromotionPanel mentorAssignmentId={assignment.id} />
|
<FilePromotionPanel projectId={projectId} />
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="text-center py-8">
|
<CardContent className="text-center py-8">
|
||||||
@@ -131,6 +187,9 @@ export default function MentorWorkspaceDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Final Documents (self-hides when not a finalist) */}
|
||||||
|
<FinalDocumentsPanel variant="mentor" projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
export default async function ObserverProjectDetailPage({
|
export default async function ObserverProjectDetailPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ projectId: string }>
|
params: Promise<{ projectId: string }>
|
||||||
|
searchParams: Promise<{ round?: string }>
|
||||||
}) {
|
}) {
|
||||||
const { projectId } = await params
|
const { projectId } = await params
|
||||||
|
const sp = await searchParams
|
||||||
|
|
||||||
return <ObserverProjectDetail projectId={projectId} />
|
return <ObserverProjectDetail projectId={projectId} initialRoundId={sp.round} />
|
||||||
}
|
}
|
||||||
|
|||||||
425
src/app/(public)/finalist/confirm/[token]/page.tsx
Normal file
425
src/app/(public)/finalist/confirm/[token]/page.tsx
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
'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'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { AlertCircle, CheckCircle2, Loader2, PartyPopper, XCircle } from 'lucide-react'
|
||||||
|
import { TRPCClientError } from '@trpc/client'
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ token: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDeadline(d: Date): string {
|
||||||
|
const main = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'long',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(d)
|
||||||
|
const tzPart = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' })
|
||||||
|
.formatToParts(d)
|
||||||
|
.find((p) => p.type === 'timeZoneName')?.value
|
||||||
|
return tzPart ? `${main} (${tzPart})` : main
|
||||||
|
}
|
||||||
|
|
||||||
|
function CountdownLabel({ deadline }: { deadline: Date }) {
|
||||||
|
const [now, setNow] = useState<number>(Date.now())
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
const ms = deadline.getTime() - now
|
||||||
|
if (ms <= 0) return <span className="text-destructive font-medium">expired</span>
|
||||||
|
const totalSec = Math.floor(ms / 1000)
|
||||||
|
const hours = Math.floor(totalSec / 3600)
|
||||||
|
const minutes = Math.floor((totalSec % 3600) / 60)
|
||||||
|
const seconds = totalSec % 60
|
||||||
|
if (hours >= 24) {
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
const remHours = hours % 24
|
||||||
|
return (
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{days}d {remHours}h remaining
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:
|
||||||
|
{seconds.toString().padStart(2, '0')} remaining
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FriendlyError({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
icon: typeof AlertCircle
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="mx-auto max-w-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="text-muted-foreground h-5 w-5" />
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground">{message}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FinalistConfirmContent({ token }: { token: string }) {
|
||||||
|
const { data, isLoading, error } = trpc.finalist.getByToken.useQuery({ token }, { retry: false })
|
||||||
|
const confirmMutation = trpc.finalist.confirm.useMutation()
|
||||||
|
const declineMutation = trpc.finalist.decline.useMutation()
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const [visa, setVisa] = useState<Record<string, boolean>>({})
|
||||||
|
const [declineReason, setDeclineReason] = useState('')
|
||||||
|
const [submitState, setSubmitState] = useState<'idle' | 'confirmed' | 'declined' | 'error'>(
|
||||||
|
'idle',
|
||||||
|
)
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Default-select all team members once data arrives
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.project.teamMembers && selected.size === 0 && submitState === 'idle') {
|
||||||
|
const cap = data.project.program.defaultAttendeeCap
|
||||||
|
const initial = new Set(
|
||||||
|
data.project.teamMembers.slice(0, cap).map((tm) => tm.userId),
|
||||||
|
)
|
||||||
|
setSelected(initial)
|
||||||
|
}
|
||||||
|
}, [data, selected.size, submitState])
|
||||||
|
|
||||||
|
// ── Loading
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl space-y-4">
|
||||||
|
<Skeleton className="h-8 w-2/3" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Token errors → friendly states
|
||||||
|
if (error) {
|
||||||
|
const msg = error.message ?? ''
|
||||||
|
if (/expired/i.test(msg)) {
|
||||||
|
return (
|
||||||
|
<FriendlyError
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="This link has expired"
|
||||||
|
message="If your team still wants to attend, please contact us at info@monaco-opc.com."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (/signature|malformed|payload/i.test(msg)) {
|
||||||
|
return (
|
||||||
|
<FriendlyError
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="This link is not valid"
|
||||||
|
message="Please check your email or contact us at info@monaco-opc.com."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<FriendlyError
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="Something went wrong"
|
||||||
|
message={msg || 'Please try again or contact us at info@monaco-opc.com.'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<FriendlyError
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="Confirmation not found"
|
||||||
|
message="Please check your email link or contact us at info@monaco-opc.com."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status branches: only PENDING is interactive
|
||||||
|
if (submitState === 'confirmed' || data.status === 'CONFIRMED') {
|
||||||
|
return (
|
||||||
|
<Card className="mx-auto max-w-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PartyPopper className="text-primary h-5 w-5" />
|
||||||
|
<CardTitle>You're in!</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="mb-2">
|
||||||
|
Your team's attendance for <strong>{data.project.title}</strong> is confirmed.
|
||||||
|
</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 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (submitState === 'declined' || data.status === 'DECLINED') {
|
||||||
|
return (
|
||||||
|
<FriendlyError
|
||||||
|
icon={XCircle}
|
||||||
|
title="Your team has declined"
|
||||||
|
message="If this was a mistake, please contact us at info@monaco-opc.com."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (data.status === 'EXPIRED') {
|
||||||
|
return (
|
||||||
|
<FriendlyError
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="The confirmation deadline has passed"
|
||||||
|
message="If your team still wants to attend, please contact us at info@monaco-opc.com."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (data.status === 'SUPERSEDED') {
|
||||||
|
return (
|
||||||
|
<FriendlyError
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="This confirmation is no longer active"
|
||||||
|
message="Please contact us at info@monaco-opc.com for details."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PENDING: render the form
|
||||||
|
const cap = data.project.program.defaultAttendeeCap
|
||||||
|
const deadline = new Date(data.deadline)
|
||||||
|
const overCap = selected.size > cap
|
||||||
|
const noneSelected = selected.size === 0
|
||||||
|
|
||||||
|
const toggle = (userId: string, checked: boolean) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (checked) next.add(userId)
|
||||||
|
else next.delete(userId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const toggleVisa = (userId: string, checked: boolean) => {
|
||||||
|
setVisa((prev) => ({ ...prev, [userId]: checked }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
setSubmitError(null)
|
||||||
|
try {
|
||||||
|
await confirmMutation.mutateAsync({
|
||||||
|
token,
|
||||||
|
attendingUserIds: Array.from(selected),
|
||||||
|
visaFlags: Object.fromEntries(
|
||||||
|
Array.from(selected).map((uid) => [uid, !!visa[uid]]),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
setSubmitState('confirmed')
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitState('error')
|
||||||
|
const msg =
|
||||||
|
err instanceof TRPCClientError ? err.message : err instanceof Error ? err.message : 'Failed'
|
||||||
|
setSubmitError(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleDecline = async () => {
|
||||||
|
setSubmitError(null)
|
||||||
|
try {
|
||||||
|
await declineMutation.mutateAsync({
|
||||||
|
token,
|
||||||
|
reason: declineReason.trim() || undefined,
|
||||||
|
})
|
||||||
|
setSubmitState('declined')
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitState('error')
|
||||||
|
const msg =
|
||||||
|
err instanceof TRPCClientError ? err.message : err instanceof Error ? err.message : 'Failed'
|
||||||
|
setSubmitError(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl space-y-6">
|
||||||
|
<Card className="border-primary/40 bg-primary/5">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PartyPopper className="text-primary h-5 w-5" />
|
||||||
|
<CardTitle>Congratulations!</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<p>
|
||||||
|
Your project <strong>{data.project.title}</strong> is a finalist for the Monaco Ocean
|
||||||
|
Protection Challenge grand finale.
|
||||||
|
</p>
|
||||||
|
<div className="bg-background border-amber-300 mt-3 rounded-md border-l-4 p-3">
|
||||||
|
<p className="text-sm">
|
||||||
|
<strong>Confirm by {formatDeadline(deadline)}.</strong>
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
<CountdownLabel deadline={deadline} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Who from your team will attend?</CardTitle>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
You can select up to <strong>{cap}</strong> team members. Indicate who needs visa
|
||||||
|
support so we can prepare documents in time.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{data.project.teamMembers.map((tm) => {
|
||||||
|
const checked = selected.has(tm.userId)
|
||||||
|
return (
|
||||||
|
<li key={tm.userId} className="flex items-start justify-between gap-4">
|
||||||
|
<label className="flex flex-1 items-start gap-3 cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(c) => toggle(tm.userId, c === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{tm.user.name ?? tm.user.email}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{tm.user.email}
|
||||||
|
{tm.role && tm.role !== 'MEMBER' ? ` · ${tm.role.toLowerCase()}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{checked && (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">Needs visa?</span>
|
||||||
|
<Switch
|
||||||
|
checked={!!visa[tm.userId]}
|
||||||
|
onCheckedChange={(c) => toggleVisa(tm.userId, c)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{overCap && (
|
||||||
|
<p className="text-destructive mt-3 text-sm">
|
||||||
|
Please select no more than {cap} member{cap === 1 ? '' : 's'}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{submitError && (
|
||||||
|
<div className="border-destructive bg-destructive/10 text-destructive rounded-md border p-3 text-sm">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" className="text-muted-foreground">
|
||||||
|
We can't attend
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Decline finalist slot?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
If your team can't attend, we'll offer the slot to a waitlisted team. This
|
||||||
|
action can't be undone from this page.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-muted-foreground text-sm" htmlFor="decline-reason">
|
||||||
|
Reason (optional, helps us improve future editions)
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="decline-reason"
|
||||||
|
value={declineReason}
|
||||||
|
onChange={(e) => setDeclineReason(e.target.value)}
|
||||||
|
placeholder="e.g. logistics conflict, team disbanded, no longer interested"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDecline}
|
||||||
|
disabled={declineMutation.isPending}
|
||||||
|
className="bg-destructive hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{declineMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Decline finalist slot'
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={overCap || noneSelected || confirmMutation.isPending}
|
||||||
|
>
|
||||||
|
{confirmMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Confirming…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
Confirm Attendance
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FinalistConfirmPage({ params }: PageProps) {
|
||||||
|
const { token } = use(params)
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
|
||||||
|
<FinalistConfirmContent token={token} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
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';
|
* Audience voting page — reached by scanning the QR code on the big screen.
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
* Zero-instruction flow: scan → (auto token) → wait → tap your favorite →
|
||||||
import { AudienceVoteCard } from '@/components/public/audience-vote-card';
|
* done. Votes can be changed until the window closes. Uses ONLY public
|
||||||
import { toast } from 'sonner';
|
* procedures: attendees have no account.
|
||||||
|
*/
|
||||||
|
|
||||||
export default function AudienceVotePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
|
import { use, useEffect, useState } from 'react'
|
||||||
const params = use(paramsPromise);
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
const utils = trpc.useUtils();
|
import { trpc } from '@/lib/trpc/client'
|
||||||
const [hasVoted, setHasVoted] = useState(false);
|
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 WINDOW_TITLE: Record<string, string> = {
|
||||||
|
'CATEGORY:BUSINESS_CONCEPT': 'Pick your favorite Business Concept',
|
||||||
const submitVoteMutation = trpc.liveVoting.castAudienceVote.useMutation({
|
'CATEGORY:STARTUP': 'Pick your favorite Startup',
|
||||||
onSuccess: () => {
|
OVERALL: 'Pick your favorite project of the night',
|
||||||
setHasVoted(true);
|
|
||||||
// Store in localStorage to prevent duplicate votes
|
|
||||||
if (cursor?.activeProject?.id) {
|
|
||||||
localStorage.setItem(`voted-${params.roundId}-${cursor.activeProject.id}`, 'true');
|
|
||||||
}
|
}
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (cursor?.activeProject?.id) {
|
const id = setInterval(() => tick((t) => t + 1), 1000)
|
||||||
const voted = localStorage.getItem(`voted-${params.roundId}-${cursor.activeProject.id}`);
|
return () => clearInterval(id)
|
||||||
if (voted === 'true') {
|
}, [])
|
||||||
setHasVoted(true);
|
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 = () => {
|
const { data: win } = trpc.liveVoting.getAudienceWindow.useQuery(
|
||||||
if (!cursor?.activeProject?.id) return;
|
{ sessionId: sessionId ?? '', token: token ?? undefined },
|
||||||
|
{ enabled: !!sessionId, refetchInterval: 3000 }
|
||||||
|
)
|
||||||
|
|
||||||
submitVoteMutation.mutate({
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
projectId: cursor.activeProject.id,
|
const cast = trpc.liveVoting.castFavoriteVote.useMutation({
|
||||||
sessionId: params.roundId,
|
onSuccess: () => {
|
||||||
score: 1,
|
utils.liveVoting.getAudienceWindow.invalidate()
|
||||||
token: `audience-${Date.now()}`
|
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 (
|
return (
|
||||||
<div className="container mx-auto flex min-h-screen items-center justify-center p-4">
|
<CenteredState
|
||||||
<Card className="w-full max-w-2xl">
|
icon={Vote}
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
title="No vote here yet"
|
||||||
<p className="text-center text-lg text-muted-foreground">
|
subtitle="This voting link isn't active. Keep an eye on the big screen!"
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
}
|
||||||
Live voting in progress
|
if (!context.allowAudienceVotes) {
|
||||||
</p>
|
return (
|
||||||
</div>
|
<CenteredState
|
||||||
</div>
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ export default function ProfileSettingsPage() {
|
|||||||
const handleSaveProfile = async () => {
|
const handleSaveProfile = async () => {
|
||||||
try {
|
try {
|
||||||
await updateProfile.mutateAsync({
|
await updateProfile.mutateAsync({
|
||||||
email: email || undefined,
|
|
||||||
name: name || undefined,
|
name: name || undefined,
|
||||||
bio,
|
bio,
|
||||||
phoneNumber: phoneNumber || null,
|
phoneNumber: phoneNumber || null,
|
||||||
@@ -229,11 +228,13 @@ export default function ProfileSettingsPage() {
|
|||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
readOnly
|
||||||
|
disabled
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
This will be used for login and all notification emails.
|
Used for login and notifications. Contact an administrator to
|
||||||
|
change your email address.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app/api/cron/finalist-confirmations/route.ts
Normal file
23
src/app/api/cron/finalist-confirmations/route.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import {
|
||||||
|
expirePendingPastDeadline,
|
||||||
|
sendDueConfirmationReminders,
|
||||||
|
} from '@/server/services/finalist-confirmation'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||||
|
const cronSecret = request.headers.get('x-cron-secret')
|
||||||
|
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/app/api/cron/lunch-recap/route.ts
Normal file
69
src/app/api/cron/lunch-recap/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { sendLunchRecapEmail } from '@/lib/email'
|
||||||
|
import { buildRecapPayload } from '@/server/services/lunch-recap'
|
||||||
|
import { logAudit } from '@/server/utils/audit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron: when a lunch event is past its change deadline and admins have
|
||||||
|
* left auto-recap on (cronEnabled), send the recap to admins +
|
||||||
|
* extraRecipients and stamp recapSentAt. Idempotent.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||||
|
const cronSecret = request.headers.get('x-cron-secret')
|
||||||
|
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
const now = new Date()
|
||||||
|
const events = await prisma.lunchEvent.findMany({
|
||||||
|
where: {
|
||||||
|
enabled: true,
|
||||||
|
cronEnabled: true,
|
||||||
|
recapSentAt: null,
|
||||||
|
eventAt: { not: null },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
let sent = 0
|
||||||
|
for (const event of events) {
|
||||||
|
try {
|
||||||
|
if (!event.eventAt) continue
|
||||||
|
const deadline = new Date(
|
||||||
|
event.eventAt.getTime() - event.changeCutoffHours * 3_600_000,
|
||||||
|
)
|
||||||
|
if (now < deadline) continue
|
||||||
|
const payload = await buildRecapPayload(prisma, event.programId)
|
||||||
|
const adminUsers = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
|
||||||
|
email: { not: '' },
|
||||||
|
},
|
||||||
|
select: { email: true },
|
||||||
|
})
|
||||||
|
const recipients = [
|
||||||
|
...adminUsers.map((u) => u.email).filter(Boolean),
|
||||||
|
...event.extraRecipients,
|
||||||
|
]
|
||||||
|
try {
|
||||||
|
await sendLunchRecapEmail(recipients, payload)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[lunch-recap] email send failed', event.id, e)
|
||||||
|
}
|
||||||
|
await prisma.lunchEvent.update({
|
||||||
|
where: { id: event.id },
|
||||||
|
data: { recapSentAt: new Date() },
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma,
|
||||||
|
userId: null,
|
||||||
|
action: 'LUNCH_RECAP_SENT',
|
||||||
|
entityType: 'LunchEvent',
|
||||||
|
entityId: event.id,
|
||||||
|
detailsJson: { recipientCount: recipients.length, source: 'cron' },
|
||||||
|
})
|
||||||
|
sent++
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[lunch-recap] event failed', event.id, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true, sent, processedEvents: events.length })
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user