Compare commits
163 Commits
f1955b68f9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
9cb3b9de13 | ||
|
|
fd4f6dde16 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -62,3 +62,4 @@ build-output.txt
|
||||
# Private keys and secrets
|
||||
private/
|
||||
public/build-id.json
|
||||
.remember/
|
||||
|
||||
@@ -6,15 +6,38 @@ MIGRATION_RETRY_DELAY_SECONDS="${MIGRATION_RETRY_DELAY_SECONDS:-2}"
|
||||
ATTEMPT=1
|
||||
|
||||
# Auto-resolve any previously failed migrations so deploy can proceed.
|
||||
# This handles the case where a migration partially applied and was fixed
|
||||
# in a subsequent deploy — without this, Prisma refuses to run anything.
|
||||
# This handles the case where a migration failed mid-flight and was then
|
||||
# fixed in a subsequent deploy — without this, Prisma refuses to run
|
||||
# anything else (P3009).
|
||||
#
|
||||
# We query `_prisma_migrations` directly rather than parsing the output of
|
||||
# `prisma migrate status`, because that output's wording has shifted between
|
||||
# Prisma versions and any drift means failed migrations slip through and
|
||||
# the container crash-loops. Truth lives in the table: a row with
|
||||
# `finished_at IS NULL AND rolled_back_at IS NULL` is an unresolved failure.
|
||||
echo "==> Checking for failed migrations..."
|
||||
MIGRATE_STATUS=$(npx prisma migrate status 2>&1 || true)
|
||||
FAILED=$(echo "$MIGRATE_STATUS" | sed -n 's/.*The `\([^`]*\)` migration.*failed.*/\1/p' | head -1)
|
||||
if [ -n "$FAILED" ]; then
|
||||
RESOLVE_ATTEMPTS=0
|
||||
while [ "$RESOLVE_ATTEMPTS" -lt 5 ]; do
|
||||
FAILED=$(node -e "
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const p = new PrismaClient();
|
||||
p.\$queryRaw\`
|
||||
SELECT migration_name FROM _prisma_migrations
|
||||
WHERE finished_at IS NULL AND rolled_back_at IS NULL
|
||||
ORDER BY started_at ASC LIMIT 1
|
||||
\`.then(r => { console.log(r[0]?.migration_name || ''); p.\$disconnect(); })
|
||||
.catch(() => { console.log(''); p.\$disconnect(); });
|
||||
" 2>/dev/null || echo "")
|
||||
if [ -z "$FAILED" ]; then
|
||||
break
|
||||
fi
|
||||
echo "==> Found failed migration: $FAILED — marking as rolled back..."
|
||||
npx prisma migrate resolve --rolled-back "$FAILED"
|
||||
fi
|
||||
npx prisma migrate resolve --rolled-back "$FAILED" || {
|
||||
echo "WARNING: prisma migrate resolve failed for $FAILED"
|
||||
break
|
||||
}
|
||||
RESOLVE_ATTEMPTS=$((RESOLVE_ATTEMPTS + 1))
|
||||
done
|
||||
|
||||
echo "==> Running database migrations (with retry)..."
|
||||
until npx prisma migrate deploy; do
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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
@@ -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.
|
||||
15
package-lock.json
generated
15
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mopc-platform",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mopc-platform",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@auth/prisma-adapter": "^2.7.4",
|
||||
@@ -61,7 +61,6 @@
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.1.0",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^7.0.7",
|
||||
"openai": "^6.16.0",
|
||||
"papaparse": "^5.4.1",
|
||||
@@ -12143,16 +12142,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-themes": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mopc-platform",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -75,7 +75,6 @@
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.1.0",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^7.0.7",
|
||||
"openai": "^6.16.0",
|
||||
"papaparse": "^5.4.1",
|
||||
|
||||
@@ -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);
|
||||
@@ -29,7 +29,6 @@ enum UserRole {
|
||||
MENTOR
|
||||
OBSERVER
|
||||
APPLICANT
|
||||
AWARD_MASTER
|
||||
AUDIENCE
|
||||
}
|
||||
|
||||
@@ -119,7 +118,6 @@ enum NotificationChannel {
|
||||
NONE
|
||||
}
|
||||
|
||||
|
||||
enum PartnerVisibility {
|
||||
ADMIN_ONLY
|
||||
JURY_VISIBLE
|
||||
@@ -134,7 +132,6 @@ enum PartnerType {
|
||||
OTHER
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// COMPETITION / ROUND ENGINE ENUMS
|
||||
// =============================================================================
|
||||
@@ -172,7 +169,6 @@ enum ProjectRoundStateValue {
|
||||
WITHDRAWN
|
||||
}
|
||||
|
||||
|
||||
enum CapMode {
|
||||
HARD
|
||||
SOFT
|
||||
@@ -302,6 +298,9 @@ model User {
|
||||
institution String? // User's institution/organization
|
||||
metadataJson Json? @db.JsonB
|
||||
|
||||
// Mentor onboarding email idempotency: stamped once when MENTOR role is first added.
|
||||
mentorOnboardingSentAt DateTime?
|
||||
|
||||
// Profile
|
||||
bio String? // User bio for matching with project descriptions
|
||||
profileImageKey String? // Storage key (e.g., "avatars/user123/1234567890.jpg")
|
||||
@@ -326,8 +325,8 @@ model User {
|
||||
inviteTokenExpiresAt DateTime?
|
||||
|
||||
// Password reset token
|
||||
passwordResetToken String? @unique
|
||||
passwordResetExpiresAt DateTime?
|
||||
passwordResetToken String? @unique
|
||||
passwordResetExpiresAt DateTime?
|
||||
|
||||
// Digest & availability preferences
|
||||
digestFrequency String @default("none") // 'none' | 'daily' | 'weekly'
|
||||
@@ -361,9 +360,9 @@ model User {
|
||||
filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy")
|
||||
|
||||
// Award overrides
|
||||
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
|
||||
awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer")
|
||||
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
|
||||
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
|
||||
awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer")
|
||||
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
|
||||
|
||||
// In-app notifications
|
||||
notifications InAppNotification[] @relation("UserNotifications")
|
||||
@@ -411,17 +410,24 @@ model User {
|
||||
sessions Session[]
|
||||
|
||||
// ── Competition/Round architecture relations ──
|
||||
juryGroupMemberships JuryGroupMember[]
|
||||
mentorFilesUploaded MentorFile[] @relation("MentorFileUploader")
|
||||
mentorFilesPromoted MentorFile[] @relation("MentorFilePromoter")
|
||||
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
|
||||
resultLocksCreated ResultLock[] @relation("ResultLockCreator")
|
||||
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
|
||||
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
|
||||
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
|
||||
juryGroupMemberships JuryGroupMember[]
|
||||
mentorFilesUploaded MentorFile[] @relation("MentorFileUploader")
|
||||
mentorFilesPromoted MentorFile[] @relation("MentorFilePromoter")
|
||||
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
|
||||
resultLocksCreated ResultLock[] @relation("ResultLockCreator")
|
||||
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
|
||||
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
|
||||
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
|
||||
|
||||
// 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([status])
|
||||
@@ -480,6 +486,10 @@ model Program {
|
||||
description String?
|
||||
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())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -493,6 +503,12 @@ model Program {
|
||||
mentorMilestones MentorMilestone[]
|
||||
competitions Competition[]
|
||||
|
||||
// Grand-finale logistics
|
||||
finalistSlotQuotas FinalistSlotQuota[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
hotel Hotel?
|
||||
lunchEvent LunchEvent?
|
||||
|
||||
@@unique([name, year])
|
||||
@@index([status])
|
||||
}
|
||||
@@ -614,7 +630,9 @@ model Project {
|
||||
assignments Assignment[]
|
||||
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
|
||||
teamMembers TeamMember[]
|
||||
mentorAssignment MentorAssignment?
|
||||
mentorAssignments MentorAssignment[]
|
||||
mentorFiles MentorFile[]
|
||||
mentorChangeRequests MentorChangeRequest[]
|
||||
filteringResults FilteringResult[]
|
||||
awardEligibilities AwardEligibility[]
|
||||
awardVotes AwardVote[]
|
||||
@@ -627,12 +645,17 @@ model Project {
|
||||
cohortProjects CohortProject[]
|
||||
|
||||
// ── Competition/Round architecture relations ──
|
||||
projectRoundStates ProjectRoundState[]
|
||||
assignmentIntents AssignmentIntent[]
|
||||
deliberationVotes DeliberationVote[]
|
||||
deliberationResults DeliberationResult[]
|
||||
submissionPromotions SubmissionPromotionEvent[]
|
||||
notificationLogs NotificationLog[]
|
||||
projectRoundStates ProjectRoundState[]
|
||||
assignmentIntents AssignmentIntent[]
|
||||
deliberationVotes DeliberationVote[]
|
||||
deliberationResults DeliberationResult[]
|
||||
submissionPromotions SubmissionPromotionEvent[]
|
||||
notificationLogs NotificationLog[]
|
||||
|
||||
// Grand-finale logistics
|
||||
waitlistEntry WaitlistEntry?
|
||||
finalistConfirmation FinalistConfirmation?
|
||||
externalLunchAttendees ExternalAttendee[]
|
||||
|
||||
@@index([programId])
|
||||
@@index([status])
|
||||
@@ -679,9 +702,9 @@ model ProjectFile {
|
||||
|
||||
// Document analysis (optional, populated by document-analyzer service)
|
||||
textPreview String? @db.Text // First ~2000 chars of extracted text
|
||||
detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und')
|
||||
langConfidence Float? // 0.0–1.0 confidence
|
||||
analyzedAt DateTime? // When analysis last ran
|
||||
detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und')
|
||||
langConfidence Float? // 0.0–1.0 confidence
|
||||
analyzedAt DateTime? // When analysis last ran
|
||||
|
||||
// MinIO location
|
||||
bucket String
|
||||
@@ -694,7 +717,7 @@ model ProjectFile {
|
||||
replacedById String? // FK to the newer file that replaced this one
|
||||
|
||||
// ── Competition/Round architecture fields ──
|
||||
submissionWindowId String? // FK to SubmissionWindow
|
||||
submissionWindowId String? // FK to SubmissionWindow
|
||||
submissionFileRequirementId String? // FK to SubmissionFileRequirement
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
@@ -743,10 +766,10 @@ model Assignment {
|
||||
juryGroupId String?
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||
evaluation Evaluation?
|
||||
conflictOfInterest ConflictOfInterest?
|
||||
|
||||
@@ -1006,12 +1029,12 @@ model NotificationEmailSetting {
|
||||
// =============================================================================
|
||||
|
||||
model LearningResource {
|
||||
id String @id @default(cuid())
|
||||
programId String? // null = global resource
|
||||
title String
|
||||
description String? @db.Text
|
||||
contentJson Json? @db.JsonB // BlockNote document structure
|
||||
accessJson Json? @db.JsonB // Fine-grained access rules
|
||||
id String @id @default(cuid())
|
||||
programId String? // null = global resource
|
||||
title String
|
||||
description String? @db.Text
|
||||
contentJson Json? @db.JsonB // BlockNote document structure
|
||||
accessJson Json? @db.JsonB // Fine-grained access rules
|
||||
|
||||
// File storage (for uploaded resources)
|
||||
fileName String?
|
||||
@@ -1250,7 +1273,7 @@ model TeamMember {
|
||||
|
||||
model MentorAssignment {
|
||||
id String @id @default(cuid())
|
||||
projectId String @unique // One mentor per project
|
||||
projectId String // Team can have multiple mentors; uniqueness enforced via composite below
|
||||
mentorId String // User with MENTOR role or expertise
|
||||
|
||||
// Assignment tracking
|
||||
@@ -1258,6 +1281,16 @@ model MentorAssignment {
|
||||
assignedAt DateTime @default(now())
|
||||
assignedBy String? // Admin who assigned
|
||||
|
||||
// Per-assignment email idempotency: stamped once the MENTOR-side notification
|
||||
// email has been sent (the "you've been assigned a project" email to the mentor).
|
||||
notificationSentAt DateTime?
|
||||
|
||||
// Stamped once the TEAM has been introduced to this mentor (the "meet your
|
||||
// mentor" email with mentor contact info). Fired by `activateRound` for
|
||||
// MENTORING rounds and by mentor.assign when the project's MENTORING round
|
||||
// is already ROUND_ACTIVE. Independent from notificationSentAt above.
|
||||
teamIntroducedAt DateTime?
|
||||
|
||||
// AI assignment metadata
|
||||
aiConfidenceScore Float?
|
||||
expertiseMatchScore Float?
|
||||
@@ -1267,6 +1300,11 @@ model MentorAssignment {
|
||||
completionStatus String @default("in_progress") // 'in_progress' | 'completed' | 'paused'
|
||||
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 ──
|
||||
workspaceEnabled Boolean @default(false)
|
||||
workspaceOpenAt DateTime?
|
||||
@@ -1279,11 +1317,47 @@ model MentorAssignment {
|
||||
milestoneCompletions MentorMilestoneCompletion[]
|
||||
messages MentorMessage[]
|
||||
files MentorFile[]
|
||||
changeRequests MentorChangeRequest[] @relation("MentorChangeRequestTarget")
|
||||
|
||||
@@unique([projectId, mentorId])
|
||||
@@index([projectId])
|
||||
@@index([mentorId])
|
||||
@@index([method])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MENTOR CHANGE REQUESTS
|
||||
// =============================================================================
|
||||
|
||||
enum MentorChangeRequestStatus {
|
||||
PENDING
|
||||
RESOLVED
|
||||
DISMISSED
|
||||
}
|
||||
|
||||
model MentorChangeRequest {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
targetAssignmentId String? // Optional: a specific co-mentor the request is about
|
||||
requestedByUserId String?
|
||||
reason String @db.Text
|
||||
status MentorChangeRequestStatus @default(PENDING)
|
||||
resolvedByUserId String?
|
||||
resolvedAt DateTime?
|
||||
resolutionNote String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
targetAssignment MentorAssignment? @relation("MentorChangeRequestTarget", fields: [targetAssignmentId], references: [id], onDelete: SetNull)
|
||||
requestedBy User? @relation("MentorChangeRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
|
||||
resolvedBy User? @relation("MentorChangeResolver", fields: [resolvedByUserId], references: [id])
|
||||
|
||||
@@index([projectId])
|
||||
@@index([status])
|
||||
@@index([targetAssignmentId])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FILTERING ROUND SYSTEM
|
||||
// =============================================================================
|
||||
@@ -1418,17 +1492,17 @@ enum AssignmentJobStatus {
|
||||
// =============================================================================
|
||||
|
||||
enum RankingTriggerType {
|
||||
MANUAL // Admin clicked "Run ranking"
|
||||
AUTO // Auto-triggered by assignment completion
|
||||
MANUAL // Admin clicked "Run ranking"
|
||||
AUTO // Auto-triggered by assignment completion
|
||||
RETROACTIVE // Retroactive scan on deployment
|
||||
QUICK // Quick-rank mode (no preview)
|
||||
QUICK // Quick-rank mode (no preview)
|
||||
}
|
||||
|
||||
enum RankingMode {
|
||||
PREVIEW // Parsed rules shown to admin (not yet applied)
|
||||
PREVIEW // Parsed rules shown to admin (not yet applied)
|
||||
CONFIRMED // Admin confirmed rules, ranking applied
|
||||
QUICK // Quick-rank: parse + apply without preview
|
||||
FORMULA // Formula-only: no LLM, pure math ranking
|
||||
QUICK // Quick-rank: parse + apply without preview
|
||||
FORMULA // Formula-only: no LLM, pure math ranking
|
||||
}
|
||||
|
||||
enum RankingSnapshotStatus {
|
||||
@@ -1445,7 +1519,7 @@ model RankingSnapshot {
|
||||
roundId String
|
||||
|
||||
// Trigger metadata
|
||||
triggeredById String? // null = auto-triggered
|
||||
triggeredById String? // null = auto-triggered
|
||||
triggerType RankingTriggerType @default(MANUAL)
|
||||
|
||||
// Criteria used
|
||||
@@ -1574,7 +1648,7 @@ model SpecialAward {
|
||||
evaluationRoundId String?
|
||||
juryGroupId String?
|
||||
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
|
||||
decisionMode String? // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION"
|
||||
decisionMode String? // "JURY_VOTE" | "ADMIN_DECISION"
|
||||
shortlistSize Int @default(10)
|
||||
|
||||
// Eligibility job tracking
|
||||
@@ -1596,10 +1670,10 @@ model SpecialAward {
|
||||
votes AwardVote[]
|
||||
|
||||
// Competition/Round architecture relations
|
||||
competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull)
|
||||
evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull)
|
||||
awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||
rounds Round[] @relation("AwardRounds")
|
||||
competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull)
|
||||
evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull)
|
||||
awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||
rounds Round[] @relation("AwardRounds")
|
||||
|
||||
@@index([programId])
|
||||
@@index([status])
|
||||
@@ -1663,12 +1737,12 @@ model AwardJuror {
|
||||
}
|
||||
|
||||
model AwardVote {
|
||||
id String @id @default(cuid())
|
||||
awardId String
|
||||
userId String
|
||||
projectId String
|
||||
rank Int? // For RANKED mode
|
||||
justification String? @db.Text
|
||||
id String @id @default(cuid())
|
||||
awardId String
|
||||
userId String
|
||||
projectId String
|
||||
rank Int? // For RANKED mode
|
||||
justification String? @db.Text
|
||||
votedAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
@@ -1785,7 +1859,7 @@ model MentorMessage {
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// ── Competition/Round architecture fields ──
|
||||
workspaceId String? // FK to MentorAssignment (used as workspace)
|
||||
workspaceId String? // FK to MentorAssignment (used as workspace)
|
||||
senderRole MentorMessageRole?
|
||||
|
||||
// Relations
|
||||
@@ -2121,9 +2195,9 @@ model Competition {
|
||||
status CompetitionStatus @default(DRAFT)
|
||||
|
||||
// Competition-wide settings
|
||||
categoryMode String @default("SHARED")
|
||||
startupFinalistCount Int @default(3)
|
||||
conceptFinalistCount Int @default(3)
|
||||
categoryMode String @default("SHARED")
|
||||
startupFinalistCount Int @default(3)
|
||||
conceptFinalistCount Int @default(3)
|
||||
|
||||
// Notification preferences
|
||||
notifyOnRoundAdvance Boolean @default(true)
|
||||
@@ -2134,7 +2208,7 @@ model Competition {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||
rounds Round[]
|
||||
juryGroups JuryGroup[]
|
||||
submissionWindows SubmissionWindow[]
|
||||
@@ -2179,10 +2253,10 @@ model Round {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
|
||||
specialAward SpecialAward? @relation("AwardRounds", fields: [specialAwardId], references: [id], onDelete: SetNull)
|
||||
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
|
||||
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
|
||||
specialAward SpecialAward? @relation("AwardRounds", fields: [specialAwardId], references: [id], onDelete: SetNull)
|
||||
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
|
||||
projectRoundStates ProjectRoundState[]
|
||||
visibleSubmissionWindows RoundSubmissionVisibility[]
|
||||
assignmentIntents AssignmentIntent[]
|
||||
@@ -2201,7 +2275,7 @@ model Round {
|
||||
filteringResults FilteringResult[]
|
||||
filteringJobs FilteringJob[]
|
||||
assignmentJobs AssignmentJob[]
|
||||
rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots")
|
||||
rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots")
|
||||
reminderLogs ReminderLog[]
|
||||
evaluationSummaries EvaluationSummary[]
|
||||
evaluationDiscussions EvaluationDiscussion[]
|
||||
@@ -2247,7 +2321,7 @@ model ProjectRoundState {
|
||||
// =============================================================================
|
||||
|
||||
model JuryGroup {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
competitionId String
|
||||
name String
|
||||
slug String
|
||||
@@ -2305,8 +2379,8 @@ model JuryGroupMember {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
juryGroup JuryGroup @relation(fields: [juryGroupId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
juryGroup JuryGroup @relation(fields: [juryGroupId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
assignmentIntents AssignmentIntent[]
|
||||
deliberationVotes DeliberationVote[]
|
||||
deliberationParticipations DeliberationParticipant[]
|
||||
@@ -2344,7 +2418,7 @@ model SubmissionWindow {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
|
||||
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
|
||||
fileRequirements SubmissionFileRequirement[]
|
||||
projectFiles ProjectFile[]
|
||||
rounds Round[]
|
||||
@@ -2378,7 +2452,7 @@ model SubmissionFileRequirement {
|
||||
}
|
||||
|
||||
model RoundSubmissionVisibility {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
roundId String
|
||||
submissionWindowId String
|
||||
canView Boolean @default(true)
|
||||
@@ -2423,8 +2497,9 @@ model AssignmentIntent {
|
||||
// =============================================================================
|
||||
|
||||
model MentorFile {
|
||||
id String @id @default(cuid())
|
||||
mentorAssignmentId String
|
||||
id String @id @default(cuid())
|
||||
projectId String // Primary access scope: files belong to the team
|
||||
mentorAssignmentId String? // Nullable audit FK: which assignment uploaded; survives mentor drop
|
||||
uploadedByUserId String
|
||||
|
||||
fileName String
|
||||
@@ -2443,13 +2518,15 @@ model MentorFile {
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade)
|
||||
uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id])
|
||||
promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
|
||||
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
mentorAssignment MentorAssignment? @relation(fields: [mentorAssignmentId], references: [id], onDelete: SetNull)
|
||||
uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id])
|
||||
promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
|
||||
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
|
||||
comments MentorFileComment[]
|
||||
promotionEvents SubmissionPromotionEvent[]
|
||||
|
||||
@@index([projectId])
|
||||
@@index([mentorAssignmentId])
|
||||
@@index([uploadedByUserId])
|
||||
}
|
||||
@@ -2467,9 +2544,9 @@ model MentorFileComment {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
mentorFile MentorFile @relation(fields: [mentorFileId], references: [id], onDelete: Cascade)
|
||||
author User @relation("MentorFileCommentAuthor", fields: [authorId], references: [id])
|
||||
parentComment MentorFileComment? @relation("CommentThread", fields: [parentCommentId], references: [id], onDelete: Cascade)
|
||||
mentorFile MentorFile @relation(fields: [mentorFileId], references: [id], onDelete: Cascade)
|
||||
author User @relation("MentorFileCommentAuthor", fields: [authorId], references: [id])
|
||||
parentComment MentorFileComment? @relation("CommentThread", fields: [parentCommentId], references: [id], onDelete: Cascade)
|
||||
replies MentorFileComment[] @relation("CommentThread")
|
||||
|
||||
@@index([mentorFileId])
|
||||
@@ -2478,14 +2555,14 @@ model MentorFileComment {
|
||||
}
|
||||
|
||||
model SubmissionPromotionEvent {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
roundId String
|
||||
slotKey String
|
||||
sourceType SubmissionPromotionSource
|
||||
sourceFileId String?
|
||||
promotedById String
|
||||
createdAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
@@ -2623,3 +2700,273 @@ model ResultUnlockEvent {
|
||||
@@index([resultLockId])
|
||||
@@index([unlockedById])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Grand-finale logistics (PR 1: finalist confirmation flow)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum WaitlistEntryStatus {
|
||||
WAITING // available for promotion
|
||||
PROMOTED // moved into a finalist slot
|
||||
USED // promoted and confirmation flow completed (declined or accepted)
|
||||
}
|
||||
|
||||
enum FinalistConfirmationStatus {
|
||||
PENDING // sent, awaiting team response
|
||||
CONFIRMED // team accepted, attendees selected
|
||||
DECLINED // team explicitly declined
|
||||
EXPIRED // deadline passed without response
|
||||
SUPERSEDED // admin manually overrode (e.g. unconfirmed to allow quota decrease)
|
||||
}
|
||||
|
||||
model FinalistSlotQuota {
|
||||
id String @id @default(cuid())
|
||||
programId String
|
||||
category CompetitionCategory
|
||||
quota Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([programId, category])
|
||||
@@index([programId])
|
||||
}
|
||||
|
||||
model WaitlistEntry {
|
||||
id String @id @default(cuid())
|
||||
programId String
|
||||
projectId String @unique
|
||||
category CompetitionCategory
|
||||
rank Int
|
||||
status WaitlistEntryStatus @default(WAITING)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([programId, category, rank])
|
||||
@@index([programId, category, status])
|
||||
}
|
||||
|
||||
model FinalistConfirmation {
|
||||
id String @id @default(cuid())
|
||||
projectId String @unique
|
||||
category CompetitionCategory
|
||||
status FinalistConfirmationStatus @default(PENDING)
|
||||
deadline DateTime
|
||||
token String @unique
|
||||
confirmedAt DateTime?
|
||||
declinedAt DateTime?
|
||||
declineReason String? // optional free-text on decline
|
||||
expiredAt DateTime?
|
||||
promotedFromWaitlistEntryId String? @unique // null for original finalists
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
attendingMembers AttendingMember[]
|
||||
|
||||
@@index([status, deadline]) // for cron scan
|
||||
@@index([category, status])
|
||||
}
|
||||
|
||||
model AttendingMember {
|
||||
id String @id @default(cuid())
|
||||
confirmationId String
|
||||
userId String // must be a TeamMember of the same project (validated at write time)
|
||||
needsVisa Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
confirmation FinalistConfirmation @relation(fields: [confirmationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
flightDetail FlightDetail?
|
||||
visaApplication VisaApplication?
|
||||
lunchPick MemberLunchPick?
|
||||
|
||||
@@unique([confirmationId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Grand-finale logistics (PR 2: hotels + flight tracking)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum FlightDetailStatus {
|
||||
PENDING // team submitted details, admin not yet reviewed
|
||||
CONFIRMED // admin verified booking
|
||||
}
|
||||
|
||||
model Hotel {
|
||||
id String @id @default(cuid())
|
||||
programId String @unique // 1:1 — one hotel per edition
|
||||
name String
|
||||
address String? @db.Text
|
||||
link String? // external URL to hotel page / booking confirmation
|
||||
notes String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model FlightDetail {
|
||||
id String @id @default(cuid())
|
||||
attendingMemberId String @unique // 1:1
|
||||
arrivalAt DateTime?
|
||||
arrivalFlightNumber String?
|
||||
arrivalAirport String?
|
||||
departureAt DateTime?
|
||||
departureFlightNumber String?
|
||||
departureAirport String?
|
||||
status FlightDetailStatus @default(PENDING)
|
||||
adminNotes String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Grand-finale visa tracking (PR 4)
|
||||
// Process metadata only — no document storage. Passport scans / invitation
|
||||
// letters / decision documents are exchanged over email; this model just
|
||||
// records what stage the application is at, key dates, and free-text notes.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum VisaStatus {
|
||||
NOT_NEEDED
|
||||
REQUESTED
|
||||
INVITATION_SENT
|
||||
APPOINTMENT_BOOKED
|
||||
GRANTED
|
||||
DENIED
|
||||
}
|
||||
|
||||
model VisaApplication {
|
||||
id String @id @default(cuid())
|
||||
attendingMemberId String @unique // 1:1
|
||||
status VisaStatus @default(REQUESTED)
|
||||
nationality String? // self-declared, optional
|
||||
invitationSentAt DateTime?
|
||||
appointmentAt DateTime?
|
||||
decisionAt DateTime? // GRANTED or DENIED date
|
||||
notes String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Grand-finale lunch event (PR 6)
|
||||
// Single configurable lunch event per edition. Each attending member has a
|
||||
// 1:1 MemberLunchPick (auto-created via lunch-pick-sync). External attendees
|
||||
// can be standalone or attached to a finalist project. Allergens use the
|
||||
// EU 14 regulated list; dishes carry dietary tags.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum DietaryTag {
|
||||
VEGETARIAN
|
||||
VEGAN
|
||||
GLUTEN_FREE
|
||||
PESCATARIAN
|
||||
}
|
||||
|
||||
enum Allergen {
|
||||
GLUTEN
|
||||
CRUSTACEANS
|
||||
EGGS
|
||||
FISH
|
||||
PEANUTS
|
||||
SOYBEANS
|
||||
MILK
|
||||
TREE_NUTS
|
||||
CELERY
|
||||
MUSTARD
|
||||
SESAME
|
||||
SULPHITES
|
||||
LUPIN
|
||||
MOLLUSCS
|
||||
}
|
||||
|
||||
model LunchEvent {
|
||||
id String @id @default(cuid())
|
||||
programId String @unique
|
||||
enabled Boolean @default(false)
|
||||
eventAt DateTime?
|
||||
endAt DateTime?
|
||||
venue String?
|
||||
notes String? @db.Text
|
||||
changeCutoffHours Int @default(48)
|
||||
reminderHoursBeforeDeadline Int?
|
||||
cronEnabled Boolean @default(true)
|
||||
extraRecipients String[] @default([])
|
||||
reminderSentAt DateTime?
|
||||
recapSentAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||
dishes Dish[]
|
||||
externalAttendees ExternalAttendee[]
|
||||
}
|
||||
|
||||
model Dish {
|
||||
id String @id @default(cuid())
|
||||
lunchEventId String
|
||||
name String
|
||||
sortOrder Int @default(0)
|
||||
dietaryTags DietaryTag[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
|
||||
memberPicks MemberLunchPick[]
|
||||
externals ExternalAttendee[]
|
||||
|
||||
@@index([lunchEventId])
|
||||
}
|
||||
|
||||
model MemberLunchPick {
|
||||
id String @id @default(cuid())
|
||||
attendingMemberId String @unique
|
||||
dishId String?
|
||||
allergens Allergen[] @default([])
|
||||
allergenOther String?
|
||||
pickedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
|
||||
dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([dishId])
|
||||
}
|
||||
|
||||
model ExternalAttendee {
|
||||
id String @id @default(cuid())
|
||||
lunchEventId String
|
||||
projectId String?
|
||||
name String
|
||||
email String?
|
||||
roleNote String?
|
||||
dishId String?
|
||||
allergens Allergen[] @default([])
|
||||
allergenOther String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([lunchEventId])
|
||||
@@index([projectId])
|
||||
}
|
||||
|
||||
@@ -317,7 +317,6 @@ async function main() {
|
||||
{ email: 'matt@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' },
|
||||
{ email: 'matt@letsbe.solutions', name: 'Matt (Applicant)', role: UserRole.APPLICANT, password: '195260Mp!' },
|
||||
{ email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' },
|
||||
{ email: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' },
|
||||
]
|
||||
|
||||
const staffUsers: Record<string, string> = {}
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -58,7 +58,7 @@ export default function EditAwardPage({
|
||||
const [votingEndAt, setVotingEndAt] = useState('')
|
||||
const [evaluationRoundId, setEvaluationRoundId] = useState('')
|
||||
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
|
||||
const [decisionMode, setDecisionMode] = useState<'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION'>('JURY_VOTE')
|
||||
const [decisionMode, setDecisionMode] = useState<'JURY_VOTE' | 'ADMIN_DECISION'>('JURY_VOTE')
|
||||
|
||||
// Helper to format date for datetime-local input
|
||||
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
||||
@@ -236,7 +236,6 @@ export default function EditAwardPage({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="JURY_VOTE">Jury Vote — tallied from all jurors</SelectItem>
|
||||
<SelectItem value="AWARD_MASTER_DECISION">Award Master — sponsor picks winner</SelectItem>
|
||||
<SelectItem value="ADMIN_DECISION">Admin Decision — admin selects winner</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -335,20 +335,20 @@ function RoundsDndGrid({
|
||||
function ConfidenceBadge({ confidence }: { confidence: number }) {
|
||||
if (confidence > 0.8) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs tabular-nums">
|
||||
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700 text-xs tabular-nums">
|
||||
{Math.round(confidence * 100)}%
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (confidence >= 0.5) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-400 text-xs tabular-nums">
|
||||
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700 text-xs tabular-nums">
|
||||
{Math.round(confidence * 100)}%
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-950/30 dark:text-red-400 text-xs tabular-nums">
|
||||
<Badge variant="outline" className="border-red-300 bg-red-50 text-red-700 text-xs tabular-nums">
|
||||
{Math.round(confidence * 100)}%
|
||||
</Badge>
|
||||
)
|
||||
@@ -408,7 +408,7 @@ export default function AwardDetailPage({
|
||||
|
||||
// Deferred queries - only load when needed
|
||||
const { data: allUsers } = trpc.user.list.useQuery(
|
||||
{ page: 1, perPage: 100, roles: ['JURY_MEMBER', 'AWARD_MASTER'] },
|
||||
{ page: 1, perPage: 100, roles: ['JURY_MEMBER'] },
|
||||
{ enabled: activeTab === 'jurors' }
|
||||
)
|
||||
const { data: juryGroups } = trpc.juryGroup.list.useQuery(
|
||||
@@ -513,6 +513,13 @@ export default function AwardDetailPage({
|
||||
},
|
||||
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({
|
||||
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-2xl font-bold tabular-nums">{award.eligibleCount}</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/40">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -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-2xl font-bold tabular-nums">{(award as any).totalAssessed ?? award._count.eligibilities}</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
|
||||
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
|
||||
<ListChecks className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -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-2xl font-bold tabular-nums">{award._count.jurors}</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-100 dark:bg-violet-950/40">
|
||||
<Users className="h-5 w-5 text-violet-600 dark:text-violet-400" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-100">
|
||||
<Users className="h-5 w-5 text-violet-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -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-2xl font-bold tabular-nums">{award._count.votes}</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-950/40">
|
||||
<Vote className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100">
|
||||
<Vote className="h-5 w-5 text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -1335,7 +1342,7 @@ export default function AwardDetailPage({
|
||||
|
||||
{/* Jurors Tab */}
|
||||
<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}>
|
||||
<SelectTrigger className="w-64">
|
||||
<SelectValue placeholder="Select a juror..." />
|
||||
@@ -1355,6 +1362,19 @@ export default function AwardDetailPage({
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Add Juror
|
||||
</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>
|
||||
|
||||
{/* Import from Jury Group */}
|
||||
@@ -1498,7 +1518,6 @@ export default function AwardDetailPage({
|
||||
onSubmit={async (rows) => {
|
||||
await bulkInvite.mutateAsync({
|
||||
awardId,
|
||||
role: 'AWARD_MASTER',
|
||||
invitees: rows.map((r) => ({ name: r.name || undefined, email: r.email })),
|
||||
})
|
||||
}}
|
||||
@@ -1549,11 +1568,23 @@ export default function AwardDetailPage({
|
||||
/>
|
||||
</TableCell>
|
||||
<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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveJuror(j.userId)}
|
||||
disabled={removeJuror.isPending}
|
||||
title="Remove juror"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -1581,7 +1612,7 @@ export default function AwardDetailPage({
|
||||
{/* Rounds Tab */}
|
||||
<TabsContent value="rounds" className="space-y-4">
|
||||
{award.eligibilityMode !== 'SEPARATE_POOL' && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
|
||||
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800">
|
||||
<Info className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p className="text-sm">
|
||||
Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects.
|
||||
@@ -1589,7 +1620,7 @@ export default function AwardDetailPage({
|
||||
</div>
|
||||
)}
|
||||
{!award.competitionId && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p className="text-sm">
|
||||
Link this award to a competition first before creating rounds.
|
||||
@@ -1719,16 +1750,16 @@ export default function AwardDetailPage({
|
||||
return (
|
||||
<TableRow
|
||||
key={r.project.id}
|
||||
className={isWinner ? 'bg-amber-50/80 dark:bg-amber-950/20' : ''}
|
||||
className={isWinner ? 'bg-amber-50/80' : ''}
|
||||
>
|
||||
<TableCell>
|
||||
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold ${
|
||||
i === 0
|
||||
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: i === 1
|
||||
? 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-300'
|
||||
? 'bg-slate-200 text-slate-700'
|
||||
: i === 2
|
||||
? 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300'
|
||||
? 'bg-orange-100 text-orange-800'
|
||||
: 'text-muted-foreground'
|
||||
}`}>
|
||||
{i + 1}
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { toast } from 'sonner'
|
||||
import { cn, formatEnumLabel } from '@/lib/utils'
|
||||
import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot } from 'lucide-react'
|
||||
import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot, Trophy } from 'lucide-react'
|
||||
|
||||
const capModeLabels = {
|
||||
HARD: 'Hard Cap',
|
||||
@@ -301,10 +301,10 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Round assignments */}
|
||||
{(group as any).rounds?.length > 0 && (
|
||||
{/* Round + Special-award assignments */}
|
||||
{((group as any).rounds?.length > 0 || (group as any).awards?.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(group as any).rounds.map((r: any) => (
|
||||
{(group as any).rounds?.map((r: any) => (
|
||||
<Badge
|
||||
key={r.id}
|
||||
variant="outline"
|
||||
@@ -319,6 +319,21 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
|
||||
{r.name}
|
||||
</Badge>
|
||||
))}
|
||||
{(group as any).awards?.map((a: any) => (
|
||||
<Badge
|
||||
key={a.id}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] gap-1',
|
||||
a.status === 'VOTING_OPEN' && 'border-amber-300 bg-amber-50 text-amber-700',
|
||||
a.status === 'CLOSED' && 'border-emerald-300 bg-emerald-50 text-emerald-700',
|
||||
(a.status === 'DRAFT' || a.status === 'NOMINATIONS_OPEN') && 'border-slate-200 text-slate-500',
|
||||
)}
|
||||
>
|
||||
<Trophy className="h-2.5 w-2.5" />
|
||||
{a.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ const ROLE_OPTIONS = [
|
||||
{ value: 'MENTOR', label: 'Mentors' },
|
||||
{ value: 'OBSERVER', label: 'Observers' },
|
||||
{ value: 'APPLICANT', label: 'Applicants' },
|
||||
{ value: 'AWARD_MASTER', label: 'Award Masters' },
|
||||
]
|
||||
|
||||
type AccessRule =
|
||||
|
||||
@@ -60,7 +60,6 @@ const ROLE_OPTIONS = [
|
||||
{ value: 'MENTOR', label: 'Mentors' },
|
||||
{ value: 'OBSERVER', label: 'Observers' },
|
||||
{ value: 'APPLICANT', label: 'Applicants' },
|
||||
{ value: 'AWARD_MASTER', label: 'Award Masters' },
|
||||
]
|
||||
|
||||
type AccessRule =
|
||||
|
||||
83
src/app/(admin)/admin/logistics/page.tsx
Normal file
83
src/app/(admin)/admin/logistics/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Hotel as HotelIcon,
|
||||
Plane,
|
||||
Salad,
|
||||
ScrollText,
|
||||
Stamp,
|
||||
} from 'lucide-react'
|
||||
import { ConfirmationsTab } from '@/components/admin/logistics/confirmations-tab'
|
||||
import { TravelTab } from '@/components/admin/logistics/travel-tab'
|
||||
import { HotelsTab } from '@/components/admin/logistics/hotels-tab'
|
||||
import { VisasTab } from '@/components/admin/logistics/visas-tab'
|
||||
import { LunchTab } from '@/components/admin/logistics/lunch-tab'
|
||||
|
||||
export default function LogisticsPage() {
|
||||
const { currentEdition } = useEdition()
|
||||
const [tab, setTab] = useState('confirmations')
|
||||
|
||||
if (!currentEdition) {
|
||||
return (
|
||||
<p className="text-muted-foreground py-12 text-center text-sm">
|
||||
Select an edition to view logistics.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
const programId = currentEdition.id
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Logistics</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Operational hub for the grand finale: confirmations, travel, hotels, and more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onValueChange={setTab} className="space-y-6">
|
||||
<TabsList className="h-auto w-full justify-start overflow-x-auto pb-2">
|
||||
<TabsTrigger value="confirmations">
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" /> Confirmations
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="travel">
|
||||
<Plane className="mr-2 h-4 w-4" /> Travel
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="hotels">
|
||||
<HotelIcon className="mr-2 h-4 w-4" /> Hotels
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visas">
|
||||
<Stamp className="mr-2 h-4 w-4" /> Visas
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="lunch">
|
||||
<Salad className="mr-2 h-4 w-4" /> Lunch
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="email-templates" disabled>
|
||||
<ScrollText className="mr-2 h-4 w-4" /> Email Templates
|
||||
<span className="text-muted-foreground ml-1 text-xs">(soon)</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="confirmations">
|
||||
<ConfirmationsTab programId={programId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="travel">
|
||||
<TravelTab programId={programId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="hotels">
|
||||
<HotelsTab programId={programId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="visas">
|
||||
<VisasTab programId={programId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="lunch">
|
||||
<LunchTab programId={programId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -48,6 +48,22 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
@@ -69,6 +85,11 @@ import {
|
||||
LogIn,
|
||||
Calendar,
|
||||
Clock,
|
||||
Link as LinkIcon,
|
||||
Copy,
|
||||
Check,
|
||||
Plus,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||
import { formatRelativeTime } from '@/lib/utils'
|
||||
@@ -97,7 +118,6 @@ const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
||||
PROGRAM_ADMIN: 'default',
|
||||
SUPER_ADMIN: 'default',
|
||||
APPLICANT: 'secondary',
|
||||
AWARD_MASTER: 'outline',
|
||||
AUDIENCE: 'outline',
|
||||
}
|
||||
|
||||
@@ -112,8 +132,45 @@ export default function MemberDetailPage() {
|
||||
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
|
||||
const updateUser = trpc.user.update.useMutation()
|
||||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
||||
const generateAccessLink = trpc.user.generateAccessLink.useMutation()
|
||||
const startImpersonation = trpc.user.startImpersonation.useMutation()
|
||||
|
||||
const [accessLinkOpen, setAccessLinkOpen] = useState(false)
|
||||
const [accessLink, setAccessLink] = useState<{
|
||||
url: string
|
||||
kind: 'setup' | 'magic_login'
|
||||
expiresAt: Date
|
||||
} | null>(null)
|
||||
const [linkCopied, setLinkCopied] = useState(false)
|
||||
|
||||
const handleGenerateAccessLink = async () => {
|
||||
try {
|
||||
const result = await generateAccessLink.mutateAsync({ userId })
|
||||
setAccessLink({
|
||||
url: result.url,
|
||||
kind: result.kind,
|
||||
expiresAt: new Date(result.expiresAt),
|
||||
})
|
||||
setLinkCopied(false)
|
||||
setAccessLinkOpen(true)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to generate access link'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyAccessLink = async () => {
|
||||
if (!accessLink) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(accessLink.url)
|
||||
setLinkCopied(true)
|
||||
toast.success('Link copied to clipboard')
|
||||
} catch {
|
||||
toast.error('Could not copy — please select and copy the link manually')
|
||||
}
|
||||
}
|
||||
|
||||
// Mentor assignments (only fetched for mentors)
|
||||
const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery(
|
||||
{ mentorId: userId, page: 1, perPage: 50 },
|
||||
@@ -138,6 +195,10 @@ export default function MemberDetailPage() {
|
||||
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
||||
const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false)
|
||||
const [pendingSuperAdminRole, setPendingSuperAdminRole] = useState(false)
|
||||
const [pendingAdditionalRole, setPendingAdditionalRole] = useState<{
|
||||
role: 'JURY_MEMBER' | 'OBSERVER' | 'MENTOR'
|
||||
action: 'add' | 'remove'
|
||||
} | null>(null)
|
||||
const [additionalRoles, setAdditionalRoles] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -154,7 +215,7 @@ export default function MemberDetailPage() {
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const allRoles = [role, ...additionalRoles.filter((r) => r !== role)] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'>
|
||||
const allRoles = [role, ...additionalRoles.filter((r) => r !== role)] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'>
|
||||
await updateUser.mutateAsync({
|
||||
id: userId,
|
||||
email: email || undefined,
|
||||
@@ -283,6 +344,21 @@ export default function MemberDetailPage() {
|
||||
{user.status === 'INVITED' ? 'Resend Invite' : 'Send Invitation'}
|
||||
</Button>
|
||||
)}
|
||||
{user.status !== 'SUSPENDED' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleGenerateAccessLink}
|
||||
disabled={generateAccessLink.isPending}
|
||||
title="Generate a one-time link to share manually if email isn't reaching them"
|
||||
>
|
||||
{generateAccessLink.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Copy Access Link
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleImpersonate}
|
||||
@@ -622,7 +698,6 @@ export default function MemberDetailPage() {
|
||||
<SelectItem value="MENTOR">Mentor</SelectItem>
|
||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||
<SelectItem value="APPLICANT">Applicant</SelectItem>
|
||||
<SelectItem value="AWARD_MASTER">Award Master</SelectItem>
|
||||
<SelectItem value="AUDIENCE">Audience</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -630,26 +705,72 @@ export default function MemberDetailPage() {
|
||||
<div className="space-y-2">
|
||||
<Label>Additional Roles</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Grant additional dashboard access beyond the primary role
|
||||
Grant additional dashboard access beyond the primary role.
|
||||
Click the menu to add or remove a role — you'll be
|
||||
asked to confirm each change.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['JURY_MEMBER', 'OBSERVER', 'MENTOR', 'AWARD_MASTER'] as const)
|
||||
.filter((r) => r !== role)
|
||||
.map((r) => (
|
||||
<label key={r} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={additionalRoles.includes(r)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setAdditionalRoles((prev) => [...prev, r])
|
||||
} else {
|
||||
setAdditionalRoles((prev) => prev.filter((x) => x !== r))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{additionalRoles.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground italic">
|
||||
None — only the primary role above
|
||||
</span>
|
||||
) : (
|
||||
additionalRoles.map((r) => (
|
||||
<Badge
|
||||
key={r}
|
||||
variant={roleColors[r] || 'secondary'}
|
||||
className="gap-1.5 pl-2 pr-1 py-0.5"
|
||||
>
|
||||
{r.replace(/_/g, ' ')}
|
||||
</label>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${r.replace(/_/g, ' ')} role`}
|
||||
className="rounded-full hover:bg-foreground/10 p-0.5 transition-colors"
|
||||
onClick={() =>
|
||||
setPendingAdditionalRole({
|
||||
role: r as 'JURY_MEMBER' | 'OBSERVER' | 'MENTOR',
|
||||
action: 'remove',
|
||||
})
|
||||
}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" type="button">
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Manage roles
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuLabel>Toggle additional roles</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{(['JURY_MEMBER', 'OBSERVER', 'MENTOR'] as const)
|
||||
.filter((r) => r !== role)
|
||||
.map((r) => {
|
||||
const isAssigned = additionalRoles.includes(r)
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={r}
|
||||
checked={isAssigned}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
setPendingAdditionalRole({
|
||||
role: r,
|
||||
action: isAssigned ? 'remove' : 'add',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{r.replace(/_/g, ' ')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -821,6 +942,81 @@ export default function MemberDetailPage() {
|
||||
</Tabs>
|
||||
|
||||
{/* Super Admin Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={pendingAdditionalRole !== null}
|
||||
onOpenChange={(open) => { if (!open) setPendingAdditionalRole(null) }}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{pendingAdditionalRole?.action === 'add' ? 'Add' : 'Remove'}{' '}
|
||||
{pendingAdditionalRole?.role.replace(/_/g, ' ')} role?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingAdditionalRole?.action === 'add' ? (
|
||||
<>
|
||||
This will give <strong>{name || user?.name || 'this user'}</strong>{' '}
|
||||
the {pendingAdditionalRole.role.replace(/_/g, ' ')} dashboard
|
||||
in addition to their primary role. They'll be able to
|
||||
switch between dashboards from the role switcher. Click
|
||||
“Save changes” below to apply.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This will revoke the {pendingAdditionalRole?.role.replace(/_/g, ' ')}
|
||||
{' '}dashboard from <strong>{name || user?.name || 'this user'}</strong>.
|
||||
They'll keep their primary role and any other additional
|
||||
roles. Click “Save changes” below to apply.
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setPendingAdditionalRole(null)}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
if (!pendingAdditionalRole) return
|
||||
const { role: r, action } = pendingAdditionalRole
|
||||
const nextAdditional =
|
||||
action === 'add'
|
||||
? additionalRoles.includes(r)
|
||||
? additionalRoles
|
||||
: [...additionalRoles, r]
|
||||
: additionalRoles.filter((x) => x !== r)
|
||||
const nextAllRoles = [
|
||||
role,
|
||||
...nextAdditional.filter((x) => x !== role),
|
||||
] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'>
|
||||
try {
|
||||
await updateUser.mutateAsync({
|
||||
id: userId,
|
||||
roles: nextAllRoles,
|
||||
})
|
||||
setAdditionalRoles(nextAdditional)
|
||||
utils.user.get.invalidate({ id: userId })
|
||||
utils.user.list.invalidate()
|
||||
toast.success(
|
||||
action === 'add'
|
||||
? `${r.replace(/_/g, ' ')} role added`
|
||||
: `${r.replace(/_/g, ' ')} role removed`,
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to update roles',
|
||||
)
|
||||
} finally {
|
||||
setPendingAdditionalRole(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pendingAdditionalRole?.action === 'add' ? 'Add role' : 'Remove role'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -848,6 +1044,67 @@ export default function MemberDetailPage() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<Dialog open={accessLinkOpen} onOpenChange={setAccessLinkOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
Access link ready
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{accessLink?.kind === 'magic_login'
|
||||
? `Share this with ${user.name || user.email} via Slack, WhatsApp, or any channel that reaches them. Clicking it will sign them in directly (their existing password is preserved) and take them to their dashboard.`
|
||||
: `Share this with ${user.name || user.email} via Slack, WhatsApp, or any channel that reaches them. They'll be walked through setting a password and onboarding.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border bg-muted/40 p-3">
|
||||
<Input
|
||||
readOnly
|
||||
value={accessLink?.url ?? ''}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="font-mono text-xs bg-background"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Expires {accessLink?.expiresAt.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
|
||||
{' · '}consumed on first successful login
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Don't paste this in a public channel. Anyone with the link
|
||||
can sign in as this user until it's consumed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button variant="outline" onClick={() => setAccessLinkOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleCopyAccessLink}>
|
||||
{linkCopied ? (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy link
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ import { useSession } from 'next-auth/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Step = 'input' | 'preview' | 'sending' | 'complete'
|
||||
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||
|
||||
interface Assignment {
|
||||
projectId: string
|
||||
@@ -106,7 +106,6 @@ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
SUPER_ADMIN: 'Super Admin',
|
||||
PROGRAM_ADMIN: 'Program Admin',
|
||||
AWARD_MASTER: 'Award Master',
|
||||
JURY_MEMBER: 'Jury Member',
|
||||
MENTOR: 'Mentor',
|
||||
OBSERVER: 'Observer',
|
||||
@@ -289,7 +288,7 @@ export default function MemberInvitePage() {
|
||||
const availableRoles = useMemo((): Role[] => {
|
||||
const roles: Role[] = []
|
||||
if (isSuperAdmin) roles.push('SUPER_ADMIN')
|
||||
if (isAdmin) roles.push('PROGRAM_ADMIN', 'AWARD_MASTER')
|
||||
if (isAdmin) roles.push('PROGRAM_ADMIN')
|
||||
roles.push('JURY_MEMBER', 'MENTOR', 'OBSERVER')
|
||||
return roles
|
||||
}, [isSuperAdmin, isAdmin])
|
||||
@@ -423,13 +422,11 @@ export default function MemberInvitePage() {
|
||||
? 'SUPER_ADMIN'
|
||||
: rawRole === 'PROGRAM_ADMIN'
|
||||
? 'PROGRAM_ADMIN'
|
||||
: rawRole === 'AWARD_MASTER'
|
||||
? 'AWARD_MASTER'
|
||||
: rawRole === 'MENTOR'
|
||||
? 'MENTOR'
|
||||
: rawRole === 'OBSERVER'
|
||||
? 'OBSERVER'
|
||||
: 'JURY_MEMBER'
|
||||
: rawRole === 'MENTOR'
|
||||
? 'MENTOR'
|
||||
: rawRole === 'OBSERVER'
|
||||
? 'OBSERVER'
|
||||
: 'JURY_MEMBER'
|
||||
const isValidFormat = emailRegex.test(email)
|
||||
const isDuplicate = email ? seenEmails.has(email) : false
|
||||
const isUnauthorizedAdmin = role === 'SUPER_ADMIN' && !isSuperAdmin
|
||||
@@ -910,7 +907,7 @@ export default function MemberInvitePage() {
|
||||
</div>
|
||||
|
||||
{!sendInvitation && (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-blue-500/10 p-4 text-blue-700 dark:text-blue-400">
|
||||
<div className="flex items-start gap-3 rounded-lg bg-blue-500/10 p-4 text-blue-700">
|
||||
<MailX className="h-5 w-5 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">No invitations will be sent</p>
|
||||
|
||||
@@ -1,5 +1,470 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
'use client'
|
||||
|
||||
export default function MentorsPage() {
|
||||
redirect('/admin/members')
|
||||
import { 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 { 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,
|
||||
Plus,
|
||||
X,
|
||||
Mail,
|
||||
} from 'lucide-react'
|
||||
import { ProjectEmailDialog } from '@/components/admin/project-email-dialog'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||
@@ -161,6 +163,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
|
||||
// State for remove member confirmation
|
||||
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
||||
const [emailDialogOpen, setEmailDialogOpen] = useState(false)
|
||||
|
||||
const addTeamMember = trpc.project.addTeamMember.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -269,14 +272,29 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/projects/${projectId}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<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>
|
||||
<Link href={`/admin/projects/${projectId}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{project && (
|
||||
<ProjectEmailDialog
|
||||
open={emailDialogOpen}
|
||||
onClose={() => setEmailDialogOpen(false)}
|
||||
projectId={project.id}
|
||||
projectTitle={project.title}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Stats Grid */}
|
||||
|
||||
@@ -462,7 +462,7 @@ export default function BulkUploadPage() {
|
||||
return (
|
||||
<TableRow
|
||||
key={row.project.id}
|
||||
className={row.isComplete ? 'bg-green-50/50 dark:bg-green-950/10' : ''}
|
||||
className={row.isComplete ? 'bg-green-50/50' : ''}
|
||||
>
|
||||
<TableCell>
|
||||
<Link
|
||||
|
||||
@@ -53,15 +53,15 @@ type TeamMemberEntry = {
|
||||
}
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400',
|
||||
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
LEAD: 'bg-red-100 text-red-700',
|
||||
MEMBER: 'bg-teal-100 text-teal-700',
|
||||
ADVISOR: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
const ROLE_AVATAR_COLORS: Record<string, string> = {
|
||||
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
||||
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
|
||||
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
LEAD: 'bg-red-100 text-red-700',
|
||||
MEMBER: 'bg-teal-100 text-teal-700',
|
||||
ADVISOR: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
|
||||
@@ -679,7 +679,7 @@ export default function ProjectsPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAiTagDialogOpen(true)}
|
||||
className={taggingInProgress ? 'border-amber-400 bg-amber-50 dark:bg-amber-950/20' : ''}
|
||||
className={taggingInProgress ? 'border-amber-400 bg-amber-50' : ''}
|
||||
>
|
||||
{taggingInProgress ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-amber-600" />
|
||||
@@ -1716,15 +1716,15 @@ export default function ProjectsPage() {
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Progress Indicator (when running) */}
|
||||
{taggingInProgress && (
|
||||
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
|
||||
<div className="p-4 rounded-lg bg-blue-50 border border-blue-200">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-blue-900 dark:text-blue-100">
|
||||
<p className="font-medium text-blue-900">
|
||||
AI Tagging in Progress
|
||||
</p>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
<p className="text-sm text-blue-700">
|
||||
{jobStatus?.status === 'PENDING'
|
||||
? 'Initializing...'
|
||||
: `Processing ${jobStatus?.totalProjects || 0} projects with AI...`}
|
||||
@@ -1739,12 +1739,12 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-blue-700 dark:text-blue-300">
|
||||
<span className="text-blue-700">
|
||||
{jobStatus?.processedCount || 0} of {jobStatus?.totalProjects || '?'} projects processed
|
||||
{jobStatus?.taggedCount ? ` (${jobStatus.taggedCount} tagged)` : ''}
|
||||
</span>
|
||||
{jobStatus && jobStatus.totalProjects > 0 && (
|
||||
<span className="font-medium text-blue-900 dark:text-blue-100">
|
||||
<span className="font-medium text-blue-900">
|
||||
{taggingProgressPercent}%
|
||||
</span>
|
||||
)}
|
||||
@@ -1767,9 +1767,9 @@ export default function ProjectsPage() {
|
||||
{taggingResult && !taggingInProgress && (
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
taggingResult.failed > 0
|
||||
? 'bg-amber-50 dark:bg-amber-950/20 border-amber-200 dark:border-amber-900'
|
||||
? 'bg-amber-50 border-amber-200'
|
||||
: taggingResult.processed > 0
|
||||
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900'
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-muted border-border'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
@@ -1804,12 +1804,12 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
{taggingResult.errors.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
<p className="text-sm font-medium text-amber-700">
|
||||
{taggingResult.errors.length} project{taggingResult.errors.length > 1 ? 's' : ''} failed:
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto rounded bg-background/50 p-2 text-xs space-y-1">
|
||||
{taggingResult.errors.map((error, i) => (
|
||||
<p key={i} className="text-amber-700 dark:text-amber-300">
|
||||
<p key={i} className="text-amber-700">
|
||||
• {error}
|
||||
</p>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -45,8 +46,13 @@ import {
|
||||
Trophy,
|
||||
ArrowRight,
|
||||
Hash,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||
import {
|
||||
ScoreDistributionChart,
|
||||
EvaluationTimelineChart,
|
||||
@@ -62,33 +68,41 @@ import {
|
||||
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
||||
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: dashStats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery()
|
||||
|
||||
// Flatten stages from all programs
|
||||
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` }))
|
||||
) || []
|
||||
|
||||
// Project reporting scope (default: latest program, all rounds)
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (programs?.length && !selectedValue) {
|
||||
setSelectedValue(`all:${programs[0].id}`)
|
||||
}
|
||||
}, [programs, selectedValue])
|
||||
|
||||
const scopeInput = parseSelection(selectedValue)
|
||||
const scopeInput = parseSelection(scope)
|
||||
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 } =
|
||||
trpc.analytics.getProjectRankings.useQuery(
|
||||
{ ...scopeInput, limit: 5000 },
|
||||
{ enabled: hasScope }
|
||||
)
|
||||
|
||||
// Applicant nationality breakdown — always runs (scope optional;
|
||||
// empty scope = global view across all programs).
|
||||
const { data: nationalityStats, isLoading: nationalityLoading } =
|
||||
trpc.analytics.getApplicantNationalities.useQuery(scopeInput)
|
||||
|
||||
const nationalityScopeLabel = scopeInput.roundId
|
||||
? 'in this round'
|
||||
: scopeInput.programId
|
||||
? `in ${programs?.find((p) => p.id === scopeInput.programId)?.year ?? ''} Edition`
|
||||
: 'across all programs'
|
||||
|
||||
if (isLoading || statsLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -196,6 +210,13 @@ function ReportsOverview() {
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
|
||||
{/* Applicant Nationalities */}
|
||||
<ApplicantNationalitiesCard
|
||||
data={nationalityStats}
|
||||
loading={nationalityLoading}
|
||||
scopeLabel={nationalityScopeLabel}
|
||||
/>
|
||||
|
||||
{/* Score Distribution (if any evaluations exist) */}
|
||||
{dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && (
|
||||
<Card>
|
||||
@@ -239,26 +260,9 @@ function ReportsOverview() {
|
||||
Project Reports
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Summary dashboard — optionally filter to a specific round
|
||||
{selectedScopeLabel}
|
||||
</CardDescription>
|
||||
</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>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
@@ -271,6 +275,12 @@ function ReportsOverview() {
|
||||
const evaluated = projectRankings.filter(p => p.averageScore !== null)
|
||||
const scores = evaluated.map(p => p.averageScore as number)
|
||||
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 maxScore = scores.length ? Math.max(...scores) : 0
|
||||
const evalPercent = projectRankings.length ? Math.round((evaluated.length / projectRankings.length) * 100) : 0
|
||||
@@ -281,14 +291,28 @@ function ReportsOverview() {
|
||||
|
||||
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">
|
||||
<p className="text-xs text-muted-foreground">Total Projects</p>
|
||||
<p className="text-xl font-bold tabular-nums">{projectRankings.length}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">Avg Score</p>
|
||||
<p className="text-xl font-bold tabular-nums">{avgScore ? avgScore.toFixed(1) : '-'}</p>
|
||||
<div
|
||||
className="rounded-lg border p-3 text-center"
|
||||
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 className="rounded-lg border p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">Evaluated</p>
|
||||
@@ -319,7 +343,7 @@ function ReportsOverview() {
|
||||
{/* Top 10 ranked table */}
|
||||
<div>
|
||||
<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>
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
@@ -328,7 +352,18 @@ function ReportsOverview() {
|
||||
<TableHead className="w-10">#</TableHead>
|
||||
<TableHead>Project</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>Status</TableHead>
|
||||
</TableRow>
|
||||
@@ -345,9 +380,12 @@ function ReportsOverview() {
|
||||
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
||||
{p.teamName || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{p.averageScore === null ? '-' : p.averageScore.toFixed(2)}
|
||||
</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>
|
||||
<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
|
||||
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
||||
if (!value) return {}
|
||||
@@ -512,9 +684,7 @@ function findDefaultRound(rounds: Array<{ id: string; status?: string }>): strin
|
||||
return rounds[0]?.id
|
||||
}
|
||||
|
||||
function StageAnalytics() {
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
|
||||
function StageAnalytics({ scope }: { scope: string | null }) {
|
||||
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
|
||||
// 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` }))
|
||||
) || []
|
||||
|
||||
// Set default selected stage — prefer active round
|
||||
useEffect(() => {
|
||||
if (rounds.length && !selectedValue) {
|
||||
setSelectedValue(findDefaultRound(rounds) ?? rounds[0].id)
|
||||
}
|
||||
}, [rounds.length, selectedValue])
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const queryInput = parseSelection(scope)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: scoreDistribution, isLoading: scoreLoading } =
|
||||
@@ -568,7 +731,7 @@ function StageAnalytics() {
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const selectedRound = rounds.find((r) => r.id === selectedValue)
|
||||
const selectedRound = rounds.find((r) => r.id === scope)
|
||||
const geoInput = queryInput.programId
|
||||
? { programId: queryInput.programId }
|
||||
: { programId: selectedRound?.programId || '', roundId: queryInput.roundId }
|
||||
@@ -606,28 +769,6 @@ function StageAnalytics() {
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: Score Distribution & Status Breakdown */}
|
||||
@@ -800,22 +941,10 @@ function CrossStageTab() {
|
||||
)
|
||||
}
|
||||
|
||||
function JurorConsistencyTab() {
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
function JurorConsistencyTab({ scope }: { scope: string | null }) {
|
||||
const { isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
|
||||
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 queryInput = parseSelection(scope)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: consistency, isLoading: consistencyLoading } =
|
||||
@@ -830,27 +959,6 @@ function JurorConsistencyTab() {
|
||||
|
||||
return (
|
||||
<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]" />}
|
||||
|
||||
{consistency && (
|
||||
@@ -870,26 +978,154 @@ function JurorConsistencyTab() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{queryInput.roundId && (
|
||||
<JurorCalibrationPanel roundId={queryInput.roundId} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DiversityTab() {
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
function JurorCalibrationPanel({ roundId }: { roundId: string }) {
|
||||
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',
|
||||
notable: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
normal: 'bg-muted text-muted-foreground',
|
||||
}
|
||||
|
||||
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` }))
|
||||
) || []
|
||||
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>
|
||||
)}
|
||||
|
||||
useEffect(() => {
|
||||
if (stages.length && !selectedValue) {
|
||||
setSelectedValue(findDefaultRound(stages) ?? stages[0].id)
|
||||
}
|
||||
}, [stages.length, selectedValue])
|
||||
{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>
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
<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 { data: diversity, isLoading: diversityLoading } =
|
||||
@@ -904,27 +1140,6 @@ function DiversityTab() {
|
||||
|
||||
return (
|
||||
<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]" />}
|
||||
|
||||
{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 rounds = programs?.flatMap(p =>
|
||||
const allRounds = programs?.flatMap(p =>
|
||||
((p.stages ?? []) as Array<{ id: string; name: string; status: string; type?: string }>).map((s) => ({
|
||||
...s,
|
||||
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 { data: comparison, isLoading: comparisonLoading } =
|
||||
@@ -1034,20 +1259,48 @@ function RoundPipelineTab() {
|
||||
}
|
||||
|
||||
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 { 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(() => {
|
||||
if (pdfStages.length && !pdfStageId) {
|
||||
setPdfStageId(findDefaultRound(pdfStages) ?? pdfStages[0].id)
|
||||
if (stages.length && !pdfStageId) {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
@@ -1059,6 +1312,35 @@ export default function ReportsPage() {
|
||||
</p>
|
||||
</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 defaultValue="overview" className="space-y-6">
|
||||
<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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pdfStages.map((stage) => (
|
||||
{stages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.name}
|
||||
</SelectItem>
|
||||
@@ -1112,11 +1394,11 @@ export default function ReportsPage() {
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<ReportsOverview />
|
||||
<ReportsOverview scope={scope} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics">
|
||||
<StageAnalytics />
|
||||
<StageAnalytics scope={scope} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="cross-stage">
|
||||
@@ -1124,15 +1406,15 @@ export default function ReportsPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="consistency">
|
||||
<JurorConsistencyTab />
|
||||
<JurorConsistencyTab scope={scope} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="diversity">
|
||||
<DiversityTab />
|
||||
<DiversityTab scope={scope} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pipeline">
|
||||
<RoundPipelineTab />
|
||||
<RoundPipelineTab scope={scope} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
||||
import { ArrowLeft, Save, Send, UserCheck, Lock } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { EvaluationConfig } from '@/types/competition-configs'
|
||||
import {
|
||||
EvaluationFormFields,
|
||||
parseCriteriaFromForm,
|
||||
} from '@/components/evaluation/evaluation-form-fields'
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ roundId: string; userId: string; projectId: string }>
|
||||
}
|
||||
|
||||
export default function AdminProxyEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
const { roundId, userId, projectId } = use(paramsPromise)
|
||||
const router = useRouter()
|
||||
const utils = trpc.useUtils()
|
||||
const backHref = `/admin/rounds/${roundId}/jurors/${userId}/evaluate` as Route
|
||||
|
||||
// Form state — mirrors the juror evaluate page
|
||||
const [criteriaValues, setCriteriaValues] = useState<Record<string, number | boolean | string>>({})
|
||||
const [globalScore, setGlobalScore] = useState('')
|
||||
const [binaryDecision, setBinaryDecision] = useState<'' | 'accept' | 'reject'>('')
|
||||
const [feedbackText, setFeedbackText] = useState('')
|
||||
|
||||
const isDirtyRef = useRef(false)
|
||||
const evaluationIdRef = useRef<string | null>(null)
|
||||
const isSubmittedRef = useRef(false)
|
||||
const isSubmittingRef = useRef(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const startPromiseRef = useRef<Promise<{ id: string }> | null>(null)
|
||||
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
// Juror + round + all assignments (we filter to the one matching projectId)
|
||||
const { data: jurorData, isLoading: jurorLoading } =
|
||||
trpc.evaluation.getJurorAssignmentsForRound.useQuery({ roundId, userId })
|
||||
|
||||
const assignment = jurorData?.assignments.find((a) => a.project.id === projectId)
|
||||
const project = assignment?.project
|
||||
const round = jurorData?.round
|
||||
const juror = jurorData?.juror
|
||||
|
||||
// Full round config (for scoringMode / requireFeedback etc.)
|
||||
const { data: fullRound } = trpc.round.getById.useQuery(
|
||||
{ id: roundId },
|
||||
{ enabled: !!roundId },
|
||||
)
|
||||
|
||||
// Existing evaluation for this assignment, if any (admin-readable via evaluation.get)
|
||||
const { data: existingEvaluation } = trpc.evaluation.get.useQuery(
|
||||
{ assignmentId: assignment?.id ?? '' },
|
||||
{ enabled: !!assignment?.id },
|
||||
)
|
||||
|
||||
// Active form for this category
|
||||
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||
{ roundId, category: project?.competitionCategory },
|
||||
{ enabled: !!roundId && !!project },
|
||||
)
|
||||
|
||||
const startMutation = trpc.evaluation.adminStart.useMutation()
|
||||
const autosaveMutation = trpc.evaluation.adminAutosave.useMutation({
|
||||
onSuccess: () => {
|
||||
isDirtyRef.current = false
|
||||
setLastSavedAt(new Date())
|
||||
},
|
||||
})
|
||||
const submitMutation = trpc.evaluation.adminSubmitOnBehalf.useMutation({
|
||||
onSuccess: () => {
|
||||
isSubmittedRef.current = true
|
||||
isDirtyRef.current = false
|
||||
utils.evaluation.getJurorAssignmentsForRound.invalidate({ roundId, userId })
|
||||
utils.evaluation.get.invalidate()
|
||||
utils.analytics.getJurorWorkload.invalidate({ roundId })
|
||||
toast.success(`Evaluation submitted on behalf of ${juror?.name || 'juror'}`)
|
||||
router.push(backHref)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
isSubmittingRef.current = false
|
||||
setIsSubmitting(false)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (existingEvaluation?.id) {
|
||||
evaluationIdRef.current = existingEvaluation.id
|
||||
}
|
||||
}, [existingEvaluation?.id])
|
||||
|
||||
// Load existing evaluation values (draft or submitted)
|
||||
useEffect(() => {
|
||||
if (existingEvaluation) {
|
||||
if (existingEvaluation.criterionScoresJson) {
|
||||
const values: Record<string, number | boolean | string> = {}
|
||||
Object.entries(existingEvaluation.criterionScoresJson).forEach(([key, value]) => {
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') {
|
||||
values[key] = value
|
||||
}
|
||||
})
|
||||
setCriteriaValues(values)
|
||||
}
|
||||
if (existingEvaluation.globalScore != null) {
|
||||
setGlobalScore(existingEvaluation.globalScore.toString())
|
||||
}
|
||||
if (existingEvaluation.binaryDecision !== null) {
|
||||
setBinaryDecision(existingEvaluation.binaryDecision ? 'accept' : 'reject')
|
||||
}
|
||||
if (existingEvaluation.feedbackText) {
|
||||
setFeedbackText(existingEvaluation.feedbackText)
|
||||
}
|
||||
isDirtyRef.current = false
|
||||
}
|
||||
}, [existingEvaluation])
|
||||
|
||||
// Config
|
||||
const evalConfig: EvaluationConfig | null = (fullRound?.configJson as EvaluationConfig | null) ?? null
|
||||
const scoringMode = evalConfig?.scoringMode ?? 'criteria'
|
||||
const requireFeedback = evalConfig?.requireFeedback ?? true
|
||||
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
|
||||
|
||||
const criteria = parseCriteriaFromForm(
|
||||
activeForm?.criteriaJson as ReadonlyArray<Record<string, unknown>> | null | undefined,
|
||||
)
|
||||
|
||||
// Seed midpoint values for numeric criteria on first load
|
||||
const criteriaInitializedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (criteriaInitializedRef.current || criteria.length === 0) return
|
||||
if (existingEvaluation?.criterionScoresJson) return
|
||||
criteriaInitializedRef.current = true
|
||||
|
||||
const defaults: Record<string, number | boolean | string> = {}
|
||||
for (const c of criteria) {
|
||||
if (c.type === 'numeric') {
|
||||
defaults[c.id] = Math.ceil((c.minScore + c.maxScore) / 2)
|
||||
}
|
||||
}
|
||||
if (Object.keys(defaults).length > 0) {
|
||||
setCriteriaValues((prev) => ({ ...defaults, ...prev }))
|
||||
}
|
||||
}, [criteria, existingEvaluation?.criterionScoresJson])
|
||||
|
||||
const buildSavePayload = useCallback(() => {
|
||||
return {
|
||||
criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : undefined,
|
||||
globalScore: scoringMode === 'global' && globalScore ? parseInt(globalScore, 10) : null,
|
||||
binaryDecision: scoringMode === 'binary' && binaryDecision ? binaryDecision === 'accept' : null,
|
||||
feedbackText: feedbackText || null,
|
||||
}
|
||||
}, [scoringMode, criteriaValues, globalScore, binaryDecision, feedbackText])
|
||||
|
||||
const performAutosave = useCallback(async () => {
|
||||
if (!isDirtyRef.current || isSubmittedRef.current || isSubmittingRef.current) return
|
||||
if (existingEvaluation?.status === 'SUBMITTED' || existingEvaluation?.status === 'LOCKED') return
|
||||
|
||||
let evalId = evaluationIdRef.current
|
||||
if (!evalId && assignment) {
|
||||
try {
|
||||
if (!startPromiseRef.current) {
|
||||
startPromiseRef.current = startMutation.mutateAsync({ assignmentId: assignment.id })
|
||||
}
|
||||
const newEval = await startPromiseRef.current
|
||||
evalId = newEval.id
|
||||
evaluationIdRef.current = evalId
|
||||
} catch {
|
||||
return
|
||||
} finally {
|
||||
startPromiseRef.current = null
|
||||
}
|
||||
}
|
||||
if (!evalId) return
|
||||
|
||||
autosaveMutation.mutate({ id: evalId, ...buildSavePayload() })
|
||||
}, [assignment, existingEvaluation?.status, startMutation, autosaveMutation, buildSavePayload])
|
||||
|
||||
// Debounced autosave
|
||||
useEffect(() => {
|
||||
if (!isDirtyRef.current) return
|
||||
if (autosaveTimerRef.current) clearTimeout(autosaveTimerRef.current)
|
||||
autosaveTimerRef.current = setTimeout(() => {
|
||||
performAutosave()
|
||||
}, 3000)
|
||||
return () => {
|
||||
if (autosaveTimerRef.current) clearTimeout(autosaveTimerRef.current)
|
||||
}
|
||||
}, [criteriaValues, globalScore, binaryDecision, feedbackText, performAutosave])
|
||||
|
||||
// Save on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isDirtyRef.current && !isSubmittedRef.current && evaluationIdRef.current) {
|
||||
performAutosave()
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleCriterionChange = (key: string, value: number | boolean | string) => {
|
||||
setCriteriaValues((prev) => ({ ...prev, [key]: value }))
|
||||
isDirtyRef.current = true
|
||||
}
|
||||
const handleGlobalScoreChange = (value: string) => {
|
||||
setGlobalScore(value)
|
||||
isDirtyRef.current = true
|
||||
}
|
||||
const handleBinaryChange = (value: 'accept' | 'reject') => {
|
||||
setBinaryDecision(value)
|
||||
isDirtyRef.current = true
|
||||
}
|
||||
const handleFeedbackChange = (value: string) => {
|
||||
setFeedbackText(value)
|
||||
isDirtyRef.current = true
|
||||
}
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
if (!assignment) {
|
||||
toast.error('Assignment not found')
|
||||
return
|
||||
}
|
||||
let evaluationId = evaluationIdRef.current
|
||||
if (!evaluationId) {
|
||||
if (!startPromiseRef.current) {
|
||||
startPromiseRef.current = startMutation.mutateAsync({ assignmentId: assignment.id })
|
||||
}
|
||||
const newEval = await startPromiseRef.current
|
||||
startPromiseRef.current = null
|
||||
evaluationId = newEval.id
|
||||
evaluationIdRef.current = evaluationId
|
||||
}
|
||||
autosaveMutation.mutate(
|
||||
{ id: evaluationId, ...buildSavePayload() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
isDirtyRef.current = false
|
||||
setLastSavedAt(new Date())
|
||||
toast.success('Draft saved', { duration: 1500 })
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const validateBeforeSubmit = (): string | null => {
|
||||
if (scoringMode === 'criteria') {
|
||||
const requiredCriteria = criteria.filter((c) => c.type !== 'section_header' && c.required)
|
||||
for (const c of requiredCriteria) {
|
||||
const val = criteriaValues[c.id]
|
||||
if (c.type === 'numeric' && (val === undefined || val === null)) return `Please score "${c.label}"`
|
||||
if ((c.type === 'boolean' || c.type === 'advance') && val === undefined) return `Please answer "${c.label}"`
|
||||
if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) return `Please fill in "${c.label}"`
|
||||
}
|
||||
}
|
||||
if (scoringMode === 'global') {
|
||||
const score = parseInt(globalScore, 10)
|
||||
if (isNaN(score) || score < 1 || score > 10) return 'Please enter a valid score between 1 and 10'
|
||||
}
|
||||
if (scoringMode === 'binary') {
|
||||
if (!binaryDecision) return 'Please select accept or reject'
|
||||
}
|
||||
if (requireFeedback) {
|
||||
if (!feedbackText.trim() || feedbackText.length < feedbackMinLength) {
|
||||
return `Please provide feedback (minimum ${feedbackMinLength} characters)`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handleOpenConfirm = () => {
|
||||
const error = validateBeforeSubmit()
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
return
|
||||
}
|
||||
setConfirmOpen(true)
|
||||
}
|
||||
|
||||
const handleConfirmedSubmit = async () => {
|
||||
if (autosaveTimerRef.current) {
|
||||
clearTimeout(autosaveTimerRef.current)
|
||||
autosaveTimerRef.current = null
|
||||
}
|
||||
if (!assignment) {
|
||||
toast.error('Assignment not found')
|
||||
return
|
||||
}
|
||||
isSubmittingRef.current = true
|
||||
setIsSubmitting(true)
|
||||
|
||||
let evaluationId = evaluationIdRef.current
|
||||
if (!evaluationId) {
|
||||
if (!startPromiseRef.current) {
|
||||
startPromiseRef.current = startMutation.mutateAsync({ assignmentId: assignment.id })
|
||||
}
|
||||
const newEval = await startPromiseRef.current
|
||||
startPromiseRef.current = null
|
||||
evaluationId = newEval.id
|
||||
evaluationIdRef.current = evaluationId
|
||||
}
|
||||
|
||||
// Compute global score from weighted criteria (same as juror page)
|
||||
const numericCriteria = criteria.filter((c) => c.type === 'numeric')
|
||||
let computedGlobalScore = 5
|
||||
if (scoringMode === 'criteria' && numericCriteria.length > 0) {
|
||||
let totalWeight = 0
|
||||
let weightedSum = 0
|
||||
for (const c of numericCriteria) {
|
||||
const val = criteriaValues[c.id]
|
||||
if (typeof val === 'number') {
|
||||
const w = c.weight ?? 1
|
||||
const normalized = ((val - c.minScore) / (c.maxScore - c.minScore)) * 9 + 1
|
||||
weightedSum += normalized * w
|
||||
totalWeight += w
|
||||
}
|
||||
}
|
||||
if (totalWeight > 0) {
|
||||
computedGlobalScore = Math.round(weightedSum / totalWeight)
|
||||
}
|
||||
}
|
||||
|
||||
submitMutation.mutate({
|
||||
id: evaluationId,
|
||||
criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : {},
|
||||
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : computedGlobalScore,
|
||||
binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true,
|
||||
feedbackText: feedbackText || 'No feedback provided',
|
||||
})
|
||||
}
|
||||
|
||||
if (jurorLoading || !round || !juror) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!assignment || !project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card className="border-l-4 border-l-red-500">
|
||||
<CardContent className="p-6">
|
||||
<p className="font-semibold">Assignment not found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
This juror does not have an assignment for this project in the selected round.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isSubmittedEvaluation = existingEvaluation?.status === 'SUBMITTED' || existingEvaluation?.status === 'LOCKED'
|
||||
const isReadOnly = isSubmittedEvaluation
|
||||
const hasCOI = assignment.conflictOfInterest?.hasConflict === true
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||
{isReadOnly ? 'Submitted Evaluation' : 'Fill In Evaluation'}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<p className="text-muted-foreground">{project.title}</p>
|
||||
{project.competitionCategory && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
project.competitionCategory === 'STARTUP'
|
||||
? 'bg-violet-100 text-violet-700 border-violet-200'
|
||||
: 'bg-sky-100 text-sky-700 border-sky-200'
|
||||
}
|
||||
>
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-l-4 border-l-amber-500 bg-amber-50/40">
|
||||
<CardContent className="flex items-start gap-3 p-4">
|
||||
<UserCheck className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-medium">
|
||||
Acting on behalf of {juror.name || juror.email}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Window and round-active checks are bypassed for this submission. A proxy audit entry
|
||||
is recorded with your admin ID and the juror's ID.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isReadOnly && (
|
||||
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50">
|
||||
<CardContent className="flex items-start gap-3 p-4">
|
||||
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">View-Only</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
This evaluation has already been submitted. To change it, reset via the
|
||||
round dashboard first.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{hasCOI && !isReadOnly && (
|
||||
<Card className="border-l-4 border-l-red-500 bg-red-50/40">
|
||||
<CardContent className="flex items-start gap-3 p-4">
|
||||
<Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-medium">Conflict of interest declared</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
The juror declared a conflict on this project. Reassign via the COI review
|
||||
workflow instead of submitting a proxy evaluation.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<MultiWindowDocViewer roundId={roundId} projectId={projectId} />
|
||||
|
||||
<EvaluationFormFields
|
||||
criteria={criteria}
|
||||
scoringMode={scoringMode}
|
||||
requireFeedback={requireFeedback}
|
||||
feedbackMinLength={feedbackMinLength}
|
||||
criteriaValues={criteriaValues}
|
||||
globalScore={globalScore}
|
||||
binaryDecision={binaryDecision}
|
||||
feedbackText={feedbackText}
|
||||
isReadOnly={isReadOnly || hasCOI}
|
||||
lastSavedAt={lastSavedAt}
|
||||
onCriterionChange={handleCriterionChange}
|
||||
onGlobalScoreChange={handleGlobalScoreChange}
|
||||
onBinaryChange={handleBinaryChange}
|
||||
onFeedbackChange={handleFeedbackChange}
|
||||
/>
|
||||
|
||||
{isReadOnly ? (
|
||||
<div className="flex items-center">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={backHref}>Cancel</Link>
|
||||
</Button>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSaveDraft}
|
||||
disabled={autosaveMutation.isPending || submitMutation.isPending || hasCOI}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{autosaveMutation.isPending ? 'Saving...' : 'Save Draft'}
|
||||
</Button>
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
onClick={handleOpenConfirm}
|
||||
disabled={submitMutation.isPending || isSubmitting || hasCOI}
|
||||
className="bg-brand-blue hover:bg-brand-blue-light"
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{submitMutation.isPending ? 'Submitting...' : 'Submit on behalf'}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Submit on behalf of {juror.name || juror.email}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will submit the evaluation as if it came from the juror. The current
|
||||
voting window and round-active status will be bypassed. The audit log will
|
||||
record both your admin ID and the juror's ID. This action cannot be
|
||||
undone without resetting the evaluation first.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isSubmitting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmedSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="bg-brand-blue hover:bg-brand-blue-light"
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Yes, submit'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Pencil,
|
||||
ShieldAlert,
|
||||
UserCheck,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ roundId: string; userId: string }>
|
||||
}
|
||||
|
||||
export default function AdminJurorProxyEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
const { roundId, userId } = use(paramsPromise)
|
||||
|
||||
const { data, isLoading } = trpc.evaluation.getJurorAssignmentsForRound.useQuery({
|
||||
roundId,
|
||||
userId,
|
||||
})
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { juror, round, assignments } = data
|
||||
const pending = assignments.filter((a) => a.evaluation?.status !== 'SUBMITTED' && a.evaluation?.status !== 'LOCKED')
|
||||
const completed = assignments.filter((a) => a.evaluation?.status === 'SUBMITTED' || a.evaluation?.status === 'LOCKED')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${roundId}` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Round
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||
Proxy Evaluations
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Filling in on behalf of <span className="font-semibold">{juror.name || juror.email}</span>{' '}
|
||||
· {round.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-l-4 border-l-amber-500">
|
||||
<CardContent className="flex items-start gap-3 p-4">
|
||||
<UserCheck className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-medium">You are acting on behalf of a juror</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Each submission is recorded in the audit log with your admin ID and the juror's
|
||||
ID. Voting-window and round-active checks are bypassed for proxy submissions, but
|
||||
COI-declared projects cannot be proxy-submitted.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Pending assignments</CardTitle>
|
||||
<CardDescription>
|
||||
{pending.length === 0
|
||||
? 'No pending evaluations — this juror has completed everything assigned to them.'
|
||||
: `${pending.length} project${pending.length === 1 ? '' : 's'} awaiting evaluation.`}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{completed.length}/{assignments.length} complete
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{pending.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">Nothing to do here.</p>
|
||||
) : (
|
||||
pending.map((a) => (
|
||||
<AssignmentRow
|
||||
key={a.id}
|
||||
roundId={roundId}
|
||||
userId={userId}
|
||||
assignment={a}
|
||||
mode="pending"
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{completed.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Completed assignments</CardTitle>
|
||||
<CardDescription>
|
||||
Already submitted. Click to view (read-only).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{completed.map((a) => (
|
||||
<AssignmentRow
|
||||
key={a.id}
|
||||
roundId={roundId}
|
||||
userId={userId}
|
||||
assignment={a}
|
||||
mode="completed"
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type AssignmentRowProps = {
|
||||
roundId: string
|
||||
userId: string
|
||||
assignment: {
|
||||
id: string
|
||||
isCompleted: boolean
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
competitionCategory: 'STARTUP' | 'BUSINESS_CONCEPT' | null
|
||||
teamName: string | null
|
||||
}
|
||||
evaluation: {
|
||||
id: string
|
||||
status: 'NOT_STARTED' | 'DRAFT' | 'SUBMITTED' | 'LOCKED'
|
||||
globalScore: number | null
|
||||
binaryDecision: boolean | null
|
||||
submittedAt: Date | null
|
||||
} | null
|
||||
conflictOfInterest: { id: string; hasConflict: boolean } | null
|
||||
}
|
||||
mode: 'pending' | 'completed'
|
||||
}
|
||||
|
||||
function AssignmentRow({ roundId, userId, assignment, mode }: AssignmentRowProps) {
|
||||
const { project, evaluation, conflictOfInterest } = assignment
|
||||
const hasCOI = conflictOfInterest?.hasConflict === true
|
||||
|
||||
const statusLabel = evaluation?.status === 'SUBMITTED'
|
||||
? 'Submitted'
|
||||
: evaluation?.status === 'DRAFT'
|
||||
? 'Draft in progress'
|
||||
: evaluation?.status === 'LOCKED'
|
||||
? 'Locked'
|
||||
: 'Not started'
|
||||
|
||||
const statusColor =
|
||||
evaluation?.status === 'SUBMITTED' || evaluation?.status === 'LOCKED'
|
||||
? 'text-emerald-600'
|
||||
: evaluation?.status === 'DRAFT'
|
||||
? 'text-amber-600'
|
||||
: 'text-muted-foreground'
|
||||
|
||||
const StatusIcon =
|
||||
evaluation?.status === 'SUBMITTED' || evaluation?.status === 'LOCKED'
|
||||
? CheckCircle2
|
||||
: evaluation?.status === 'DRAFT'
|
||||
? Pencil
|
||||
: Clock
|
||||
|
||||
const href = `/admin/rounds/${roundId}/jurors/${userId}/evaluate/${project.id}` as Route
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-3 p-3 rounded-lg border transition-colors',
|
||||
mode === 'pending' ? 'hover:bg-muted/30' : 'bg-muted/20',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium text-sm truncate">{project.title}</p>
|
||||
{project.competitionCategory && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
project.competitionCategory === 'STARTUP'
|
||||
? 'bg-violet-100 text-violet-700 border-violet-200'
|
||||
: 'bg-sky-100 text-sky-700 border-sky-200',
|
||||
)}
|
||||
>
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
</Badge>
|
||||
)}
|
||||
{hasCOI && (
|
||||
<Badge variant="destructive" className="shrink-0 gap-1">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
COI declared
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn('flex items-center gap-1.5 text-xs mt-1', statusColor)}>
|
||||
<StatusIcon className="h-3.5 w-3.5" />
|
||||
<span>{statusLabel}</span>
|
||||
{evaluation?.status === 'SUBMITTED' && evaluation.globalScore !== null && (
|
||||
<span className="ml-2 text-muted-foreground">Score: {evaluation.globalScore}/10</span>
|
||||
)}
|
||||
{project.teamName && (
|
||||
<span className="ml-2 text-muted-foreground">· {project.teamName}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant={mode === 'pending' ? 'default' : 'outline'} asChild disabled={hasCOI}>
|
||||
<Link href={href} aria-disabled={hasCOI} className={hasCOI ? 'pointer-events-none opacity-50' : ''}>
|
||||
{mode === 'pending' ? 'Fill in' : 'View'}
|
||||
<ArrowRight className="ml-1.5 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -91,6 +91,10 @@ import { ProjectStatesTable } from '@/components/admin/round/project-states-tabl
|
||||
// SubmissionWindowManager removed — round dates + file requirements in Config are sufficient
|
||||
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
|
||||
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
||||
import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview'
|
||||
import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table'
|
||||
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
|
||||
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
|
||||
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
|
||||
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
||||
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
||||
@@ -145,6 +149,95 @@ const stateColors: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(projectStateConfig).map(([k, v]) => [k, v.bg])
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -514,6 +607,16 @@ export default function RoundDetailPage() {
|
||||
|
||||
const isFiltering = round?.roundType === 'FILTERING'
|
||||
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 hasAwards = roundAwards.length > 0
|
||||
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
|
||||
@@ -589,7 +692,8 @@ export default function RoundDetailPage() {
|
||||
action: undefined as Route | 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',
|
||||
@@ -1161,17 +1265,32 @@ export default function RoundDetailPage() {
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Project Management</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<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">
|
||||
{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">
|
||||
Add projects from the pool to this round
|
||||
Open the Projects tab to add or auto-fill teams in this round
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={poolLink}>
|
||||
<button className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full">
|
||||
<Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Assign Projects</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Add projects from the pool to this round
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('projects')}
|
||||
@@ -1400,6 +1519,17 @@ export default function RoundDetailPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mentoring-specific stats \u2014 only on MENTORING rounds */}
|
||||
{isMentoring && <MentoringRoundOverview roundId={roundId} />}
|
||||
|
||||
{/* Grand-finale logistics \u2014 only on LIVE_FINAL rounds */}
|
||||
{isGrandFinale && programId && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FinalistSlotsCard programId={programId} />
|
||||
<WaitlistCard programId={programId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Round Info + Project Breakdown */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<AnimatedCard index={2}>
|
||||
@@ -1413,7 +1543,9 @@ export default function RoundDetailPage() {
|
||||
{ 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> },
|
||||
...(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: 'Closes', value: <span className="font-medium">{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}</span> },
|
||||
].map((row, i) => (
|
||||
@@ -1475,17 +1607,30 @@ export default function RoundDetailPage() {
|
||||
|
||||
{/* ═══════════ PROJECTS TAB ═══════════ */}
|
||||
<TabsContent value="projects" className="space-y-4">
|
||||
<ProjectStatesTable
|
||||
competitionId={competitionId}
|
||||
roundId={roundId}
|
||||
roundStatus={round?.status}
|
||||
competitionRounds={competition?.rounds}
|
||||
currentSortOrder={round?.sortOrder}
|
||||
onAssignProjects={() => {
|
||||
setActiveTab('assignments')
|
||||
setTimeout(() => setPreviewSheetOpen(true), 100)
|
||||
}}
|
||||
/>
|
||||
{isMentoring && (
|
||||
<>
|
||||
<MentoringBulkAssignToolbar roundId={roundId} configJson={config} />
|
||||
<MentoringProjectsTable
|
||||
roundId={roundId}
|
||||
competitionId={competitionId}
|
||||
competitionRounds={competition?.rounds}
|
||||
currentSortOrder={round?.sortOrder}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isMentoring && (
|
||||
<ProjectStatesTable
|
||||
competitionId={competitionId}
|
||||
roundId={roundId}
|
||||
roundStatus={round?.status}
|
||||
competitionRounds={competition?.rounds}
|
||||
currentSortOrder={round?.sortOrder}
|
||||
onAssignProjects={() => {
|
||||
setActiveTab('assignments')
|
||||
setTimeout(() => setPreviewSheetOpen(true), 100)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ═══════════ FILTERING TAB ═══════════ */}
|
||||
@@ -1977,39 +2122,39 @@ export default function RoundDetailPage() {
|
||||
</p>
|
||||
)}
|
||||
{aiAssignmentMutation.isPending && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-violet-50 border border-violet-200 dark:bg-violet-950/20 dark:border-violet-800">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-violet-50 border border-violet-200">
|
||||
<div className="relative">
|
||||
<div className="h-8 w-8 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-violet-800 dark:text-violet-200">AI is analyzing projects and jurors...</p>
|
||||
<p className="text-xs text-violet-600 dark:text-violet-400">
|
||||
<p className="text-sm font-medium text-violet-800">AI is analyzing projects and jurors...</p>
|
||||
<p className="text-xs text-violet-600">
|
||||
Matching expertise, reviewing bios, and balancing workloads
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{aiAssignmentMutation.error && !aiAssignmentMutation.isPending && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-red-50 border border-red-200 dark:bg-red-950/20 dark:border-red-800">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-red-50 border border-red-200">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
<p className="text-sm font-medium text-red-800">
|
||||
AI generation failed
|
||||
</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-400">
|
||||
<p className="text-xs text-red-600">
|
||||
{aiAssignmentMutation.error.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 border border-emerald-200 dark:bg-emerald-950/20 dark:border-emerald-800">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 border border-emerald-200">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-200">
|
||||
<p className="text-sm font-medium text-emerald-800">
|
||||
{aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated
|
||||
</p>
|
||||
<p className="text-xs text-emerald-600 dark:text-emerald-400">
|
||||
<p className="text-xs text-emerald-600">
|
||||
{aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects
|
||||
{aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'}
|
||||
</p>
|
||||
@@ -2198,7 +2343,8 @@ export default function RoundDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* General Round Settings */}
|
||||
{/* General Round Settings — hidden on MENTORING rounds (no advancement targets apply) */}
|
||||
{!isMentoring && (
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<ConfigSectionHeader
|
||||
@@ -2321,6 +2467,7 @@ export default function RoundDetailPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Round-type-specific config */}
|
||||
<RoundConfigForm
|
||||
@@ -2489,9 +2636,9 @@ export default function RoundDetailPage() {
|
||||
|
||||
{/* Autosave error bar — only shows when save fails */}
|
||||
{autosaveStatus === 'error' && (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 border-t bg-red-50 dark:bg-red-950/50 shadow-[0_-4px_12px_rgba(0,0,0,0.1)]">
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 border-t bg-red-50 shadow-[0_-4px_12px_rgba(0,0,0,0.1)]">
|
||||
<div className="container flex items-center justify-between py-3 px-4 max-w-5xl mx-auto">
|
||||
<div className="flex items-center gap-2 text-sm text-red-700 dark:text-red-300">
|
||||
<div className="flex items-center gap-2 text-sm text-red-700">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>Auto-save failed</span>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma'
|
||||
import { requireRole } from '@/lib/auth-redirect'
|
||||
import { AdminSidebar } from '@/components/layouts/admin-sidebar'
|
||||
import { AdminEditionWrapper } from '@/components/layouts/admin-edition-wrapper'
|
||||
import { RoleSwitcherPill } from '@/components/layouts/role-switcher'
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
@@ -34,6 +35,12 @@ export default async function AdminLayout({
|
||||
<main className="lg:pl-64">
|
||||
{/* Spacer for mobile header */}
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -8,69 +8,72 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import {
|
||||
Star,
|
||||
MessageSquare,
|
||||
Trophy,
|
||||
Vote,
|
||||
TrendingUp,
|
||||
BarChart3,
|
||||
Award,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
import { Star, MessageSquare, Trophy, Vote, ShieldCheck } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Criterion = {
|
||||
id?: string
|
||||
type?: string
|
||||
label?: string
|
||||
name?: string
|
||||
scale?: string
|
||||
maxScore?: number
|
||||
}
|
||||
|
||||
type Evaluation = {
|
||||
id: string
|
||||
submittedAt: Date | null
|
||||
globalScore: number | null
|
||||
criterionScores: unknown
|
||||
feedbackText: string | null
|
||||
criteria: unknown
|
||||
}
|
||||
|
||||
type EvaluationRound = {
|
||||
roundId: string
|
||||
roundName: string
|
||||
roundType: string
|
||||
evaluationCount: number
|
||||
evaluations: Array<{
|
||||
id: string
|
||||
submittedAt: Date | null
|
||||
globalScore: number | null
|
||||
criterionScores: unknown
|
||||
feedbackText: string | null
|
||||
criteria: unknown
|
||||
}>
|
||||
evaluations: Evaluation[]
|
||||
}
|
||||
|
||||
function computeRoundStats(round: EvaluationRound) {
|
||||
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100
|
||||
const HIDDEN_CRITERION_TYPES = new Set(['boolean', 'advance'])
|
||||
|
||||
function parseScaleMax(scale: string | undefined, fallback = 10): number {
|
||||
if (!scale) return fallback
|
||||
const m = scale.match(/^\s*\d+\s*-\s*(\d+)\s*$/)
|
||||
if (m) return Number(m[1])
|
||||
return fallback
|
||||
}
|
||||
|
||||
function getCriterionMax(c: Criterion): number {
|
||||
if (typeof c.maxScore === 'number' && c.maxScore > 0) return c.maxScore
|
||||
return parseScaleMax(c.scale)
|
||||
}
|
||||
|
||||
function visibleCriteria(criteria: unknown): Criterion[] {
|
||||
if (!Array.isArray(criteria)) return []
|
||||
return (criteria as Criterion[]).filter((c) => {
|
||||
if (!c) return false
|
||||
if (!c.id && !c.label && !c.name) return false
|
||||
if (c.type && HIDDEN_CRITERION_TYPES.has(c.type)) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function globalScoreSummary(round: EvaluationRound) {
|
||||
if (round.roundType === 'DELIBERATION') return null
|
||||
const scores = round.evaluations
|
||||
.map((ev) => ev.globalScore)
|
||||
.filter((s): s is number => s !== null)
|
||||
const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null
|
||||
const highest = scores.length > 0 ? Math.max(...scores) : null
|
||||
const lowest = scores.length > 0 ? Math.min(...scores) : null
|
||||
return { maxScore, avg, highest, lowest, scores }
|
||||
}
|
||||
|
||||
function ScoreBar({ score, maxScore, color }: { score: number; maxScore: number; color: string }) {
|
||||
const pct = (score / maxScore) * 100
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="flex-1 overflow-hidden rounded-full bg-muted" style={{ height: 10 }}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold tabular-nums w-8 text-right">{score}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getScoreColor(score: number, maxScore: number): string {
|
||||
const pct = score / maxScore
|
||||
if (pct >= 0.8) return '#053d57'
|
||||
if (pct >= 0.6) return '#1e7a8a'
|
||||
if (pct >= 0.4) return '#557f8c'
|
||||
if (pct >= 0.2) return '#c4453a'
|
||||
return '#de0f1e'
|
||||
if (scores.length === 0) return null
|
||||
const max = 10
|
||||
const avg = scores.reduce((a, b) => a + b, 0) / scores.length
|
||||
const lowest = Math.min(...scores)
|
||||
const highest = Math.max(...scores)
|
||||
return { avg, lowest, highest, max }
|
||||
}
|
||||
|
||||
function RoundIcon({ roundType, className }: { roundType: string; className?: string }) {
|
||||
@@ -85,6 +88,48 @@ function roundIconBg(roundType: string) {
|
||||
return 'bg-yellow-500/10'
|
||||
}
|
||||
|
||||
function CriterionBar({ value, max }: { value: number; max: number }) {
|
||||
const pct = Math.max(0, Math.min(100, (value / max) * 100))
|
||||
return (
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-blue transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NumericCriterion({ label, score, max }: { label: string; score: number | undefined; max: number }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{score !== undefined ? score : '—'}
|
||||
<span className="text-muted-foreground font-normal text-xs"> / {max}</span>
|
||||
</span>
|
||||
</div>
|
||||
{score !== undefined && <CriterionBar value={score} max={max} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TextCriterion({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/40 px-4 py-3 border-l-3 border-brand-teal">
|
||||
<p className="text-sm italic text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ApplicantEvaluationsPage() {
|
||||
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
|
||||
|
||||
@@ -95,14 +140,6 @@ export default function ApplicantEvaluationsPage() {
|
||||
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
||||
<p className="text-muted-foreground">Anonymous evaluations from jury members</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-px overflow-hidden rounded-lg border bg-border">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-card p-4">
|
||||
<Skeleton className="h-5 w-20 mb-2" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<Card key={i}>
|
||||
@@ -119,34 +156,14 @@ export default function ApplicantEvaluationsPage() {
|
||||
|
||||
const hasEvaluations = rounds && rounds.length > 0
|
||||
|
||||
// Compute global stats
|
||||
const allScores: number[] = []
|
||||
let totalEvaluations = 0
|
||||
if (rounds) {
|
||||
for (const round of rounds) {
|
||||
totalEvaluations += round.evaluationCount
|
||||
for (const ev of round.evaluations) {
|
||||
if (ev.globalScore !== null && round.roundType !== 'DELIBERATION') {
|
||||
// Normalize to 0-100 for live final scores
|
||||
const normalized = round.roundType === 'LIVE_FINAL'
|
||||
? ev.globalScore * 10
|
||||
: ev.globalScore
|
||||
allScores.push(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const globalAvg = allScores.length > 0
|
||||
? allScores.reduce((a, b) => a + b, 0) / allScores.length
|
||||
: null
|
||||
const globalHighest = allScores.length > 0 ? Math.max(...allScores) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Anonymous evaluations from jury members
|
||||
{hasEvaluations
|
||||
? `Anonymous evaluations from jury members across ${rounds.length} round${rounds.length === 1 ? '' : 's'}.`
|
||||
: 'Anonymous evaluations from jury members.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -164,174 +181,100 @@ export default function ApplicantEvaluationsPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Summary Strip */}
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<div className="grid grid-cols-3 divide-x divide-border">
|
||||
<div className="p-4 text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 mb-1">
|
||||
<BarChart3 className="h-3.5 w-3.5 text-blue-500" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Reviews</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums">{totalEvaluations}</p>
|
||||
</div>
|
||||
<div className="p-4 text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 mb-1">
|
||||
<TrendingUp className="h-3.5 w-3.5 text-emerald-500" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Avg Score</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{globalAvg !== null ? globalAvg.toFixed(1) : '—'}
|
||||
{globalAvg !== null && <span className="text-sm font-normal text-muted-foreground"> / 100</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 mb-1">
|
||||
<Award className="h-3.5 w-3.5 text-amber-500" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Highest</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{globalHighest !== null ? globalHighest : '—'}
|
||||
{globalHighest !== null && <span className="text-sm font-normal text-muted-foreground"> / 100</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Per-Round Cards */}
|
||||
{rounds.map((round, roundIdx) => {
|
||||
const { maxScore, avg, highest, lowest } = computeRoundStats(round)
|
||||
const summary = globalScoreSummary(round)
|
||||
|
||||
return (
|
||||
<AnimatedCard key={round.roundId} index={roundIdx + 1}>
|
||||
<AnimatedCard key={round.roundId} index={roundIdx}>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<CardTitle className="flex items-center gap-2.5">
|
||||
<div className={cn('rounded-lg p-1.5', roundIconBg(round.roundType))}>
|
||||
<RoundIcon roundType={round.roundType} />
|
||||
</div>
|
||||
<div>
|
||||
<span>{round.roundName}</span>
|
||||
{avg !== null && round.roundType !== 'DELIBERATION' && (
|
||||
{summary && (
|
||||
<p className="text-sm font-normal text-muted-foreground mt-0.5">
|
||||
Average: <span className="font-semibold text-foreground">{avg.toFixed(1)}</span> / {maxScore}
|
||||
{highest !== null && lowest !== null && highest !== lowest && (
|
||||
<span className="ml-2">
|
||||
Range: {lowest}–{highest}
|
||||
</span>
|
||||
Average <span className="font-semibold text-foreground">{summary.avg.toFixed(1)}</span> / {summary.max}
|
||||
{summary.lowest !== summary.highest && (
|
||||
<span className="ml-2">· Lowest {summary.lowest} · Highest {summary.highest}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
<Badge variant="secondary">
|
||||
{round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'evaluation'}{round.evaluationCount !== 1 ? 's' : ''}
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'review'}{round.evaluationCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Score Overview Bar — visual comparison across evaluators */}
|
||||
{round.roundType !== 'DELIBERATION' && round.evaluations.some((ev) => ev.globalScore !== null) && (
|
||||
<div className="px-6 pb-3">
|
||||
<div className="rounded-lg bg-muted/40 p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Score Comparison</p>
|
||||
{round.evaluations.map((ev, idx) => {
|
||||
if (ev.globalScore === null) return null
|
||||
return (
|
||||
<div key={ev.id} className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground w-6 text-right shrink-0 tabular-nums">
|
||||
#{idx + 1}
|
||||
</span>
|
||||
<ScoreBar
|
||||
score={ev.globalScore}
|
||||
maxScore={maxScore}
|
||||
color={getScoreColor(ev.globalScore, maxScore)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{round.evaluations.map((ev, idx) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="px-6 py-4 space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">
|
||||
{round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{ev.globalScore !== null && round.roundType !== 'DELIBERATION' && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 text-yellow-500" />
|
||||
<span className="text-sm font-bold tabular-nums">{ev.globalScore}</span>
|
||||
<span className="text-xs text-muted-foreground">/ {maxScore}</span>
|
||||
</span>
|
||||
)}
|
||||
{ev.submittedAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(ev.submittedAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{round.evaluations.map((ev, idx) => {
|
||||
const criteria = visibleCriteria(ev.criteria)
|
||||
const scores = (ev.criterionScores ?? {}) as Record<string, unknown>
|
||||
|
||||
return (
|
||||
<div key={ev.id} className="px-6 py-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">
|
||||
{round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{ev.globalScore !== null && round.roundType !== 'DELIBERATION' && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 text-yellow-500" />
|
||||
<span className="text-sm font-bold tabular-nums">{ev.globalScore}</span>
|
||||
<span className="text-xs text-muted-foreground">/ 10</span>
|
||||
</span>
|
||||
)}
|
||||
{ev.submittedAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(ev.submittedAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{criteria.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{criteria.map((c, ci) => {
|
||||
const key = c.id || String(ci)
|
||||
const label = c.label || c.name || `Criterion ${ci + 1}`
|
||||
const raw = scores[key]
|
||||
|
||||
if (c.type === 'text') {
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return null
|
||||
return <TextCriterion key={key} label={label} value={raw} />
|
||||
}
|
||||
|
||||
// numeric (default)
|
||||
const score = typeof raw === 'number' ? raw : undefined
|
||||
const max = getCriterionMax(c)
|
||||
return <NumericCriterion key={key} label={label} score={score} max={max} />
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ev.feedbackText && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'}
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/40 px-4 py-3 border-l-3 border-brand-teal">
|
||||
<p className="text-sm italic text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{ev.feedbackText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ev.criterionScores && ev.criteria && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Criteria Breakdown</p>
|
||||
<div className="grid gap-2">
|
||||
{(() => {
|
||||
const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
|
||||
const scores = ev.criterionScores as Record<string, number>
|
||||
return criteria
|
||||
.filter((c) => c.id || c.label || c.name)
|
||||
.map((c, ci) => {
|
||||
const key = c.id || String(ci)
|
||||
const score = scores[key]
|
||||
const cMax = c.maxScore || 10
|
||||
const pct = score !== undefined ? (score / cMax) * 100 : 0
|
||||
return (
|
||||
<div key={ci} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{c.label || c.name || `Criterion ${ci + 1}`}</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{score !== undefined ? score : '—'}
|
||||
<span className="text-muted-foreground font-normal text-xs"> / {cMax}</span>
|
||||
</span>
|
||||
</div>
|
||||
{score !== undefined && (
|
||||
<Progress value={pct} className="h-1.5" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ev.feedbackText && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'}
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/40 px-4 py-3 border-l-3 border-brand-teal">
|
||||
<p className="text-sm italic text-muted-foreground leading-relaxed">
|
||||
{ev.feedbackText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -339,7 +282,6 @@ export default function ApplicantEvaluationsPage() {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Confidentiality Footer */}
|
||||
<div className="flex items-center justify-center gap-2 py-2">
|
||||
<ShieldCheck className="h-4 w-4 text-muted-foreground/60" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,138 +1,230 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||
import {
|
||||
MessageSquare,
|
||||
UserCircle,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
|
||||
export default function ApplicantMentorPage() {
|
||||
const { data: session, status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
|
||||
const { data: dashboardData, isLoading: dashLoading } = trpc.applicant.getMyDashboard.useQuery(
|
||||
undefined,
|
||||
{ enabled: isAuthenticated }
|
||||
)
|
||||
|
||||
const projectId = dashboardData?.project?.id
|
||||
|
||||
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
|
||||
{ projectId: projectId! },
|
||||
{ enabled: !!projectId }
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.applicant.getMentorMessages.invalidate({ projectId: projectId! })
|
||||
},
|
||||
})
|
||||
|
||||
if (dashLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Mentor</h1>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">No Project</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
Submit a project first to communicate with your mentor.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mentor = dashboardData?.project?.mentorAssignment?.mentor
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<MessageSquare className="h-6 w-6" />
|
||||
Mentor Communication
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Chat with your assigned mentor
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mentor info */}
|
||||
{mentor ? (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserCircle className="h-10 w-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">{mentor.name || 'Mentor'}</p>
|
||||
<p className="text-sm text-muted-foreground">{mentor.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||
<UserCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-muted-foreground text-center">
|
||||
No mentor has been assigned to your project yet.
|
||||
You'll be notified when a mentor is assigned.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Chat */}
|
||||
{mentor && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Messages</CardTitle>
|
||||
<CardDescription>
|
||||
Your conversation history with {mentor.name || 'your mentor'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MentorChat
|
||||
messages={mentorMessages || []}
|
||||
currentUserId={session?.user?.id || ''}
|
||||
onSendMessage={async (message) => {
|
||||
await sendMessage.mutateAsync({ projectId: projectId!, message })
|
||||
}}
|
||||
isLoading={messagesLoading}
|
||||
isSending={sendMessage.isPending}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { format } from 'date-fns'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
||||
import { RequestChangeDialog } from './request-change-dialog'
|
||||
import {
|
||||
MessageSquare,
|
||||
UserCircle,
|
||||
FileText,
|
||||
UserCog,
|
||||
} from 'lucide-react'
|
||||
|
||||
export default function ApplicantMentorPage() {
|
||||
const { data: session, status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
|
||||
const { data: dashboardData, isLoading: dashLoading } = trpc.applicant.getMyDashboard.useQuery(
|
||||
undefined,
|
||||
{ enabled: isAuthenticated }
|
||||
)
|
||||
|
||||
const projectId = dashboardData?.project?.id
|
||||
|
||||
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
|
||||
{ projectId: projectId! },
|
||||
{ enabled: !!projectId }
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.applicant.getMentorMessages.invalidate({ projectId: projectId! })
|
||||
},
|
||||
})
|
||||
|
||||
const [isChangeOpen, setIsChangeOpen] = useState(false)
|
||||
|
||||
if (dashLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Mentor</h1>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">No Project</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
Submit a project first to communicate with your mentor.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const assignments = dashboardData?.project?.mentorAssignments ?? []
|
||||
const hasMentors = assignments.length > 0
|
||||
const primaryAssignment = assignments[0] ?? null
|
||||
const primaryMentor = primaryAssignment?.mentor
|
||||
const hasPendingChangeRequest = !!dashboardData?.hasPendingMentorChangeRequest
|
||||
|
||||
const dialogMentors = assignments
|
||||
.filter((a) => !!a.mentor)
|
||||
.map((a) => ({
|
||||
assignmentId: a.id,
|
||||
name: a.mentor?.name || a.mentor?.email || 'Mentor',
|
||||
}))
|
||||
|
||||
const teamHeading = assignments.length > 1 ? 'Your mentor team' : 'Your mentor'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<MessageSquare className="h-6 w-6" />
|
||||
Mentor Communication
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{assignments.length > 1
|
||||
? 'Chat with your assigned mentor team'
|
||||
: 'Chat with your assigned mentor'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mentor list */}
|
||||
{hasMentors ? (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{teamHeading}</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{assignments.map((assignment) => {
|
||||
const mentor = assignment.mentor
|
||||
if (!mentor) return null
|
||||
const expertise = mentor.expertiseTags ?? []
|
||||
return (
|
||||
<Card key={assignment.id} className="bg-muted/50">
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<UserCircle className="h-10 w-10 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">
|
||||
{mentor.name || 'Mentor'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{mentor.email}
|
||||
</p>
|
||||
{assignment.assignedAt && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Assigned since {format(new Date(assignment.assignedAt), 'MMM d, yyyy')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{expertise.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{expertise.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="font-normal">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Request change action */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pt-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hasPendingChangeRequest
|
||||
? "You have a pending mentor change request — admins will follow up soon."
|
||||
: 'Need a different match? Let the program admins know.'}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsChangeOpen(true)}
|
||||
disabled={hasPendingChangeRequest}
|
||||
>
|
||||
<UserCog className="mr-2 h-4 w-4" />
|
||||
{hasPendingChangeRequest ? 'Change requested' : 'Request a mentor change'}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||
<UserCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-muted-foreground text-center">
|
||||
No mentor has been assigned to your project yet.
|
||||
You'll be notified when a mentor is assigned.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Chat */}
|
||||
{primaryMentor && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Messages</CardTitle>
|
||||
<CardDescription>
|
||||
{assignments.length > 1
|
||||
? 'Your conversation history with your mentor team'
|
||||
: `Your conversation history with ${primaryMentor.name || 'your mentor'}`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MentorChat
|
||||
messages={mentorMessages || []}
|
||||
currentUserId={session?.user?.id || ''}
|
||||
onSendMessage={async (message) => {
|
||||
await sendMessage.mutateAsync({ projectId: projectId!, message })
|
||||
}}
|
||||
isLoading={messagesLoading}
|
||||
isSending={sendMessage.isPending}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{primaryAssignment?.id && projectId && (
|
||||
<WorkspaceFilesPanel
|
||||
projectId={projectId}
|
||||
mentorAssignmentId={primaryAssignment.id}
|
||||
asApplicant
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Request change dialog */}
|
||||
{projectId && (
|
||||
<RequestChangeDialog
|
||||
projectId={projectId}
|
||||
mentors={dialogMentors}
|
||||
open={isChangeOpen}
|
||||
onOpenChange={setIsChangeOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,10 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
|
||||
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
|
||||
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card'
|
||||
import { AttendingMembersCard } from '@/components/applicant/attending-members-card'
|
||||
import { LunchBanner } from '@/components/applicant/lunch-banner'
|
||||
import { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
@@ -215,12 +219,12 @@ export default function ApplicantDashboardPage() {
|
||||
key={round.id}
|
||||
className={`flex flex-col sm:flex-row items-start sm:items-center gap-3 rounded-lg border px-4 py-3 ${
|
||||
isUrgent
|
||||
? 'border-amber-500/50 bg-amber-50 dark:bg-amber-950/20'
|
||||
? 'border-amber-500/50 bg-amber-50'
|
||||
: 'border-primary/20 bg-primary/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Clock className={`h-4 w-4 shrink-0 ${isUrgent ? 'text-amber-600 dark:text-amber-400' : 'text-primary'}`} />
|
||||
<Clock className={`h-4 w-4 shrink-0 ${isUrgent ? 'text-amber-600' : 'text-primary'}`} />
|
||||
<span className="font-medium text-sm truncate">{round.name}</span>
|
||||
<Badge variant={isUrgent ? 'warning' : 'default'} className="shrink-0">
|
||||
{remaining > 0 ? formatCountdown(remaining) + ' left' : 'Closed'}
|
||||
@@ -401,6 +405,19 @@ export default function ApplicantDashboardPage() {
|
||||
</AnimatedCard>
|
||||
))}
|
||||
|
||||
{/* Lunch banner (auto-hides when lunch event disabled or unconfigured) */}
|
||||
<LunchBanner programId={project.programId} />
|
||||
|
||||
{/* External lunch attendees attached to this team (auto-hides if none) */}
|
||||
<ExternalAttendeesStrip projectId={project.id} />
|
||||
|
||||
{/* Grand finale attendee roster (auto-hides until confirmation status is CONFIRMED) */}
|
||||
<AttendingMembersCard />
|
||||
|
||||
{/* Conversation with assigned mentor (auto-hides when no mentor assigned) */}
|
||||
<MentorConversationCard projectId={project.id} />
|
||||
|
||||
|
||||
{/* Jury Feedback Card */}
|
||||
{totalEvaluations > 0 && (
|
||||
<AnimatedCard index={4}>
|
||||
@@ -422,13 +439,14 @@ export default function ApplicantDashboardPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{evaluations?.map((round) => {
|
||||
const showScore = round.roundType !== 'DELIBERATION'
|
||||
const scores = round.evaluations
|
||||
.map((ev) => ev.globalScore)
|
||||
.filter((s): s is number => s !== null)
|
||||
const avgScore = scores.length > 0
|
||||
const avgScore = showScore && scores.length > 0
|
||||
? scores.reduce((a, b) => a + b, 0) / scores.length
|
||||
: null
|
||||
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100
|
||||
const maxScore = 10
|
||||
const pct = avgScore !== null ? (avgScore / maxScore) * 100 : 0
|
||||
const roundIcon = round.roundType === 'LIVE_FINAL'
|
||||
? <Trophy className="h-3.5 w-3.5 text-amber-500" />
|
||||
|
||||
@@ -357,15 +357,33 @@ export default function ApplicantProjectPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mentor info */}
|
||||
{project.mentorAssignment?.mentor && (
|
||||
<div className="rounded-lg border p-3 bg-muted/50">
|
||||
<p className="text-sm font-medium mb-1">Assigned Mentor</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.mentorAssignment.mentor.name} ({project.mentorAssignment.mentor.email})
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
type MentorAssignment = {
|
||||
droppedAt: Date | string | null
|
||||
mentor: { name: string | null; email: string } | null
|
||||
}
|
||||
const active = (
|
||||
(project.mentorAssignments as MentorAssignment[] | undefined) ?? []
|
||||
).filter((a) => !a.droppedAt && a.mentor)
|
||||
if (active.length === 0) return null
|
||||
return (
|
||||
<div className="rounded-lg border p-3 bg-muted/50">
|
||||
<p className="text-sm font-medium mb-1">
|
||||
{active.length === 1 ? 'Assigned Mentor' : `Assigned Mentors (${active.length})`}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{active.map((a, idx) => (
|
||||
<li key={`${a.mentor!.email}-${idx}`} className="text-sm text-muted-foreground">
|
||||
{a.mentor!.name ?? a.mentor!.email}
|
||||
{a.mentor!.name && (
|
||||
<span className="text-xs"> ({a.mentor!.email})</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Tags */}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
|
||||
@@ -160,8 +160,12 @@ function AcceptInviteContent() {
|
||||
setState('error')
|
||||
setErrorType('AUTH_FAILED')
|
||||
} else if (result?.ok) {
|
||||
// Redirect to set-password (middleware will enforce this since mustSetPassword=true)
|
||||
window.location.href = '/set-password'
|
||||
// Let app/page.tsx route by role. Middleware will detour to
|
||||
// /set-password if the user still needs to set one (first-time
|
||||
// setup); for users who already had a password (admin-issued
|
||||
// access link, magic-login style) it'll go straight to their
|
||||
// dashboard.
|
||||
window.location.href = '/'
|
||||
}
|
||||
} catch {
|
||||
setState('error')
|
||||
|
||||
@@ -1,581 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Trophy,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
FileText,
|
||||
Star,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
import { ProjectFilesSection } from '@/components/jury/project-files-section'
|
||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||
|
||||
export default function AwardMasterVotingPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: awardId } = use(params)
|
||||
const router = useRouter()
|
||||
|
||||
// State
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const [expandedProjectId, setExpandedProjectId] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const [justification, setJustification] = useState('')
|
||||
|
||||
// Queries & mutations
|
||||
const utils = trpc.useUtils()
|
||||
const { data, isLoading } =
|
||||
trpc.specialAward.getMyAwardDetailEnhanced.useQuery({ awardId })
|
||||
|
||||
const submitVote = trpc.specialAward.submitAwardMasterVote.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.specialAward.getMyAwardDetailEnhanced.invalidate({ awardId })
|
||||
toast.success('Vote submitted')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const confirmWinner = trpc.specialAward.confirmWinner.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.specialAward.getMyAwardDetailEnhanced.invalidate({ awardId })
|
||||
toast.success('Winner confirmed and award closed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Initialize selection from existing vote
|
||||
const initializedRef = useRef(false)
|
||||
if (data && !initializedRef.current && data.myVotes.length > 0) {
|
||||
initializedRef.current = true
|
||||
setSelectedProjectId(data.myVotes[0].projectId)
|
||||
if (data.myVotes[0].justification) {
|
||||
setJustification(data.myVotes[0].justification)
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-6 w-72" />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-44" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
// Destructure data
|
||||
const { award, projects, myVotes, isChair, otherVotes, totalJurors } = data
|
||||
const hasVoted = myVotes.length > 0
|
||||
const isVotingOpen = award.status === 'VOTING_OPEN'
|
||||
const isClosed = award.status === 'CLOSED'
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId)
|
||||
|
||||
// Toggle project expansion
|
||||
const handleProjectClick = (projectId: string) => {
|
||||
if (isVotingOpen) setSelectedProjectId(projectId)
|
||||
setExpandedProjectId(expandedProjectId === projectId ? null : projectId)
|
||||
}
|
||||
|
||||
// Submit vote handler
|
||||
const handleSubmitVote = () => {
|
||||
if (!selectedProjectId) return
|
||||
submitVote.mutate({
|
||||
awardId,
|
||||
projectId: selectedProjectId,
|
||||
justification: justification.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// Confirm winner handler
|
||||
const handleConfirmWinner = () => {
|
||||
confirmWinner.mutate({ awardId })
|
||||
}
|
||||
|
||||
// Find the winner project for closed state
|
||||
const winnerProject = isClosed
|
||||
? projects.find((p) => p.id === award.winnerProjectId)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back button */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => router.push('/award-master' as Route)}
|
||||
className="-ml-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<Trophy className="h-6 w-6 text-amber-500" />
|
||||
{award.name}
|
||||
</h1>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
isVotingOpen
|
||||
? 'default'
|
||||
: isClosed
|
||||
? 'secondary'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{award.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
{hasVoted && !isClosed && (
|
||||
<Badge variant="outline" className="text-green-600">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Voted
|
||||
</Badge>
|
||||
)}
|
||||
{award.competition && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{award.competition.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{award.criteriaText && (
|
||||
<Card className="mt-3 bg-muted/30">
|
||||
<CardContent className="py-3 px-4">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Star className="mr-1.5 inline h-4 w-4 text-amber-500" />
|
||||
<span className="font-medium text-foreground">Criteria: </span>
|
||||
{award.criteriaText}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Closed State */}
|
||||
{isClosed ? (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="rounded-full bg-amber-100 p-4 dark:bg-amber-900/40">
|
||||
<Trophy className="h-12 w-12 text-amber-500" />
|
||||
</div>
|
||||
<p className="mt-4 text-lg font-semibold">Award Finalized</p>
|
||||
{winnerProject ? (
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="text-xl font-bold text-[#053d57] dark:text-blue-300">
|
||||
{winnerProject.title}
|
||||
</p>
|
||||
{winnerProject.teamName && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{winnerProject.teamName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
This award has been finalized
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Project Grid */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">
|
||||
Eligible Projects ({projects.length})
|
||||
</h2>
|
||||
{isVotingOpen && (
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Click a project to select it as your pick and expand details
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className={cn(
|
||||
expandedProjectId === project.id && 'sm:col-span-2 lg:col-span-3'
|
||||
)}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'cursor-pointer transition-all',
|
||||
selectedProjectId === project.id
|
||||
? 'ring-2 ring-primary bg-primary/5'
|
||||
: 'hover:bg-muted/50'
|
||||
)}
|
||||
onClick={() => handleProjectClick(project.id)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<ProjectLogo project={project} logoUrl={project.logoUrl} size="sm" fallback="initials" className="mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base">
|
||||
{project.title}
|
||||
</CardTitle>
|
||||
{project.teamName && (
|
||||
<CardDescription className="mt-0.5">
|
||||
{project.teamName}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-2 shrink-0">
|
||||
{expandedProjectId === project.id ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.competitionCategory.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
{project.country && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<CountryDisplay country={project.country} />
|
||||
</Badge>
|
||||
)}
|
||||
{project.evaluationScore && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300"
|
||||
>
|
||||
<Star className="mr-0.5 h-3 w-3" />
|
||||
Avg: {project.evaluationScore.avg.toFixed(1)}/10 (
|
||||
{project.evaluationScore.count}{' '}
|
||||
{project.evaluationScore.count === 1
|
||||
? 'review'
|
||||
: 'reviews'}
|
||||
)
|
||||
</Badge>
|
||||
)}
|
||||
{selectedProjectId === project.id && (
|
||||
<Badge className="text-xs bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300">
|
||||
<CheckCircle2 className="mr-0.5 h-3 w-3" />
|
||||
Selected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Expanded Project Detail */}
|
||||
{expandedProjectId === project.id && (
|
||||
<Card className="mt-2 border-dashed">
|
||||
<CardContent className="space-y-4 py-4">
|
||||
{project.description && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-1 flex items-center gap-1.5">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
Description
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-line">
|
||||
{project.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{award.evaluationRoundId && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
Documents
|
||||
</h4>
|
||||
<ProjectFilesSection
|
||||
projectId={project.id}
|
||||
roundId={award.evaluationRoundId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.evaluationScore && (
|
||||
<Card className="bg-blue-50/50 dark:bg-blue-950/20">
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
Evaluation Score
|
||||
</p>
|
||||
<p className="text-lg font-bold text-blue-700 dark:text-blue-300">
|
||||
{project.evaluationScore.avg.toFixed(1)} / 10
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Based on {project.evaluationScore.count}{' '}
|
||||
{project.evaluationScore.count === 1
|
||||
? 'evaluation'
|
||||
: 'evaluations'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vote Section */}
|
||||
{isVotingOpen && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Your Vote</CardTitle>
|
||||
<CardDescription>
|
||||
{hasVoted
|
||||
? 'You can update your vote until the award is finalized'
|
||||
: 'Select a project above and submit your vote'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedProject ? (
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your selection
|
||||
</p>
|
||||
<p className="font-semibold">{selectedProject.title}</p>
|
||||
{selectedProject.teamName && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedProject.teamName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No project selected. Click a project card above to select it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="justification"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Justification
|
||||
</label>
|
||||
<Textarea
|
||||
id="justification"
|
||||
value={justification}
|
||||
onChange={(e) => setJustification(e.target.value)}
|
||||
placeholder="Why did you choose this project? (optional)"
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{justification.length} / 2000
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSubmitVote}
|
||||
disabled={!selectedProjectId || submitVote.isPending}
|
||||
>
|
||||
{submitVote.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Chair Section */}
|
||||
{isChair && isVotingOpen && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-muted-foreground" />
|
||||
Team Votes
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
As chair, you can view team votes and confirm the winner
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{otherVotes.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{otherVotes.map((vote) => {
|
||||
const votedProject = projects.find(
|
||||
(p) => p.id === vote.projectId
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={vote.userId}
|
||||
className="rounded-lg border p-3 space-y-1"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium text-sm">
|
||||
{vote.userName || 'Anonymous Juror'}
|
||||
</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
voted for
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm font-semibold">
|
||||
{votedProject?.title || 'Unknown project'}
|
||||
</p>
|
||||
{vote.justification && (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
“{vote.justification}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Waiting for other team members to vote
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Vote tally */}
|
||||
<div className="rounded-lg bg-muted/30 p-3">
|
||||
<p className="text-sm font-medium">Vote Summary</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{otherVotes.length + (hasVoted ? 1 : 0)} of{' '}
|
||||
{totalJurors} jurors have voted
|
||||
</p>
|
||||
{(() => {
|
||||
const allVotes = [
|
||||
...otherVotes.map((v) => v.projectId),
|
||||
...(hasVoted && myVotes[0]
|
||||
? [myVotes[0].projectId]
|
||||
: []),
|
||||
]
|
||||
const tally = new Map<string, number>()
|
||||
for (const pid of allVotes) {
|
||||
tally.set(pid, (tally.get(pid) || 0) + 1)
|
||||
}
|
||||
const sorted = [...tally.entries()].sort(
|
||||
(a, b) => b[1] - a[1]
|
||||
)
|
||||
if (sorted.length === 0) return null
|
||||
return (
|
||||
<div className="mt-2 space-y-1">
|
||||
{sorted.map(([pid, count]) => {
|
||||
const proj = projects.find((p) => p.id === pid)
|
||||
return (
|
||||
<div
|
||||
key={pid}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span>{proj?.title || 'Unknown'}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{count} {count === 1 ? 'vote' : 'votes'}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Confirm Winner button */}
|
||||
<div className="flex justify-end">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!hasVoted || confirmWinner.isPending}
|
||||
>
|
||||
{confirmWinner.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trophy className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Confirm Winner
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Confirm Award Winner
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will finalize the winner and close the award.
|
||||
This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmWinner}>
|
||||
Confirm Winner
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Trophy } from 'lucide-react'
|
||||
|
||||
export default function AwardMasterDashboard() {
|
||||
const { data: awards, isLoading } = trpc.specialAward.getMyAwards.useQuery()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-40" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Award Master Dashboard
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Review eligible projects and select award winners
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{awards && awards.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{awards.map((award) => (
|
||||
<Link key={award.id} href={`/award-master/awards/${award.id}` as Route}>
|
||||
<Card className="transition-colors hover:bg-muted/50 cursor-pointer h-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-amber-500" />
|
||||
{award.name}
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant={
|
||||
award.status === 'VOTING_OPEN' ? 'default' : 'secondary'
|
||||
}
|
||||
>
|
||||
{award.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
{award.description && (
|
||||
<CardDescription className="line-clamp-2">
|
||||
{award.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{award._count.eligibilities} eligible projects
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Trophy className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No awards assigned</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You will see your awards here when they are assigned to you
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { requireRole } from '@/lib/auth-redirect'
|
||||
import { AwardMasterNav } from '@/components/layouts/award-master-nav'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function AwardMasterLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await requireRole('AWARD_MASTER', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AwardMasterNav
|
||||
user={{
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
}}
|
||||
/>
|
||||
<main className="container-app py-6 lg:py-8">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,16 +13,29 @@ import {
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Trophy,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
GripVertical,
|
||||
ChevronDown,
|
||||
Users,
|
||||
Tag,
|
||||
Star,
|
||||
Gavel,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
@@ -50,11 +63,19 @@ export default function JuryAwardVotingPage({
|
||||
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
|
||||
},
|
||||
})
|
||||
const confirmWinner = trpc.specialAward.confirmWinner.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
|
||||
toast.success('Winner confirmed and award closed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const [rankedIds, setRankedIds] = useState<string[]>([])
|
||||
const [justification, setJustification] = useState('')
|
||||
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleExpanded = (projectId: string) => {
|
||||
@@ -71,6 +92,9 @@ export default function JuryAwardVotingPage({
|
||||
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
|
||||
if (data.award.scoringMode === 'PICK_WINNER') {
|
||||
setSelectedProjectId(data.myVotes[0]?.projectId || null)
|
||||
if (data.myVotes[0]?.justification) {
|
||||
setJustification(data.myVotes[0].justification)
|
||||
}
|
||||
} else if (data.award.scoringMode === 'RANKED') {
|
||||
const sorted = [...data.myVotes]
|
||||
.sort((a, b) => (a.rank || 0) - (b.rank || 0))
|
||||
@@ -84,7 +108,10 @@ export default function JuryAwardVotingPage({
|
||||
try {
|
||||
await submitVote.mutateAsync({
|
||||
awardId,
|
||||
votes: [{ projectId: selectedProjectId }],
|
||||
votes: [{
|
||||
projectId: selectedProjectId,
|
||||
justification: justification.trim() || undefined,
|
||||
}],
|
||||
})
|
||||
toast.success('Vote submitted')
|
||||
refetch()
|
||||
@@ -136,9 +163,10 @@ export default function JuryAwardVotingPage({
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const { award, projects, myVotes } = data
|
||||
const { award, projects, myVotes, isChair, otherVotes, totalJurors } = data
|
||||
const hasVoted = myVotes.length > 0
|
||||
const isVotingOpen = award.status === 'VOTING_OPEN'
|
||||
const isClosed = award.status === 'CLOSED'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -197,10 +225,30 @@ export default function JuryAwardVotingPage({
|
||||
isExpanded={expandedProjects.has(project.id)}
|
||||
onSelect={() => setSelectedProjectId(project.id)}
|
||||
onToggleExpand={() => toggleExpanded(project.id)}
|
||||
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedProjectId && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Justification (optional)</CardTitle>
|
||||
<CardDescription>
|
||||
Visible to the jury chair when they finalize the award.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
placeholder="Why this project? (optional)"
|
||||
value={justification}
|
||||
onChange={(e) => setJustification(e.target.value)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSubmitPickWinner}
|
||||
@@ -214,6 +262,18 @@ export default function JuryAwardVotingPage({
|
||||
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isChair && totalJurors > 1 && (
|
||||
<ChairPanel
|
||||
award={award}
|
||||
projects={projects}
|
||||
otherVotes={otherVotes}
|
||||
totalJurors={totalJurors}
|
||||
hasVoted={hasVoted}
|
||||
onConfirm={() => confirmWinner.mutate({ awardId })}
|
||||
isPending={confirmWinner.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : award.scoringMode === 'RANKED' ? (
|
||||
/* RANKED Mode */
|
||||
@@ -332,6 +392,7 @@ type ProjectData = {
|
||||
tags: string[]
|
||||
logoKey?: string | null
|
||||
logoUrl?: string | null
|
||||
evaluationScore?: { avg: number; count: number } | null
|
||||
files: Array<{
|
||||
id: string
|
||||
fileName: string
|
||||
@@ -355,9 +416,31 @@ type ProjectData = {
|
||||
}>
|
||||
}
|
||||
|
||||
type OtherVote = {
|
||||
userId: string
|
||||
userName: string | null
|
||||
projectId: string
|
||||
justification: string | null
|
||||
}
|
||||
|
||||
function ProjectDetails({ project }: { project: ProjectData }) {
|
||||
return (
|
||||
<div className="px-4 pb-4 pt-2 space-y-4 border-t mt-2">
|
||||
{project.evaluationScore && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-blue-50/50 px-3 py-2">
|
||||
<Star className="h-4 w-4 text-blue-600 shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold text-blue-700">
|
||||
{project.evaluationScore.avg.toFixed(1)} / 10
|
||||
</span>
|
||||
<span className="text-muted-foreground ml-2">
|
||||
from {project.evaluationScore.count}{' '}
|
||||
{project.evaluationScore.count === 1 ? 'evaluation' : 'evaluations'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.description && (
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line">{project.description}</p>
|
||||
)}
|
||||
@@ -435,7 +518,7 @@ function ProjectCard({
|
||||
isExpanded && 'rotate-180'
|
||||
)} />
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">
|
||||
<h3 className="font-semibold text-sm group-hover:text-brand-blue transition-colors">
|
||||
{project.title}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
@@ -469,3 +552,139 @@ function ProjectCard({
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ChairPanel({
|
||||
award,
|
||||
projects,
|
||||
otherVotes,
|
||||
totalJurors,
|
||||
hasVoted,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: {
|
||||
award: { id: string; status: string }
|
||||
projects: ProjectData[]
|
||||
otherVotes: OtherVote[]
|
||||
totalJurors: number
|
||||
hasVoted: boolean
|
||||
onConfirm: () => void
|
||||
isPending: boolean
|
||||
}) {
|
||||
const projectMap = new Map(projects.map((p) => [p.id, p]))
|
||||
const tally = new Map<string, number>()
|
||||
for (const v of otherVotes) {
|
||||
tally.set(v.projectId, (tally.get(v.projectId) ?? 0) + 1)
|
||||
}
|
||||
const ranked = Array.from(tally.entries())
|
||||
.map(([projectId, votes]) => ({
|
||||
project: projectMap.get(projectId),
|
||||
votes,
|
||||
}))
|
||||
.filter((r) => r.project)
|
||||
.sort((a, b) => b.votes - a.votes)
|
||||
|
||||
const votedCount = new Set(otherVotes.map((v) => v.userId)).size + (hasVoted ? 1 : 0)
|
||||
const isClosed = award.status === 'CLOSED'
|
||||
|
||||
return (
|
||||
<Card className="border-amber-200">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Gavel className="h-5 w-5 text-amber-600" />
|
||||
<CardTitle className="text-base">Chair tools</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{votedCount} of {totalJurors} jurors have voted. As the chair you
|
||||
can review their picks and finalize the award.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{ranked.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No other juror votes yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Tally so far
|
||||
</p>
|
||||
{ranked.map(({ project, votes }) => (
|
||||
<div key={project!.id} className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<span className="text-sm font-medium truncate">{project!.title}</span>
|
||||
<Badge variant="secondary">{votes} {votes === 1 ? 'vote' : 'votes'}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{otherVotes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Justifications
|
||||
</p>
|
||||
{otherVotes.map((v) => {
|
||||
const project = projectMap.get(v.projectId)
|
||||
return (
|
||||
<div key={v.userId} className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{v.userName || 'Anonymous juror'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
→ {project?.title || 'Unknown project'}
|
||||
</span>
|
||||
</div>
|
||||
{v.justification && (
|
||||
<p className="text-xs text-muted-foreground mt-2 whitespace-pre-line">
|
||||
{v.justification}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isClosed && (
|
||||
<div className="flex justify-end pt-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button disabled={!hasVoted || isPending}>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trophy className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Confirm winner & close award
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Finalize the award?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The project with the most votes will be set as the
|
||||
winner. If there's a tie, your own vote breaks it.
|
||||
Voting will close immediately and this can't be
|
||||
reopened from this page.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasVoted && (
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
You must submit your own vote before finalizing.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function JuryRoundDetailPage() {
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||
{round?.name || 'Round Details'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
|
||||
@@ -5,20 +5,19 @@ import { useRouter } from 'next/navigation'
|
||||
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 { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { COIDeclarationDialog } from '@/components/forms/coi-declaration-dialog'
|
||||
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert, Lock } from 'lucide-react'
|
||||
import { ArrowLeft, Save, Send, AlertCircle, Clock, ShieldAlert, Lock } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { EvaluationConfig } from '@/types/competition-configs'
|
||||
import {
|
||||
EvaluationFormFields,
|
||||
parseCriteriaFromForm,
|
||||
} from '@/components/evaluation/evaluation-form-fields'
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ roundId: string; projectId: string }>
|
||||
@@ -148,33 +147,10 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
const requireFeedback = evalConfig?.requireFeedback ?? true
|
||||
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
|
||||
|
||||
// Parse criteria from the active form
|
||||
const criteria = (activeForm?.criteriaJson ?? []).map((c) => {
|
||||
const type = (c as any).type || 'numeric'
|
||||
let minScore = 1
|
||||
let maxScore = 10
|
||||
if (type === 'numeric' && c.scale) {
|
||||
const parts = c.scale.split('-').map(Number)
|
||||
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
||||
minScore = parts[0]
|
||||
maxScore = parts[1]
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
description: c.description,
|
||||
type: type as 'numeric' | 'text' | 'boolean' | 'advance' | 'section_header',
|
||||
weight: c.weight,
|
||||
minScore,
|
||||
maxScore,
|
||||
required: (c as any).required ?? true,
|
||||
trueLabel: (c as any).trueLabel || 'Yes',
|
||||
falseLabel: (c as any).falseLabel || 'No',
|
||||
maxLength: (c as any).maxLength || 1000,
|
||||
placeholder: (c as any).placeholder || '',
|
||||
}
|
||||
})
|
||||
// Parse criteria from the active form (shared with admin proxy flow)
|
||||
const criteria = parseCriteriaFromForm(
|
||||
activeForm?.criteriaJson as ReadonlyArray<Record<string, unknown>> | null | undefined,
|
||||
)
|
||||
|
||||
// Initialize numeric criteria with midpoint values so slider visual matches stored value.
|
||||
const criteriaInitializedRef = useRef(false)
|
||||
@@ -484,7 +460,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</div>
|
||||
<Card className="border-l-4 border-l-amber-500">
|
||||
<CardContent className="flex items-start gap-4 p-6">
|
||||
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3">
|
||||
<div className="rounded-xl bg-amber-50 p-3">
|
||||
<Clock className="h-6 w-6 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -519,7 +495,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||
Evaluate Project
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">{project.title}</p>
|
||||
@@ -550,7 +526,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||
Evaluate Project
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">{project.title}</p>
|
||||
@@ -558,7 +534,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</div>
|
||||
<Card className="border-l-4 border-l-amber-500">
|
||||
<CardContent className="flex items-start gap-4 p-6">
|
||||
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3">
|
||||
<div className="rounded-xl bg-amber-50 p-3">
|
||||
<ShieldAlert className="h-6 w-6 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -597,7 +573,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</Button>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||
{isReadOnly ? 'Submitted Evaluation' : 'Evaluate Project'}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
@@ -607,8 +583,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
variant="secondary"
|
||||
className={
|
||||
project.competitionCategory === 'STARTUP'
|
||||
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
||||
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
|
||||
? 'bg-violet-100 text-violet-700 border-violet-200'
|
||||
: 'bg-sky-100 text-sky-700 border-sky-200'
|
||||
}
|
||||
>
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
@@ -619,7 +595,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</div>
|
||||
|
||||
{isReadOnly && (
|
||||
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50 dark:bg-blue-950/20">
|
||||
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50">
|
||||
<CardContent className="flex items-start gap-3 p-4">
|
||||
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
@@ -650,330 +626,23 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>Evaluation Form</CardTitle>
|
||||
<CardDescription>
|
||||
{scoringMode === 'criteria'
|
||||
? 'Complete all required fields below'
|
||||
: `Provide your assessment using the ${scoringMode} scoring method`}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{lastSavedAt && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
|
||||
Saved {lastSavedAt.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Criteria-based scoring with mixed types */}
|
||||
{scoringMode === 'criteria' && criteria && criteria.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{criteria.map((criterion) => {
|
||||
if (criterion.type === 'section_header') {
|
||||
return (
|
||||
<div key={criterion.id} className="border-b pb-2 pt-4 first:pt-0">
|
||||
<h3 className="font-semibold text-lg">{criterion.label}</h3>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{criterion.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<EvaluationFormFields
|
||||
criteria={criteria}
|
||||
scoringMode={scoringMode}
|
||||
requireFeedback={requireFeedback}
|
||||
feedbackMinLength={feedbackMinLength}
|
||||
criteriaValues={criteriaValues}
|
||||
globalScore={globalScore}
|
||||
binaryDecision={binaryDecision}
|
||||
feedbackText={feedbackText}
|
||||
isReadOnly={isReadOnly}
|
||||
lastSavedAt={lastSavedAt}
|
||||
onCriterionChange={handleCriterionChange}
|
||||
onGlobalScoreChange={handleGlobalScoreChange}
|
||||
onBinaryChange={handleBinaryChange}
|
||||
onFeedbackChange={handleFeedbackChange}
|
||||
/>
|
||||
|
||||
if (criterion.type === 'advance') {
|
||||
const currentValue = criteriaValues[criterion.id]
|
||||
return (
|
||||
<div key={criterion.id} className="space-y-3 p-5 border-2 border-brand-blue/30 rounded-xl bg-brand-blue/5">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base font-semibold text-brand-blue">
|
||||
{criterion.label}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground">{criterion.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleCriterionChange(criterion.id, true)}
|
||||
className={cn(
|
||||
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
|
||||
currentValue === true
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200'
|
||||
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50',
|
||||
isReadOnly && 'opacity-60 cursor-default'
|
||||
)}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-5 w-5" />
|
||||
{criterion.trueLabel || 'Yes'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleCriterionChange(criterion.id, false)}
|
||||
className={cn(
|
||||
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
|
||||
currentValue === false
|
||||
? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200'
|
||||
: 'border-border hover:border-red-300 hover:bg-red-50/50',
|
||||
isReadOnly && 'opacity-60 cursor-default'
|
||||
)}
|
||||
>
|
||||
<ThumbsDown className="mr-2 h-5 w-5" />
|
||||
{criterion.falseLabel || 'No'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (criterion.type === 'boolean') {
|
||||
const currentValue = criteriaValues[criterion.id]
|
||||
return (
|
||||
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base font-medium">
|
||||
{criterion.label}
|
||||
{criterion.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground">{criterion.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleCriterionChange(criterion.id, true)}
|
||||
className={cn(
|
||||
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
|
||||
currentValue === true
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
|
||||
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50',
|
||||
isReadOnly && 'opacity-60 cursor-default'
|
||||
)}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-4 w-4" />
|
||||
{criterion.trueLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleCriterionChange(criterion.id, false)}
|
||||
className={cn(
|
||||
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
|
||||
currentValue === false
|
||||
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
|
||||
: 'border-border hover:border-red-300 hover:bg-red-50/50',
|
||||
isReadOnly && 'opacity-60 cursor-default'
|
||||
)}
|
||||
>
|
||||
<ThumbsDown className="mr-2 h-4 w-4" />
|
||||
{criterion.falseLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (criterion.type === 'text') {
|
||||
const currentValue = (criteriaValues[criterion.id] as string) || ''
|
||||
return (
|
||||
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base font-medium">
|
||||
{criterion.label}
|
||||
{criterion.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground">{criterion.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
value={currentValue}
|
||||
onChange={(e) => handleCriterionChange(criterion.id, e.target.value)}
|
||||
placeholder={criterion.placeholder || 'Enter your response...'}
|
||||
rows={4}
|
||||
maxLength={criterion.maxLength}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{currentValue.length}/{criterion.maxLength}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default: numeric criterion
|
||||
const min = criterion.minScore ?? 1
|
||||
const max = criterion.maxScore ?? 10
|
||||
const currentValue = criteriaValues[criterion.id]
|
||||
const displayValue = typeof currentValue === 'number' ? currentValue : undefined
|
||||
const sliderValue = typeof currentValue === 'number' ? currentValue : Math.ceil((min + max) / 2)
|
||||
|
||||
return (
|
||||
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base font-medium">
|
||||
{criterion.label}
|
||||
{criterion.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground">{criterion.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
|
||||
{displayValue !== undefined ? displayValue : '\u2014'}/{max}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-4">{min}</span>
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={1}
|
||||
value={[sliderValue]}
|
||||
onValueChange={(v) => handleCriterionChange(criterion.id, v[0])}
|
||||
className="flex-1"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-4">{max}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{Array.from({ length: max - min + 1 }, (_, i) => i + min).map((num) => (
|
||||
<button
|
||||
key={num}
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleCriterionChange(criterion.id, num)}
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
||||
displayValue !== undefined && displayValue === num
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: displayValue !== undefined && displayValue > num
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted hover:bg-muted/80',
|
||||
isReadOnly && 'cursor-default'
|
||||
)}
|
||||
>
|
||||
{num}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global scoring */}
|
||||
{scoringMode === 'global' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Overall Score <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<span className="rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
|
||||
{globalScore || '\u2014'}/10
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">1</span>
|
||||
<Slider
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
value={[globalScore ? parseInt(globalScore, 10) : 5]}
|
||||
onValueChange={(v) => handleGlobalScoreChange(v[0].toString())}
|
||||
className="flex-1"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">10</span>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => {
|
||||
const current = globalScore ? parseInt(globalScore, 10) : 0
|
||||
return (
|
||||
<button
|
||||
key={num}
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleGlobalScoreChange(num.toString())}
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
||||
current === num
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: current > num
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted hover:bg-muted/80',
|
||||
isReadOnly && 'cursor-default'
|
||||
)}
|
||||
>
|
||||
{num}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Binary decision */}
|
||||
{scoringMode === 'binary' && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Decision <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<RadioGroup value={binaryDecision} onValueChange={(v) => handleBinaryChange(v as 'accept' | 'reject')} disabled={isReadOnly}>
|
||||
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50">
|
||||
<RadioGroupItem value="accept" id="accept" />
|
||||
<Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1">
|
||||
<ThumbsUp className="h-4 w-4 text-emerald-600" />
|
||||
<span>Accept — This project should advance</span>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-red-50/50">
|
||||
<RadioGroupItem value="reject" id="reject" />
|
||||
<Label htmlFor="reject" className="flex items-center gap-2 cursor-pointer flex-1">
|
||||
<ThumbsDown className="h-4 w-4 text-red-600" />
|
||||
<span>Reject — This project should not advance</span>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feedbackText">
|
||||
General Comment / Feedback
|
||||
{requireFeedback && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="feedbackText"
|
||||
value={feedbackText}
|
||||
onChange={(e) => handleFeedbackChange(e.target.value)}
|
||||
placeholder="Provide your feedback on the project..."
|
||||
rows={8}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{requireFeedback && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Minimum {feedbackMinLength} characters ({feedbackText.length}/{feedbackMinLength})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isReadOnly ? (
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -98,8 +98,8 @@ export default function JuryProjectDetailPage() {
|
||||
variant="secondary"
|
||||
className={
|
||||
project.competitionCategory === 'STARTUP'
|
||||
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
||||
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
|
||||
? 'bg-violet-100 text-violet-700 border-violet-200'
|
||||
: 'bg-sky-100 text-sky-700 border-sky-200'
|
||||
}
|
||||
>
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function JuryAssignmentsPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||
My Assignments
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
|
||||
@@ -262,7 +262,7 @@ async function JuryDashboardContent() {
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-teal/40 via-brand-blue/40 to-brand-teal/40" />
|
||||
<CardContent className="py-8 px-6">
|
||||
<div className="flex flex-col items-center text-center mb-6">
|
||||
<div className="rounded-2xl bg-gradient-to-br from-brand-teal/10 to-brand-blue/10 p-4 mb-3 dark:from-brand-teal/20 dark:to-brand-blue/20">
|
||||
<div className="rounded-2xl bg-gradient-to-br from-brand-teal/10 to-brand-blue/10 p-4 mb-3">
|
||||
<ClipboardList className="h-8 w-8 text-brand-teal/60" />
|
||||
</div>
|
||||
<p className="text-lg font-semibold">No assignments yet</p>
|
||||
@@ -273,13 +273,13 @@ async function JuryDashboardContent() {
|
||||
<div className={`grid gap-3 max-w-md mx-auto ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
|
||||
<Link
|
||||
href="/jury/competitions"
|
||||
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
|
||||
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40">
|
||||
<ClipboardList className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100">
|
||||
<ClipboardList className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">All Assignments</p>
|
||||
<p className="font-semibold text-sm group-hover:text-brand-blue transition-colors">All Assignments</p>
|
||||
<p className="text-xs text-muted-foreground">View evaluations</p>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -288,7 +288,7 @@ async function JuryDashboardContent() {
|
||||
href="/jury/competitions"
|
||||
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40">
|
||||
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100">
|
||||
<GitCompare className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
@@ -314,8 +314,8 @@ async function JuryDashboardContent() {
|
||||
<div className="rounded-[7px] bg-background">
|
||||
<CardHeader className="pb-2 pt-4 px-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/40">
|
||||
<Trophy className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
<div className="rounded-lg bg-amber-100 p-1.5">
|
||||
<Trophy className="h-4 w-4 text-amber-600" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Special Awards — Voting Open</CardTitle>
|
||||
</div>
|
||||
@@ -333,27 +333,27 @@ async function JuryDashboardContent() {
|
||||
className={cn(
|
||||
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
hasVoted
|
||||
? 'border-green-200/60 bg-green-50/30 dark:border-green-800/40 dark:bg-green-950/10'
|
||||
? 'border-green-200/60 bg-green-50/30'
|
||||
: isUrgent
|
||||
? 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20'
|
||||
: 'border-amber-200/60 bg-amber-50/30 dark:border-amber-800/40 dark:bg-amber-950/10'
|
||||
? 'border-red-200 bg-red-50/50'
|
||||
: 'border-amber-200/60 bg-amber-50/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className={cn('font-semibold', hasVoted ? 'text-green-700 dark:text-green-400' : 'text-amber-700 dark:text-amber-400')}>{award.name}</h3>
|
||||
<h3 className={cn('font-semibold', hasVoted ? 'text-green-700' : 'text-amber-700')}>{award.name}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{award._count.eligibilities} project{award._count.eligibilities !== 1 ? 's' : ''} to review
|
||||
{record.isChair && ' · You are the Chair'}
|
||||
</p>
|
||||
</div>
|
||||
{hasVoted ? (
|
||||
<Badge className="bg-green-100 text-green-800 border-green-300 dark:bg-green-950 dark:text-green-300 dark:border-green-700">
|
||||
<Badge className="bg-green-100 text-green-800 border-green-300">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Submitted
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-700">
|
||||
<Badge className="bg-amber-100 text-amber-800 border-amber-300">
|
||||
Vote Now
|
||||
</Badge>
|
||||
)}
|
||||
@@ -452,8 +452,8 @@ async function JuryDashboardContent() {
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-brand-blue/10 p-1.5 dark:bg-brand-blue/20">
|
||||
<ClipboardList className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
|
||||
<div className="rounded-lg bg-brand-blue/10 p-1.5">
|
||||
<ClipboardList className="h-4 w-4 text-brand-blue" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">My Assignments</CardTitle>
|
||||
</div>
|
||||
@@ -487,14 +487,14 @@ async function JuryDashboardContent() {
|
||||
href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}`}
|
||||
className="flex-1 min-w-0 group"
|
||||
>
|
||||
<p className="text-sm font-medium truncate group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">
|
||||
<p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
|
||||
{assignment.project.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{assignment.project.teamName}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-brand-blue/5 text-brand-blue/80 dark:bg-brand-teal/10 dark:text-brand-teal/80 border-0">
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-brand-blue/5 text-brand-blue/80 border-0">
|
||||
{assignment.round.name}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -506,7 +506,7 @@ async function JuryDashboardContent() {
|
||||
Done
|
||||
</Badge>
|
||||
) : isDraft && isVotingOpen ? (
|
||||
<Badge className="text-xs bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-700 animate-pulse">
|
||||
<Badge className="text-xs bg-amber-100 text-amber-800 border-amber-300 animate-pulse">
|
||||
<Send className="mr-1 h-3 w-3" />
|
||||
Ready to submit
|
||||
</Badge>
|
||||
@@ -571,7 +571,7 @@ async function JuryDashboardContent() {
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20">
|
||||
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
||||
<Zap className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
||||
@@ -581,13 +581,13 @@ async function JuryDashboardContent() {
|
||||
<div className={`grid gap-3 ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
|
||||
<Link
|
||||
href="/jury/competitions"
|
||||
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
|
||||
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded-xl bg-blue-50 p-3 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40 dark:group-hover:bg-blue-950/60">
|
||||
<ClipboardList className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
<div className="rounded-xl bg-blue-50 p-3 transition-colors group-hover:bg-blue-100">
|
||||
<ClipboardList className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">All Assignments</p>
|
||||
<p className="font-semibold text-sm group-hover:text-brand-blue transition-colors">All Assignments</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">View and manage evaluations</p>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -596,7 +596,7 @@ async function JuryDashboardContent() {
|
||||
href="/jury/competitions"
|
||||
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40 dark:group-hover:bg-teal-950/60">
|
||||
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100">
|
||||
<GitCompare className="h-5 w-5 text-brand-teal" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
@@ -620,8 +620,8 @@ async function JuryDashboardContent() {
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-brand-blue/10 p-1.5 dark:bg-brand-blue/20">
|
||||
<Waves className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
|
||||
<div className="rounded-lg bg-brand-blue/10 p-1.5">
|
||||
<Waves className="h-4 w-4 text-brand-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">Active Voting Stages</CardTitle>
|
||||
@@ -650,13 +650,13 @@ async function JuryDashboardContent() {
|
||||
className={cn(
|
||||
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
isUrgent
|
||||
? 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20'
|
||||
: 'border-border/60 bg-muted/20 dark:bg-muted/10'
|
||||
? 'border-red-200 bg-red-50/50'
|
||||
: 'border-border/60 bg-muted/20'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{round.name}</h3>
|
||||
<h3 className="font-semibold text-brand-blue">{round.name}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{program.name} · {program.year}
|
||||
</p>
|
||||
@@ -716,7 +716,7 @@ async function JuryDashboardContent() {
|
||||
<AnimatedCard index={8}>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div className="rounded-2xl bg-brand-teal/10 p-3 mb-2 dark:bg-brand-teal/20">
|
||||
<div className="rounded-2xl bg-brand-teal/10 p-3 mb-2">
|
||||
<Clock className="h-6 w-6 text-brand-teal/70" />
|
||||
</div>
|
||||
<p className="font-semibold text-sm">No active voting stages</p>
|
||||
@@ -734,7 +734,7 @@ async function JuryDashboardContent() {
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20">
|
||||
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
||||
<BarChart3 className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Round Summary</CardTitle>
|
||||
@@ -750,7 +750,7 @@ async function JuryDashboardContent() {
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium truncate">{round.name}</span>
|
||||
<div className="flex items-baseline gap-1 shrink-0 ml-2">
|
||||
<span className="font-bold tabular-nums text-brand-blue dark:text-brand-teal">{pct}%</span>
|
||||
<span className="font-bold tabular-nums text-brand-blue">{pct}%</span>
|
||||
<span className="text-xs text-muted-foreground">({done}/{total})</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -852,7 +852,7 @@ export default async function JuryDashboardPage() {
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||
{getGreeting()}, {session?.user?.name || 'Juror'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
import { RecentMessagesCard } from '@/components/mentor/recent-messages-card'
|
||||
|
||||
// Status badge colors
|
||||
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
@@ -117,6 +118,9 @@ export default function MentorDashboard() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Recent unread messages from teams */}
|
||||
<RecentMessagesCard />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<AnimatedCard index={0}>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Suspense, use, useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -31,6 +32,7 @@ import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { FileViewer } from '@/components/shared/file-viewer'
|
||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||
import { DropAssignmentDialog } from '@/components/mentor/drop-assignment-dialog'
|
||||
import {
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
@@ -76,6 +78,7 @@ const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'ou
|
||||
|
||||
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
const router = useRouter()
|
||||
const { data: session } = useSession()
|
||||
const { data: project, isLoading, error } = trpc.mentor.getProjectDetail.useQuery({
|
||||
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
|
||||
const trackView = trpc.mentor.trackView.useMutation()
|
||||
useEffect(() => {
|
||||
if (project?.mentorAssignment?.id) {
|
||||
trackView.mutate({ mentorAssignmentId: project.mentorAssignment.id })
|
||||
if (primaryAssignment?.id) {
|
||||
trackView.mutate({ mentorAssignmentId: primaryAssignment.id })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [project?.mentorAssignment?.id])
|
||||
}, [primaryAssignment?.id])
|
||||
|
||||
if (isLoading) {
|
||||
return <ProjectDetailSkeleton />
|
||||
@@ -132,8 +139,15 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
|
||||
const teamLead = project.teamMembers?.find((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 viewerIsAssignedMentor =
|
||||
!!mentorAssignment && session?.user?.id === mentorAssignment.mentor?.id
|
||||
const canDrop =
|
||||
viewerIsAssignedMentor &&
|
||||
!mentorAssignment.droppedAt &&
|
||||
mentorAssignment.completionStatus !== 'completed'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -179,6 +193,12 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canDrop && mentorAssignmentId && (
|
||||
<DropAssignmentDialog
|
||||
assignmentId={mentorAssignmentId}
|
||||
projectTitle={project.title}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{project.assignedAt && (
|
||||
@@ -461,7 +481,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<CardContent>
|
||||
<MentorChat
|
||||
messages={mentorMessages || []}
|
||||
currentUserId={project.mentorAssignment?.mentor?.id || ''}
|
||||
currentUserId={primaryAssignment?.mentor?.id || ''}
|
||||
onSendMessage={async (message) => {
|
||||
await sendMessage.mutateAsync({ projectId, message })
|
||||
}}
|
||||
@@ -576,7 +596,7 @@ function MilestonesSection({
|
||||
<div
|
||||
key={milestone.id}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm ${
|
||||
isCompleted ? 'bg-green-50/50 border-green-200 dark:bg-green-950/20 dark:border-green-900' : ''
|
||||
isCompleted ? 'bg-green-50/50 border-green-200' : ''
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { WorkspaceChat } from '@/components/mentor/workspace-chat'
|
||||
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
|
||||
import { ArrowLeft, MessageSquare, FileText, Upload } from 'lucide-react'
|
||||
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
||||
import { ArrowLeft, MessageSquare, FileText, Upload, Users } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function MentorWorkspaceDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { data: session } = useSession()
|
||||
const projectId = params.projectId as string
|
||||
|
||||
// Get mentor assignment for this project
|
||||
@@ -26,6 +35,22 @@ export default function MentorWorkspaceDetailPage() {
|
||||
{ enabled: !!projectId }
|
||||
)
|
||||
|
||||
// Co-mentor visibility (PR8 multi-mentor): show who else is on the team.
|
||||
// Gracefully tolerates stale tabs where the caller no longer has access
|
||||
// (assignment dropped) — query just returns nothing in that case.
|
||||
const { data: projectMentors } = trpc.mentor.getProjectMentors.useQuery(
|
||||
{ projectId },
|
||||
{ enabled: !!projectId, retry: false }
|
||||
)
|
||||
|
||||
const currentUserId = session?.user?.id
|
||||
const coMentors = (projectMentors ?? []).filter(
|
||||
a => a.mentor.id !== currentUserId
|
||||
)
|
||||
const coMentorNames = coMentors.map(a => a.mentor.name ?? 'Unnamed mentor')
|
||||
const visibleCoMentors = coMentorNames.slice(0, 3)
|
||||
const hiddenCoMentors = coMentorNames.slice(3)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -69,6 +94,37 @@ export default function MentorWorkspaceDetailPage() {
|
||||
{project.teamName && (
|
||||
<p className="text-muted-foreground mt-1">{project.teamName}</p>
|
||||
)}
|
||||
{coMentors.length > 0 && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
You + {coMentors.length} co-mentor
|
||||
{coMentors.length === 1 ? '' : 's'}:{' '}
|
||||
<span className="text-foreground">
|
||||
{visibleCoMentors.join(', ')}
|
||||
</span>
|
||||
{hiddenCoMentors.length > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help underline decoration-dotted underline-offset-2">
|
||||
+{hiddenCoMentors.length} more
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-xs">
|
||||
{hiddenCoMentors.join(', ')}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,25 +158,24 @@ export default function MentorWorkspaceDetailPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="files" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workspace Files</CardTitle>
|
||||
<CardDescription>
|
||||
Files shared in the mentor workspace
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center py-8">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
File listing feature coming soon
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{assignment ? (
|
||||
<WorkspaceFilesPanel
|
||||
projectId={projectId}
|
||||
mentorAssignmentId={assignment.id}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">Loading workspace…</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="promotion" className="mt-6">
|
||||
{assignment ? (
|
||||
<FilePromotionPanel mentorAssignmentId={assignment.id} />
|
||||
<FilePromotionPanel projectId={projectId} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
|
||||
@@ -6,10 +6,13 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ObserverProjectDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>
|
||||
searchParams: Promise<{ round?: string }>
|
||||
}) {
|
||||
const { projectId } = await params
|
||||
const sp = await searchParams
|
||||
|
||||
return <ObserverProjectDetail projectId={projectId} />
|
||||
return <ObserverProjectDetail projectId={projectId} initialRoundId={sp.round} />
|
||||
}
|
||||
|
||||
421
src/app/(public)/finalist/confirm/[token]/page.tsx
Normal file
421
src/app/(public)/finalist/confirm/[token]/page.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useEffect, useMemo, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { AlertCircle, CheckCircle2, Loader2, PartyPopper, XCircle } from 'lucide-react'
|
||||
import { TRPCClientError } from '@trpc/client'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ token: string }>
|
||||
}
|
||||
|
||||
function formatDeadline(d: Date): string {
|
||||
const main = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
}).format(d)
|
||||
const tzPart = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' })
|
||||
.formatToParts(d)
|
||||
.find((p) => p.type === 'timeZoneName')?.value
|
||||
return tzPart ? `${main} (${tzPart})` : main
|
||||
}
|
||||
|
||||
function CountdownLabel({ deadline }: { deadline: Date }) {
|
||||
const [now, setNow] = useState<number>(Date.now())
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
const ms = deadline.getTime() - now
|
||||
if (ms <= 0) return <span className="text-destructive font-medium">expired</span>
|
||||
const totalSec = Math.floor(ms / 1000)
|
||||
const hours = Math.floor(totalSec / 3600)
|
||||
const minutes = Math.floor((totalSec % 3600) / 60)
|
||||
const seconds = totalSec % 60
|
||||
if (hours >= 24) {
|
||||
const days = Math.floor(hours / 24)
|
||||
const remHours = hours % 24
|
||||
return (
|
||||
<span className="font-medium tabular-nums">
|
||||
{days}d {remHours}h remaining
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="font-medium tabular-nums">
|
||||
{hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:
|
||||
{seconds.toString().padStart(2, '0')} remaining
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function FriendlyError({
|
||||
title,
|
||||
message,
|
||||
icon: Icon,
|
||||
}: {
|
||||
title: string
|
||||
message: string
|
||||
icon: typeof AlertCircle
|
||||
}) {
|
||||
return (
|
||||
<Card className="mx-auto max-w-xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="text-muted-foreground h-5 w-5" />
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{message}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FinalistConfirmContent({ token }: { token: string }) {
|
||||
const { data, isLoading, error } = trpc.finalist.getByToken.useQuery({ token }, { retry: false })
|
||||
const confirmMutation = trpc.finalist.confirm.useMutation()
|
||||
const declineMutation = trpc.finalist.decline.useMutation()
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [visa, setVisa] = useState<Record<string, boolean>>({})
|
||||
const [declineReason, setDeclineReason] = useState('')
|
||||
const [submitState, setSubmitState] = useState<'idle' | 'confirmed' | 'declined' | 'error'>(
|
||||
'idle',
|
||||
)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
|
||||
// Default-select all team members once data arrives
|
||||
useEffect(() => {
|
||||
if (data?.project.teamMembers && selected.size === 0 && submitState === 'idle') {
|
||||
const cap = data.project.program.defaultAttendeeCap
|
||||
const initial = new Set(
|
||||
data.project.teamMembers.slice(0, cap).map((tm) => tm.userId),
|
||||
)
|
||||
setSelected(initial)
|
||||
}
|
||||
}, [data, selected.size, submitState])
|
||||
|
||||
// ── Loading
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4">
|
||||
<Skeleton className="h-8 w-2/3" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Token errors → friendly states
|
||||
if (error) {
|
||||
const msg = error.message ?? ''
|
||||
if (/expired/i.test(msg)) {
|
||||
return (
|
||||
<FriendlyError
|
||||
icon={AlertCircle}
|
||||
title="This link has expired"
|
||||
message="If your team still wants to attend, please contact us at info@monaco-opc.com."
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (/signature|malformed|payload/i.test(msg)) {
|
||||
return (
|
||||
<FriendlyError
|
||||
icon={AlertCircle}
|
||||
title="This link is not valid"
|
||||
message="Please check your email or contact us at info@monaco-opc.com."
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<FriendlyError
|
||||
icon={AlertCircle}
|
||||
title="Something went wrong"
|
||||
message={msg || 'Please try again or contact us at info@monaco-opc.com.'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (!data) {
|
||||
return (
|
||||
<FriendlyError
|
||||
icon={AlertCircle}
|
||||
title="Confirmation not found"
|
||||
message="Please check your email link or contact us at info@monaco-opc.com."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Status branches: only PENDING is interactive
|
||||
if (submitState === 'confirmed' || data.status === 'CONFIRMED') {
|
||||
return (
|
||||
<Card className="mx-auto max-w-xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<PartyPopper className="text-primary h-5 w-5" />
|
||||
<CardTitle>You'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 from your project page closer to the event.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
if (submitState === 'declined' || data.status === 'DECLINED') {
|
||||
return (
|
||||
<FriendlyError
|
||||
icon={XCircle}
|
||||
title="Your team has declined"
|
||||
message="If this was a mistake, please contact us at info@monaco-opc.com."
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (data.status === 'EXPIRED') {
|
||||
return (
|
||||
<FriendlyError
|
||||
icon={AlertCircle}
|
||||
title="The confirmation deadline has passed"
|
||||
message="If your team still wants to attend, please contact us at info@monaco-opc.com."
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (data.status === 'SUPERSEDED') {
|
||||
return (
|
||||
<FriendlyError
|
||||
icon={AlertCircle}
|
||||
title="This confirmation is no longer active"
|
||||
message="Please contact us at info@monaco-opc.com for details."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── PENDING: render the form
|
||||
const cap = data.project.program.defaultAttendeeCap
|
||||
const deadline = new Date(data.deadline)
|
||||
const overCap = selected.size > cap
|
||||
const noneSelected = selected.size === 0
|
||||
|
||||
const toggle = (userId: string, checked: boolean) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(userId)
|
||||
else next.delete(userId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const toggleVisa = (userId: string, checked: boolean) => {
|
||||
setVisa((prev) => ({ ...prev, [userId]: checked }))
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setSubmitError(null)
|
||||
try {
|
||||
await confirmMutation.mutateAsync({
|
||||
token,
|
||||
attendingUserIds: Array.from(selected),
|
||||
visaFlags: Object.fromEntries(
|
||||
Array.from(selected).map((uid) => [uid, !!visa[uid]]),
|
||||
),
|
||||
})
|
||||
setSubmitState('confirmed')
|
||||
} catch (err) {
|
||||
setSubmitState('error')
|
||||
const msg =
|
||||
err instanceof TRPCClientError ? err.message : err instanceof Error ? err.message : 'Failed'
|
||||
setSubmitError(msg)
|
||||
}
|
||||
}
|
||||
const handleDecline = async () => {
|
||||
setSubmitError(null)
|
||||
try {
|
||||
await declineMutation.mutateAsync({
|
||||
token,
|
||||
reason: declineReason.trim() || undefined,
|
||||
})
|
||||
setSubmitState('declined')
|
||||
} catch (err) {
|
||||
setSubmitState('error')
|
||||
const msg =
|
||||
err instanceof TRPCClientError ? err.message : err instanceof Error ? err.message : 'Failed'
|
||||
setSubmitError(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-6">
|
||||
<Card className="border-primary/40 bg-primary/5">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<PartyPopper className="text-primary h-5 w-5" />
|
||||
<CardTitle>Congratulations!</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>
|
||||
Your project <strong>{data.project.title}</strong> is a finalist for the Monaco Ocean
|
||||
Protection Challenge grand finale.
|
||||
</p>
|
||||
<div className="bg-background border-amber-300 mt-3 rounded-md border-l-4 p-3">
|
||||
<p className="text-sm">
|
||||
<strong>Confirm by {formatDeadline(deadline)}.</strong>
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
<CountdownLabel deadline={deadline} />
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Who from your team will attend?</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
You can select up to <strong>{cap}</strong> team members. Indicate who needs visa
|
||||
support so we can prepare documents in time.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-3">
|
||||
{data.project.teamMembers.map((tm) => {
|
||||
const checked = selected.has(tm.userId)
|
||||
return (
|
||||
<li key={tm.userId} className="flex items-start justify-between gap-4">
|
||||
<label className="flex flex-1 items-start gap-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(c) => toggle(tm.userId, c === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{tm.user.name ?? tm.user.email}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{tm.user.email}
|
||||
{tm.role && tm.role !== 'MEMBER' ? ` · ${tm.role.toLowerCase()}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{checked && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Needs visa?</span>
|
||||
<Switch
|
||||
checked={!!visa[tm.userId]}
|
||||
onCheckedChange={(c) => toggleVisa(tm.userId, c)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{overCap && (
|
||||
<p className="text-destructive mt-3 text-sm">
|
||||
Please select no more than {cap} member{cap === 1 ? '' : 's'}.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{submitError && (
|
||||
<div className="border-destructive bg-destructive/10 text-destructive rounded-md border p-3 text-sm">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" className="text-muted-foreground">
|
||||
We can'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>
|
||||
)
|
||||
}
|
||||
@@ -106,7 +106,6 @@ export default function ProfileSettingsPage() {
|
||||
const handleSaveProfile = async () => {
|
||||
try {
|
||||
await updateProfile.mutateAsync({
|
||||
email: email || undefined,
|
||||
name: name || undefined,
|
||||
bio,
|
||||
phoneNumber: phoneNumber || null,
|
||||
@@ -229,11 +228,13 @@ export default function ProfileSettingsPage() {
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
readOnly
|
||||
disabled
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
17
src/app/api/cron/finalist-confirmations/route.ts
Normal file
17
src/app/api/cron/finalist-confirmations/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { expirePendingPastDeadline } from '@/server/services/finalist-confirmation'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
try {
|
||||
const result = await expirePendingPastDeadline(prisma)
|
||||
return NextResponse.json({ ok: true, ...result })
|
||||
} catch (error) {
|
||||
console.error('[Cron] finalist-confirmations failed:', error)
|
||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
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 })
|
||||
}
|
||||
73
src/app/api/cron/lunch-reminders/route.ts
Normal file
73
src/app/api/cron/lunch-reminders/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sendLunchReminderEmail } from '@/lib/email'
|
||||
|
||||
/**
|
||||
* Cron: send a single reminder email per attending member who hasn't picked
|
||||
* a lunch dish yet, when we're inside the reminder window
|
||||
* (deadline - reminderHoursBeforeDeadline) <= now < deadline.
|
||||
*
|
||||
* Idempotent — `LunchEvent.reminderSentAt` blocks repeat sends.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const now = new Date()
|
||||
const events = await prisma.lunchEvent.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
reminderSentAt: null,
|
||||
reminderHoursBeforeDeadline: { not: null },
|
||||
eventAt: { not: null },
|
||||
},
|
||||
})
|
||||
let sent = 0
|
||||
for (const event of events) {
|
||||
try {
|
||||
if (!event.eventAt || event.reminderHoursBeforeDeadline == null) continue
|
||||
const deadline = new Date(
|
||||
event.eventAt.getTime() - event.changeCutoffHours * 3_600_000,
|
||||
)
|
||||
const reminderAt = new Date(
|
||||
deadline.getTime() - event.reminderHoursBeforeDeadline * 3_600_000,
|
||||
)
|
||||
if (now < reminderAt || now >= deadline) continue
|
||||
|
||||
const ams = await prisma.attendingMember.findMany({
|
||||
where: {
|
||||
confirmation: {
|
||||
project: { programId: event.programId },
|
||||
status: 'CONFIRMED',
|
||||
},
|
||||
lunchPick: { is: { pickedAt: null } },
|
||||
},
|
||||
include: { user: { select: { name: true, email: true } } },
|
||||
})
|
||||
for (const am of ams) {
|
||||
if (!am.user.email) continue
|
||||
try {
|
||||
await sendLunchReminderEmail({
|
||||
to: am.user.email,
|
||||
memberName: am.user.name ?? am.user.email,
|
||||
eventAt: event.eventAt,
|
||||
venue: event.venue,
|
||||
changeDeadline: deadline,
|
||||
pickUrl: `${process.env.NEXTAUTH_URL ?? ''}/applicant`,
|
||||
})
|
||||
sent++
|
||||
} catch (e) {
|
||||
console.error('[lunch-reminders] send failed for', am.user.email, e)
|
||||
}
|
||||
}
|
||||
await prisma.lunchEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { reminderSentAt: new Date() },
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[lunch-reminders] event failed', event.id, e)
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ ok: true, sent, processedEvents: events.length })
|
||||
}
|
||||
@@ -30,21 +30,21 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
// Authorization: must be admin or assigned jury/mentor for this project
|
||||
const isAdmin = userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN'
|
||||
|
||||
// Per-round scope: jurors may only pull URLs for files in rounds with
|
||||
// sortOrder <= their assigned round. Mirrors file.getDownloadUrl. Without
|
||||
// this, a juror assigned to EVALUATION could bulk-download LIVE_FINAL
|
||||
// confidential files via this endpoint.
|
||||
let priorRoundIds: string[] | null = null
|
||||
|
||||
if (!isAdmin) {
|
||||
// Check if user is assigned as jury
|
||||
const juryAssignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
where: { userId, projectId },
|
||||
select: { id: true, roundId: true },
|
||||
})
|
||||
|
||||
// Check if user is assigned as mentor
|
||||
const mentorAssignment = await prisma.mentorAssignment.findFirst({
|
||||
where: {
|
||||
mentorId: userId,
|
||||
projectId,
|
||||
},
|
||||
where: { mentorId: userId, projectId },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!juryAssignment && !mentorAssignment) {
|
||||
@@ -53,14 +53,41 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Apply the per-round filter only when access is jury-only.
|
||||
if (juryAssignment && !mentorAssignment) {
|
||||
const assignedRound = await prisma.round.findUnique({
|
||||
where: { id: juryAssignment.roundId },
|
||||
select: { competitionId: true, sortOrder: true },
|
||||
})
|
||||
if (assignedRound) {
|
||||
const priorOrCurrent = await prisma.round.findMany({
|
||||
where: {
|
||||
competitionId: assignedRound.competitionId,
|
||||
sortOrder: { lte: assignedRound.sortOrder },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
priorRoundIds = priorOrCurrent.map((r) => r.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch file metadata from DB
|
||||
const fileWhere: Record<string, unknown> = {
|
||||
id: { in: fileIds },
|
||||
projectId,
|
||||
}
|
||||
if (priorRoundIds !== null) {
|
||||
fileWhere.OR = [
|
||||
{ requirement: { roundId: { in: priorRoundIds } } },
|
||||
{ requirementId: null, roundId: { in: priorRoundIds } },
|
||||
{ requirementId: null, roundId: null },
|
||||
]
|
||||
}
|
||||
|
||||
const files = await prisma.projectFile.findMany({
|
||||
where: {
|
||||
id: { in: fileIds },
|
||||
projectId,
|
||||
},
|
||||
where: fileWhere,
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
|
||||
@@ -218,35 +218,6 @@
|
||||
--info: 194 25% 44%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 220 15% 8%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 220 15% 10%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 220 15% 10%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 354 90% 50%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--secondary: 220 15% 18%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 220 15% 18%;
|
||||
--muted-foreground: 0 0% 64%;
|
||||
|
||||
--accent: 194 20% 18%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 84% 55%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--border: 220 15% 22%;
|
||||
--input: 220 15% 22%;
|
||||
--ring: 220 10% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -345,13 +316,6 @@ div[class*="recharts-tooltip"] {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.dark div[class*="tremor"][class*="tooltip"],
|
||||
.dark .recharts-tooltip-wrapper .recharts-default-tooltip,
|
||||
.dark div[class*="recharts-tooltip"] {
|
||||
background-color: hsl(var(--card)) !important;
|
||||
border-color: hsl(var(--border)) !important;
|
||||
}
|
||||
|
||||
/* Tremor/Recharts tooltip color indicator icons — fix rendering */
|
||||
.recharts-tooltip-wrapper svg.recharts-surface {
|
||||
display: inline-block !important;
|
||||
|
||||
@@ -4,28 +4,26 @@ import Image from 'next/image'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import type { Route } from 'next'
|
||||
import type { UserRole } from '@prisma/client'
|
||||
|
||||
export const metadata: Metadata = { title: 'Monaco Ocean Protection Challenge' }
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await auth()
|
||||
|
||||
// Redirect authenticated users to their appropriate dashboard
|
||||
// Redirect authenticated users to their appropriate dashboard.
|
||||
// Reads the multi-role array (roles[]) so a user who is e.g. JURY_MEMBER+MENTOR
|
||||
// lands on /jury (their highest-priority role) rather than always falling
|
||||
// through on the singular `role` field. The context-aware variant —
|
||||
// user.getDefaultDashboard tRPC procedure — exists for surfaces that can call
|
||||
// tRPC; page.tsx uses static priority for simplicity.
|
||||
if (session?.user) {
|
||||
if (
|
||||
session.user.role === 'SUPER_ADMIN' ||
|
||||
session.user.role === 'PROGRAM_ADMIN'
|
||||
) {
|
||||
redirect('/admin')
|
||||
} else if (session.user.role === 'JURY_MEMBER') {
|
||||
redirect('/jury')
|
||||
} else if (session.user.role === 'MENTOR') {
|
||||
redirect('/mentor' as Route)
|
||||
} else if (session.user.role === 'OBSERVER') {
|
||||
redirect('/observer')
|
||||
} else if (session.user.role === 'APPLICANT') {
|
||||
redirect('/applicant' as Route)
|
||||
}
|
||||
const roles = (session.user.roles as UserRole[] | undefined) ?? [session.user.role as UserRole]
|
||||
if (roles.includes('SUPER_ADMIN') || roles.includes('PROGRAM_ADMIN')) redirect('/admin')
|
||||
if (roles.includes('JURY_MEMBER')) redirect('/jury')
|
||||
if (roles.includes('MENTOR')) redirect('/mentor' as Route)
|
||||
if (roles.includes('APPLICANT')) redirect('/applicant' as Route)
|
||||
if (roles.includes('OBSERVER')) redirect('/observer')
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { httpBatchLink } from '@trpc/client'
|
||||
import superjson from 'superjson'
|
||||
@@ -78,12 +77,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||
<SessionProvider>
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
<SessionProvider>
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -437,7 +437,7 @@ export function AssignmentPreviewSheet({
|
||||
{mode === 'ai' && !aiResult && !isAIGenerating && (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 gap-3">
|
||||
<div className="h-12 w-12 rounded-full bg-violet-100 dark:bg-violet-950 flex items-center justify-center">
|
||||
<div className="h-12 w-12 rounded-full bg-violet-100 flex items-center justify-center">
|
||||
<Sparkles className="h-6 w-6 text-violet-600" />
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
@@ -463,7 +463,7 @@ export function AssignmentPreviewSheet({
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{mode === 'ai' && (
|
||||
<Card className="border-violet-200 bg-violet-50/50 dark:bg-violet-950/20">
|
||||
<Card className="border-violet-200 bg-violet-50/50">
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="relative">
|
||||
<div className="h-6 w-6 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
|
||||
@@ -567,13 +567,13 @@ export function AssignmentPreviewSheet({
|
||||
|
||||
{/* ── Warnings ── */}
|
||||
{preview.warnings && preview.warnings.length > 0 && (
|
||||
<Card className="border-amber-300 bg-amber-50/50 dark:bg-amber-950/20">
|
||||
<Card className="border-amber-300 bg-amber-50/50">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
{preview.warnings.map((w: string, idx: number) => (
|
||||
<p key={idx} className="text-xs text-amber-800 dark:text-amber-200">
|
||||
<p key={idx} className="text-xs text-amber-800">
|
||||
{w}
|
||||
</p>
|
||||
))}
|
||||
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Loader2, Mail, ArrowRightLeft, UserPlus, Trash2 } from 'lucide-react'
|
||||
import { Loader2, Mail, ArrowRightLeft, UserPlus, Trash2, FilePen } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { TransferAssignmentsDialog } from './transfer-assignments-dialog'
|
||||
import { InlineMemberCap } from '@/components/admin/jury/inline-member-cap'
|
||||
|
||||
@@ -186,6 +188,24 @@ export function JuryProgressTable({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/admin/rounds/${roundId}/jurors/${juror.id}/evaluate` as Route}>
|
||||
<FilePen className="h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left"><p>Fill in evaluations on behalf of this juror</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -51,7 +51,11 @@ export function RoundUnassignedQueue({ roundId, requiredReviews = 3, onAssignUna
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.competitionCategory || 'No category'}
|
||||
{project.category === 'STARTUP'
|
||||
? 'Startup'
|
||||
: project.category === 'BUSINESS_CONCEPT'
|
||||
? 'Business Concept'
|
||||
: 'No category'}
|
||||
{project.teamName && ` \u00b7 ${project.teamName}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
162
src/components/admin/grand-finale/finalist-slots-card.tsx
Normal file
162
src/components/admin/grand-finale/finalist-slots-card.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Loader2, Save, Trophy } from 'lucide-react'
|
||||
import { formatEnumLabel } from '@/lib/utils'
|
||||
import type { CompetitionCategory } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
programId: string
|
||||
}
|
||||
|
||||
const CATEGORIES: CompetitionCategory[] = ['STARTUP', 'BUSINESS_CONCEPT']
|
||||
|
||||
type Row = {
|
||||
category: CompetitionCategory
|
||||
quota: number
|
||||
confirmed: number
|
||||
pending: number
|
||||
}
|
||||
|
||||
export function FinalistSlotsCard({ programId }: Props) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: quotas, isLoading: loadingQuotas } = trpc.finalist.listQuotas.useQuery({
|
||||
programId,
|
||||
})
|
||||
const { data: counts, isLoading: loadingCounts } = trpc.finalist.listCategoryCounts.useQuery({
|
||||
programId,
|
||||
})
|
||||
|
||||
const [draft, setDraft] = useState<Record<CompetitionCategory, string>>({
|
||||
STARTUP: '',
|
||||
BUSINESS_CONCEPT: '',
|
||||
})
|
||||
|
||||
// Sync draft from server response on first load / after save
|
||||
useEffect(() => {
|
||||
if (!quotas) return
|
||||
const next: Record<CompetitionCategory, string> = { STARTUP: '', BUSINESS_CONCEPT: '' }
|
||||
for (const cat of CATEGORIES) {
|
||||
const found = quotas.find((q) => q.category === cat)
|
||||
next[cat] = found ? String(found.quota) : ''
|
||||
}
|
||||
setDraft(next)
|
||||
}, [quotas])
|
||||
|
||||
const setQuotaMutation = trpc.finalist.setQuota.useMutation({
|
||||
onSuccess: (_, vars) => {
|
||||
toast.success(`${formatEnumLabel(vars.category)} quota saved`)
|
||||
utils.finalist.listQuotas.invalidate({ programId })
|
||||
utils.finalist.listCategoryCounts.invalidate({ programId })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (loadingQuotas || loadingCounts) {
|
||||
return <Skeleton className="h-44 w-full rounded-md" />
|
||||
}
|
||||
|
||||
const rows: Row[] = CATEGORIES.map((cat) => {
|
||||
const q = quotas?.find((x) => x.category === cat)
|
||||
const c = counts?.find((x) => x.category === cat)
|
||||
return {
|
||||
category: cat,
|
||||
quota: q?.quota ?? 0,
|
||||
confirmed: c?.confirmed ?? 0,
|
||||
pending: c?.pending ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
const handleSave = (category: CompetitionCategory) => {
|
||||
const raw = draft[category]
|
||||
const n = Number.parseInt(raw, 10)
|
||||
if (Number.isNaN(n) || n < 0) {
|
||||
toast.error('Quota must be a non-negative integer')
|
||||
return
|
||||
}
|
||||
setQuotaMutation.mutate({ programId, category, quota: n })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="text-muted-foreground h-4 w-4" />
|
||||
<CardTitle className="text-base">Finalist slots</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Per-category quotas. Reductions blocked when {`> `}confirmed count — un-confirm a team
|
||||
first if you need to shrink a category.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{rows.map((r) => {
|
||||
const isPending =
|
||||
setQuotaMutation.isPending &&
|
||||
setQuotaMutation.variables?.category === r.category
|
||||
const dirty = String(r.quota) !== draft[r.category]
|
||||
return (
|
||||
<div
|
||||
key={r.category}
|
||||
className="flex items-center justify-between gap-3 rounded-md border p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{formatEnumLabel(r.category)}</div>
|
||||
<div className="text-muted-foreground mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span>
|
||||
<Badge variant="default" className="text-xs">
|
||||
{r.confirmed}
|
||||
</Badge>{' '}
|
||||
confirmed
|
||||
</span>
|
||||
<span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{r.pending}
|
||||
</Badge>{' '}
|
||||
pending
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
className="w-20 tabular-nums"
|
||||
value={draft[r.category]}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, [r.category]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={dirty ? 'default' : 'outline'}
|
||||
disabled={!dirty || isPending}
|
||||
onClick={() => handleSave(r.category)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-1 h-3.5 w-3.5" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
167
src/components/admin/grand-finale/waitlist-card.tsx
Normal file
167
src/components/admin/grand-finale/waitlist-card.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { ListOrdered, Loader2 } from 'lucide-react'
|
||||
import { formatEnumLabel } from '@/lib/utils'
|
||||
import type { CompetitionCategory } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
programId: string
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }> = {
|
||||
WAITING: { label: 'Waiting', variant: 'outline' },
|
||||
PROMOTED: { label: 'Promoted', variant: 'default' },
|
||||
USED: { label: 'Used', variant: 'secondary' },
|
||||
}
|
||||
|
||||
export function WaitlistCard({ programId }: Props) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data, isLoading } = trpc.finalist.listWaitlist.useQuery({ programId })
|
||||
|
||||
const promoteMutation = trpc.finalist.manualPromote.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Waitlist entry promoted — confirmation email sent')
|
||||
utils.finalist.listWaitlist.invalidate({ programId })
|
||||
utils.finalist.listCategoryCounts.invalidate({ programId })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (isLoading) return <Skeleton className="h-44 w-full rounded-md" />
|
||||
|
||||
const byCategory = new Map<CompetitionCategory, typeof data>()
|
||||
for (const entry of data ?? []) {
|
||||
const list = byCategory.get(entry.category) ?? []
|
||||
list.push(entry)
|
||||
byCategory.set(entry.category, list)
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<ListOrdered className="text-muted-foreground h-4 w-4" />
|
||||
<CardTitle className="text-base">Waitlist</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Per-category ranked waitlist. Auto-cascades when a finalist declines or expires.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
No waitlist entries yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<ListOrdered className="text-muted-foreground h-4 w-4" />
|
||||
<CardTitle className="text-base">Waitlist</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Per-category ranked waitlist. Auto-cascades when a finalist declines or expires. You can
|
||||
manually promote out of order — overrides are audit-logged.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{Array.from(byCategory.entries()).map(([category, entries]) => (
|
||||
<div key={category}>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium uppercase tracking-wide">
|
||||
{formatEnumLabel(category)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(entries ?? []).map((entry) => {
|
||||
const badge = STATUS_LABEL[entry.status] ?? { label: entry.status, variant: 'outline' as const }
|
||||
const canPromote = entry.status === 'WAITING'
|
||||
const isPending =
|
||||
promoteMutation.isPending && promoteMutation.variables?.waitlistEntryId === entry.id
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold tabular-nums">
|
||||
{entry.rank}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">{entry.project.title}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{entry.project.country ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={badge.variant} className="text-xs">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
{canPromote && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Promote'
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Promote this team out of order?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{entry.project.title} (rank #{entry.rank}) will be promoted into a
|
||||
finalist slot. A confirmation email will be sent to the team lead
|
||||
with a 24-hour window. This override is audit-logged.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
promoteMutation.mutate({
|
||||
waitlistEntryId: entry.id,
|
||||
windowHours: 24,
|
||||
})
|
||||
}
|
||||
>
|
||||
Promote
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
235
src/components/admin/logistics/admin-attendance-dialog.tsx
Normal file
235
src/components/admin/logistics/admin-attendance-dialog.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export type AttendanceMode = 'confirm' | 'decline'
|
||||
|
||||
export function AdminAttendanceDialog({
|
||||
open,
|
||||
mode,
|
||||
confirmationId,
|
||||
programId,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
mode: AttendanceMode
|
||||
confirmationId: string | null
|
||||
programId: string
|
||||
onOpenChange: (next: boolean) => void
|
||||
}) {
|
||||
const utils = trpc.useUtils()
|
||||
const enabled = open && !!confirmationId
|
||||
const { data: detail, isLoading } = trpc.finalist.getConfirmationDetail.useQuery(
|
||||
{ confirmationId: confirmationId ?? '' },
|
||||
{ enabled },
|
||||
)
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [visa, setVisa] = useState<Record<string, boolean>>({})
|
||||
const [reason, setReason] = useState('')
|
||||
|
||||
const invalidate = () => {
|
||||
utils.logistics.listConfirmations.invalidate({ programId })
|
||||
}
|
||||
|
||||
const confirmMutation = trpc.finalist.adminConfirm.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Attendance confirmed')
|
||||
invalidate()
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
const declineMutation = trpc.finalist.adminDecline.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Marked as declined')
|
||||
invalidate()
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
// Reset form when the dialog opens for a new row
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setReason('')
|
||||
if (detail) {
|
||||
// Default-pre-select the team lead + up to cap members
|
||||
const cap = detail.project.program.defaultAttendeeCap
|
||||
const initial = new Set(
|
||||
detail.project.teamMembers.slice(0, cap).map((tm) => tm.userId),
|
||||
)
|
||||
setSelected(initial)
|
||||
setVisa({})
|
||||
}
|
||||
}, [open, detail])
|
||||
|
||||
const isPending = confirmMutation.isPending || declineMutation.isPending
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!confirmationId) return
|
||||
const ids = Array.from(selected)
|
||||
confirmMutation.mutate({
|
||||
confirmationId,
|
||||
attendingUserIds: ids,
|
||||
visaFlags: Object.fromEntries(ids.map((id) => [id, !!visa[id]])),
|
||||
})
|
||||
}
|
||||
const handleDecline = () => {
|
||||
if (!confirmationId) return
|
||||
declineMutation.mutate({
|
||||
confirmationId,
|
||||
reason: reason.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const cap = detail?.project.program.defaultAttendeeCap ?? 3
|
||||
const overCap = selected.size > cap
|
||||
const noneSelected = selected.size === 0
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!isPending) onOpenChange(next)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === 'confirm' ? 'Confirm attendance on team behalf' : 'Decline on team behalf'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'confirm'
|
||||
? 'Use this when the team replied by email. The selected attendees will be locked in just like a public confirmation.'
|
||||
: 'Use this when the team has told us they cannot attend. The slot will cascade to the next waitlist entry.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading || !detail ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
) : mode === 'confirm' ? (
|
||||
<>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Project:</span>{' '}
|
||||
<strong>{detail.project.title}</strong>
|
||||
</div>
|
||||
<ul className="space-y-2 max-h-[50vh] overflow-y-auto pr-1">
|
||||
{detail.project.teamMembers.map((tm) => {
|
||||
const checked = selected.has(tm.userId)
|
||||
return (
|
||||
<li
|
||||
key={tm.userId}
|
||||
className="flex items-start justify-between gap-4 rounded-md border px-3 py-2"
|
||||
>
|
||||
<label className="flex flex-1 items-start gap-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(c) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (c === true) next.add(tm.userId)
|
||||
else next.delete(tm.userId)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{tm.user.name ?? tm.user.email}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{tm.user.email}
|
||||
{tm.role && tm.role !== 'MEMBER' ? ` · ${tm.role.toLowerCase()}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{checked && (
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">Visa?</span>
|
||||
<Switch
|
||||
checked={!!visa[tm.userId]}
|
||||
onCheckedChange={(c) =>
|
||||
setVisa((prev) => ({ ...prev, [tm.userId]: c }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{overCap && (
|
||||
<p className="text-destructive text-sm">
|
||||
Please select no more than {cap} member{cap === 1 ? '' : 's'}.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Project:</span>{' '}
|
||||
<strong>{detail.project.title}</strong>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-muted-foreground text-sm" htmlFor="admin-decline-reason">
|
||||
Reason (optional)
|
||||
</label>
|
||||
<Textarea
|
||||
id="admin-decline-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="e.g. logistics conflict, team disbanded, no longer interested"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
{mode === 'confirm' ? (
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!detail || overCap || noneSelected || isPending}
|
||||
>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Confirm attendance
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleDecline}
|
||||
disabled={!detail || isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Mark as declined
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
246
src/components/admin/logistics/confirmations-tab.tsx
Normal file
246
src/components/admin/logistics/confirmations-tab.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatEnumLabel } from '@/lib/utils'
|
||||
import type { FinalistConfirmationStatus } from '@prisma/client'
|
||||
import { AdminAttendanceDialog, type AttendanceMode } from './admin-attendance-dialog'
|
||||
|
||||
interface Props {
|
||||
programId: string
|
||||
}
|
||||
|
||||
type StatusFilter = 'all' | FinalistConfirmationStatus
|
||||
|
||||
const STATUS_BADGE: Record<
|
||||
FinalistConfirmationStatus,
|
||||
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
|
||||
> = {
|
||||
PENDING: { label: 'Pending', variant: 'secondary' },
|
||||
CONFIRMED: { label: 'Confirmed', variant: 'default' },
|
||||
DECLINED: { label: 'Declined', variant: 'destructive' },
|
||||
EXPIRED: { label: 'Expired', variant: 'outline' },
|
||||
SUPERSEDED: { label: 'Superseded', variant: 'outline' },
|
||||
}
|
||||
|
||||
function formatDeadline(d: Date): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(d)
|
||||
}
|
||||
|
||||
function relativeFromNow(d: Date): string {
|
||||
const ms = d.getTime() - Date.now()
|
||||
if (ms <= 0) return 'past deadline'
|
||||
const hours = Math.floor(ms / 3_600_000)
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days >= 1) return `in ${days}d`
|
||||
return `in ${hours}h`
|
||||
}
|
||||
|
||||
export function ConfirmationsTab({ programId }: Props) {
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
const [dialogState, setDialogState] = useState<{
|
||||
open: boolean
|
||||
mode: AttendanceMode
|
||||
confirmationId: string | null
|
||||
}>({ open: false, mode: 'confirm', confirmationId: null })
|
||||
const { data, isLoading } = trpc.logistics.listConfirmations.useQuery(
|
||||
{ programId },
|
||||
{ refetchInterval: 60_000 },
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data) return []
|
||||
return statusFilter === 'all' ? data : data.filter((r) => r.status === statusFilter)
|
||||
}, [data, statusFilter])
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const counts: Record<FinalistConfirmationStatus, number> = {
|
||||
PENDING: 0,
|
||||
CONFIRMED: 0,
|
||||
DECLINED: 0,
|
||||
EXPIRED: 0,
|
||||
SUPERSEDED: 0,
|
||||
}
|
||||
for (const r of data ?? []) counts[r.status]++
|
||||
return counts
|
||||
}, [data])
|
||||
|
||||
const StatusPill = ({ value, label, count }: { value: StatusFilter; label: string; count: number }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(value)}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
statusFilter === value
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{label} <span className="tabular-nums opacity-80">({count})</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<CardTitle className="text-base">All confirmations</CardTitle>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<StatusPill
|
||||
value="all"
|
||||
label="All"
|
||||
count={(data ?? []).length}
|
||||
/>
|
||||
<StatusPill value="PENDING" label="Pending" count={totals.PENDING} />
|
||||
<StatusPill value="CONFIRMED" label="Confirmed" count={totals.CONFIRMED} />
|
||||
<StatusPill value="DECLINED" label="Declined" count={totals.DECLINED} />
|
||||
<StatusPill value="EXPIRED" label="Expired" count={totals.EXPIRED} />
|
||||
<StatusPill value="SUPERSEDED" label="Superseded" count={totals.SUPERSEDED} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground py-12 text-center text-sm">
|
||||
{statusFilter === 'all'
|
||||
? 'No finalists have been selected yet. Use the grand-finale round page to send confirmations.'
|
||||
: 'No confirmations match this filter.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Deadline</TableHead>
|
||||
<TableHead className="text-right">Attendees</TableHead>
|
||||
<TableHead>Notes</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((r) => {
|
||||
const badge = STATUS_BADGE[r.status]
|
||||
const isPending = r.status === 'PENDING'
|
||||
return (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{r.project.title}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{formatEnumLabel(r.category)}
|
||||
{r.project.country && (
|
||||
<>
|
||||
{' · '}
|
||||
{r.project.country}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={badge.variant} className="text-xs">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
{r.promotedFromWaitlistEntryId && (
|
||||
<Badge variant="outline" className="ml-1 text-xs">
|
||||
Waitlist
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<div>{formatDeadline(new Date(r.deadline))}</div>
|
||||
{r.status === 'PENDING' && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{relativeFromNow(new Date(r.deadline))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{r.attendeeCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-[20rem] truncate text-xs">
|
||||
{r.status === 'DECLINED' && r.declineReason
|
||||
? `Reason: ${r.declineReason}`
|
||||
: r.status === 'CONFIRMED' && r.confirmedAt
|
||||
? `Confirmed ${formatDeadline(new Date(r.confirmedAt))}`
|
||||
: r.status === 'EXPIRED' && r.expiredAt
|
||||
? `Expired ${formatDeadline(new Date(r.expiredAt))}`
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{isPending ? (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() =>
|
||||
setDialogState({
|
||||
open: true,
|
||||
mode: 'confirm',
|
||||
confirmationId: r.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setDialogState({
|
||||
open: true,
|
||||
mode: 'decline',
|
||||
confirmationId: r.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AdminAttendanceDialog
|
||||
open={dialogState.open}
|
||||
mode={dialogState.mode}
|
||||
confirmationId={dialogState.confirmationId}
|
||||
programId={programId}
|
||||
onOpenChange={(next) =>
|
||||
setDialogState((prev) => ({ ...prev, open: next }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
175
src/components/admin/logistics/hotels-tab.tsx
Normal file
175
src/components/admin/logistics/hotels-tab.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ExternalLink, Hotel as HotelIcon, Loader2, Save } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
programId: string
|
||||
}
|
||||
|
||||
export function HotelsTab({ programId }: Props) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: hotel, isLoading } = trpc.logistics.getHotel.useQuery({ programId })
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [address, setAddress] = useState('')
|
||||
const [link, setLink] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
// Sync form state from server data on first load / after save.
|
||||
useEffect(() => {
|
||||
if (hotel) {
|
||||
setName(hotel.name)
|
||||
setAddress(hotel.address ?? '')
|
||||
setLink(hotel.link ?? '')
|
||||
setNotes(hotel.notes ?? '')
|
||||
}
|
||||
}, [hotel])
|
||||
|
||||
const upsertMutation = trpc.logistics.upsertHotel.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Hotel saved')
|
||||
utils.logistics.getHotel.invalidate({ programId })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) {
|
||||
toast.error('Hotel name is required')
|
||||
return
|
||||
}
|
||||
upsertMutation.mutate({
|
||||
programId,
|
||||
name: name.trim(),
|
||||
address: address.trim() || undefined,
|
||||
link: link.trim() || '',
|
||||
notes: notes.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) return <Skeleton className="h-96 w-full" />
|
||||
|
||||
const dirty =
|
||||
name !== (hotel?.name ?? '') ||
|
||||
address !== (hotel?.address ?? '') ||
|
||||
link !== (hotel?.link ?? '') ||
|
||||
notes !== (hotel?.notes ?? '')
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="md:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<HotelIcon className="text-muted-foreground h-4 w-4" />
|
||||
<CardTitle className="text-base">Hotel for this edition</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
One hotel per edition. Used in confirmation emails and finalist communications.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hotel-name">Name *</Label>
|
||||
<Input
|
||||
id="hotel-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Hôtel de Paris"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hotel-address">Address</Label>
|
||||
<Textarea
|
||||
id="hotel-address"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="Place du Casino, 98000 Monaco"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hotel-link">Hotel website / booking link</Label>
|
||||
<Input
|
||||
id="hotel-link"
|
||||
type="url"
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
placeholder="https://hoteldeparismontecarlo.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hotel-notes">Internal notes</Label>
|
||||
<Textarea
|
||||
id="hotel-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Check-in time, special arrangements, etc."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || upsertMutation.isPending}
|
||||
>
|
||||
{upsertMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-1">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Email preview</CardTitle>
|
||||
<CardDescription>What teams will see in confirmation emails.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!name.trim() ? (
|
||||
<p className="text-muted-foreground text-sm">Save a hotel to see the preview.</p>
|
||||
) : (
|
||||
<div className="bg-muted/30 rounded-md border p-4 text-sm">
|
||||
<div className="text-muted-foreground mb-1 text-xs uppercase tracking-wide">
|
||||
Your accommodation
|
||||
</div>
|
||||
<div className="font-semibold">{name}</div>
|
||||
{address.trim() && (
|
||||
<div className="text-muted-foreground mt-1 whitespace-pre-line text-xs">
|
||||
{address}
|
||||
</div>
|
||||
)}
|
||||
{link.trim() && (
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
|
||||
>
|
||||
Visit hotel website <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
160
src/components/admin/logistics/lunch-dishes.tsx
Normal file
160
src/components/admin/logistics/lunch-dishes.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const DIETARY_TAGS = ['VEGETARIAN', 'VEGAN', 'GLUTEN_FREE', 'PESCATARIAN'] as const
|
||||
type DietaryTag = (typeof DIETARY_TAGS)[number]
|
||||
|
||||
function formatTag(tag: string): string {
|
||||
return tag.replace('_', ' ').toLowerCase()
|
||||
}
|
||||
|
||||
export function LunchDishes({
|
||||
programId,
|
||||
lunchEventId,
|
||||
}: {
|
||||
programId: string
|
||||
lunchEventId: string
|
||||
}) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: dishes } = trpc.lunch.listDishes.useQuery({ lunchEventId })
|
||||
|
||||
const invalidateAll = () => {
|
||||
utils.lunch.listDishes.invalidate({ lunchEventId })
|
||||
utils.lunch.getManifest.invalidate({ programId })
|
||||
}
|
||||
|
||||
const create = trpc.lunch.createDish.useMutation({
|
||||
onSuccess: invalidateAll,
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
const update = trpc.lunch.updateDish.useMutation({
|
||||
onSuccess: invalidateAll,
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
const del = trpc.lunch.deleteDish.useMutation({
|
||||
onSuccess: invalidateAll,
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newTags, setNewTags] = useState<DietaryTag[]>([])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Dishes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{dishes && dishes.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Add at least one dish to open picks.
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{dishes?.map((d) => (
|
||||
<li
|
||||
key={d.id}
|
||||
className="flex items-center gap-3 rounded-md border p-3"
|
||||
>
|
||||
<span className="font-medium">{d.name}</span>
|
||||
<div className="flex gap-1">
|
||||
{d.dietaryTags.map((t) => (
|
||||
<Badge key={t} variant="outline">
|
||||
{formatTag(t)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const name = prompt('Edit dish name', d.name)
|
||||
if (name && name !== d.name) {
|
||||
update.mutate({ dishId: d.id, name })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`Delete "${d.name}"? Existing picks will go back to "not picked".`,
|
||||
)
|
||||
) {
|
||||
del.mutate({ dishId: d.id })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t pt-4">
|
||||
<Input
|
||||
placeholder="New dish name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{DIETARY_TAGS.map((t) => (
|
||||
<Button
|
||||
key={t}
|
||||
size="sm"
|
||||
variant={newTags.includes(t) ? 'default' : 'outline'}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setNewTags(
|
||||
newTags.includes(t)
|
||||
? newTags.filter((x) => x !== t)
|
||||
: [...newTags, t],
|
||||
)
|
||||
}
|
||||
>
|
||||
{formatTag(t)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
disabled={!newName.trim() || create.isPending}
|
||||
onClick={() => {
|
||||
if (!newName.trim()) return
|
||||
create.mutate(
|
||||
{
|
||||
lunchEventId,
|
||||
name: newName.trim(),
|
||||
dietaryTags: newTags,
|
||||
sortOrder: dishes?.length ?? 0,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setNewName('')
|
||||
setNewTags([])
|
||||
},
|
||||
},
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
246
src/components/admin/logistics/lunch-event-config.tsx
Normal file
246
src/components/admin/logistics/lunch-event-config.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { LunchEvent } from '@prisma/client'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
function toLocalDateTimeInputValue(d: Date | null | undefined): string {
|
||||
if (!d) return ''
|
||||
// datetime-local expects "YYYY-MM-DDTHH:mm" in local time.
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
|
||||
d.getHours(),
|
||||
)}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
export function LunchEventConfig({
|
||||
programId,
|
||||
event,
|
||||
}: {
|
||||
programId: string
|
||||
event: LunchEvent
|
||||
}) {
|
||||
const utils = trpc.useUtils()
|
||||
const update = trpc.lunch.updateEvent.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.lunch.getEvent.invalidate({ programId })
|
||||
utils.lunch.getEventForMember.invalidate({ programId })
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
const [extraInput, setExtraInput] = useState('')
|
||||
|
||||
const eventAt = event.eventAt ? new Date(event.eventAt) : null
|
||||
const endAt = event.endAt ? new Date(event.endAt) : null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Event configuration</CardTitle>
|
||||
<CardDescription>Per-edition lunch settings.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* enabled */}
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lunch-enabled">Enable lunch event</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
When off, attendees see no banner or picker; admins still see this tab.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="lunch-enabled"
|
||||
checked={event.enabled}
|
||||
onCheckedChange={(v) => update.mutate({ programId, enabled: v })}
|
||||
disabled={update.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* eventAt */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="event-at">Event start</Label>
|
||||
<Input
|
||||
id="event-at"
|
||||
type="datetime-local"
|
||||
defaultValue={toLocalDateTimeInputValue(eventAt)}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value
|
||||
update.mutate({ programId, eventAt: v ? new Date(v) : null })
|
||||
}}
|
||||
disabled={update.isPending}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* endAt */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="end-at">Event end (optional)</Label>
|
||||
<Input
|
||||
id="end-at"
|
||||
type="datetime-local"
|
||||
defaultValue={toLocalDateTimeInputValue(endAt)}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value
|
||||
update.mutate({ programId, endAt: v ? new Date(v) : null })
|
||||
}}
|
||||
disabled={update.isPending}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* venue */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="venue">Venue (optional)</Label>
|
||||
<Input
|
||||
id="venue"
|
||||
defaultValue={event.venue ?? ''}
|
||||
placeholder="e.g. Hôtel Hermitage, Salle Belle Époque"
|
||||
onBlur={(e) =>
|
||||
update.mutate({ programId, venue: e.target.value || null })
|
||||
}
|
||||
disabled={update.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* notes */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="notes">Notes for attendees (optional)</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
defaultValue={event.notes ?? ''}
|
||||
placeholder="Wine pairings included. Vegetarian options at table 4."
|
||||
onBlur={(e) =>
|
||||
update.mutate({ programId, notes: e.target.value || null })
|
||||
}
|
||||
disabled={update.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* changeCutoffHours */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cutoff">Change cutoff (hours before event)</Label>
|
||||
<Input
|
||||
id="cutoff"
|
||||
type="number"
|
||||
min={0}
|
||||
max={720}
|
||||
defaultValue={event.changeCutoffHours}
|
||||
onBlur={(e) => {
|
||||
const n = Number(e.target.value)
|
||||
if (Number.isFinite(n) && n !== event.changeCutoffHours) {
|
||||
update.mutate({ programId, changeCutoffHours: n })
|
||||
}
|
||||
}}
|
||||
disabled={update.isPending}
|
||||
className="max-w-[12rem]"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
After this many hours before the event, attendees and team leads can
|
||||
no longer change their picks. Admins always can.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* reminderHoursBeforeDeadline */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="reminder">Reminder (hours before deadline)</Label>
|
||||
<Input
|
||||
id="reminder"
|
||||
type="number"
|
||||
min={0}
|
||||
max={720}
|
||||
defaultValue={event.reminderHoursBeforeDeadline ?? ''}
|
||||
placeholder="Leave blank for no reminder"
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value
|
||||
update.mutate({
|
||||
programId,
|
||||
reminderHoursBeforeDeadline: v === '' ? null : Number(v),
|
||||
})
|
||||
}}
|
||||
disabled={update.isPending}
|
||||
className="max-w-[12rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* cronEnabled */}
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="cron-enabled">Auto-send recap at deadline</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
When on, the platform automatically emails the manifest when the
|
||||
change deadline passes.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="cron-enabled"
|
||||
checked={event.cronEnabled}
|
||||
onCheckedChange={(v) => update.mutate({ programId, cronEnabled: v })}
|
||||
disabled={update.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* extraRecipients */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Extra recap recipients (optional)</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
All edition admins receive the recap automatically. Add additional
|
||||
email addresses here (e.g. caterer, event manager).
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{event.extraRecipients.map((email) => (
|
||||
<Badge key={email} variant="secondary" className="gap-1">
|
||||
{email}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1"
|
||||
onClick={() =>
|
||||
update.mutate({
|
||||
programId,
|
||||
extraRecipients: event.extraRecipients.filter(
|
||||
(e) => e !== email,
|
||||
),
|
||||
})
|
||||
}
|
||||
aria-label={`Remove ${email}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
placeholder="email@example.com — press Enter to add"
|
||||
value={extraInput}
|
||||
onChange={(e) => setExtraInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && extraInput.trim()) {
|
||||
e.preventDefault()
|
||||
const next = [
|
||||
...event.extraRecipients,
|
||||
extraInput.trim(),
|
||||
]
|
||||
update.mutate({ programId, extraRecipients: next })
|
||||
setExtraInput('')
|
||||
}
|
||||
}}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
341
src/components/admin/logistics/lunch-externals.tsx
Normal file
341
src/components/admin/logistics/lunch-externals.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useImperativeHandle, forwardRef } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const ALLERGENS = [
|
||||
'GLUTEN',
|
||||
'CRUSTACEANS',
|
||||
'EGGS',
|
||||
'FISH',
|
||||
'PEANUTS',
|
||||
'SOYBEANS',
|
||||
'MILK',
|
||||
'TREE_NUTS',
|
||||
'CELERY',
|
||||
'MUSTARD',
|
||||
'SESAME',
|
||||
'SULPHITES',
|
||||
'LUPIN',
|
||||
'MOLLUSCS',
|
||||
] as const
|
||||
type Allergen = (typeof ALLERGENS)[number]
|
||||
|
||||
const STANDALONE = '__standalone__'
|
||||
const NO_DISH = '__no_dish__'
|
||||
|
||||
type Editing = { mode: 'new' } | { mode: 'edit'; id: string } | null
|
||||
|
||||
export type LunchExternalsHandle = {
|
||||
openEditDialog: (id: string) => void
|
||||
}
|
||||
|
||||
export const LunchExternals = forwardRef<
|
||||
LunchExternalsHandle,
|
||||
{ programId: string; lunchEventId: string }
|
||||
>(function LunchExternals({ programId, lunchEventId }, ref) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: externals } = trpc.lunch.listExternals.useQuery({ lunchEventId })
|
||||
const { data: dishes } = trpc.lunch.listDishes.useQuery({ lunchEventId })
|
||||
const { data: projects } = trpc.program.listFinalistProjects.useQuery({
|
||||
programId,
|
||||
})
|
||||
|
||||
const [editing, setEditing] = useState<Editing>(null)
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
openEditDialog: (id: string) => setEditing({ mode: 'edit', id }),
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
const invalidateAll = () => {
|
||||
utils.lunch.listExternals.invalidate({ lunchEventId })
|
||||
utils.lunch.getManifest.invalidate({ programId })
|
||||
}
|
||||
|
||||
const create = trpc.lunch.createExternal.useMutation({
|
||||
onSuccess: invalidateAll,
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
const update = trpc.lunch.updateExternal.useMutation({
|
||||
onSuccess: invalidateAll,
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
const del = trpc.lunch.deleteExternal.useMutation({
|
||||
onSuccess: invalidateAll,
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const editingRow =
|
||||
editing?.mode === 'edit'
|
||||
? (externals?.find((e) => e.id === editing.id) ?? null)
|
||||
: null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>External attendees</span>
|
||||
<Button size="sm" onClick={() => setEditing({ mode: 'new' })}>
|
||||
<Plus className="mr-1 h-4 w-4" /> Add external
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{externals?.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No external attendees yet. Add jurors, dignitaries, or per-team plus-ones.
|
||||
</p>
|
||||
)}
|
||||
{externals && externals.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{externals.map((e) => (
|
||||
<tr key={e.id} className="border-b last:border-b-0">
|
||||
<td className="py-2 font-medium">{e.name}</td>
|
||||
<td className="text-muted-foreground">
|
||||
{e.project?.title ?? 'Standalone'}
|
||||
</td>
|
||||
<td className="text-muted-foreground">{e.roleNote ?? ''}</td>
|
||||
<td className="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditing({ mode: 'edit', id: e.id })}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete external attendee "${e.name}"?`)) {
|
||||
del.mutate({ externalId: e.id })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{editing && (
|
||||
<ExternalDialog
|
||||
mode={editing.mode}
|
||||
initial={editingRow}
|
||||
dishes={dishes ?? []}
|
||||
projects={projects ?? []}
|
||||
submitting={create.isPending || update.isPending}
|
||||
onClose={() => setEditing(null)}
|
||||
onSubmit={(values) => {
|
||||
if (editing.mode === 'new') {
|
||||
create.mutate(
|
||||
{ lunchEventId, ...values },
|
||||
{ onSuccess: () => setEditing(null) },
|
||||
)
|
||||
} else {
|
||||
update.mutate(
|
||||
{ externalId: editing.id, ...values },
|
||||
{ onSuccess: () => setEditing(null) },
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
|
||||
function ExternalDialog({
|
||||
mode,
|
||||
initial,
|
||||
dishes,
|
||||
projects,
|
||||
submitting,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
mode: 'new' | 'edit'
|
||||
initial: {
|
||||
name: string
|
||||
email: string | null
|
||||
projectId: string | null
|
||||
roleNote: string | null
|
||||
dishId: string | null
|
||||
allergens: string[]
|
||||
allergenOther: string | null
|
||||
} | null
|
||||
dishes: Array<{ id: string; name: string }>
|
||||
projects: Array<{ id: string; title: string }>
|
||||
submitting: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (values: {
|
||||
name: string
|
||||
email?: string
|
||||
projectId?: string | null
|
||||
roleNote?: string
|
||||
dishId?: string | null
|
||||
allergens: Allergen[]
|
||||
allergenOther?: string | null
|
||||
}) => void
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? '')
|
||||
const [email, setEmail] = useState(initial?.email ?? '')
|
||||
const [projectId, setProjectId] = useState(initial?.projectId ?? '')
|
||||
const [roleNote, setRoleNote] = useState(initial?.roleNote ?? '')
|
||||
const [dishId, setDishId] = useState(initial?.dishId ?? '')
|
||||
const [allergens, setAllergens] = useState<Allergen[]>(
|
||||
(initial?.allergens as Allergen[]) ?? [],
|
||||
)
|
||||
const [allergenOther, setAllergenOther] = useState(initial?.allergenOther ?? '')
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onClose() }}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === 'new' ? 'Add external attendee' : 'Edit external attendee'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Name *</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Email (optional)</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Project (optional)</Label>
|
||||
<Select
|
||||
value={projectId === '' ? STANDALONE : projectId}
|
||||
onValueChange={(v) => setProjectId(v === STANDALONE ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={STANDALONE}>Standalone</SelectItem>
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Role / note (optional)</Label>
|
||||
<Input
|
||||
value={roleNote}
|
||||
onChange={(e) => setRoleNote(e.target.value)}
|
||||
placeholder="e.g. Foundation rep, Speaker, Sponsor"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Dish</Label>
|
||||
<Select
|
||||
value={dishId === '' ? NO_DISH : dishId}
|
||||
onValueChange={(v) => setDishId(v === NO_DISH ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Not picked" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_DISH}>Not picked</SelectItem>
|
||||
{dishes.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Allergens</Label>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{ALLERGENS.map((a) => (
|
||||
<label key={a} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={allergens.includes(a)}
|
||||
onCheckedChange={(v) =>
|
||||
setAllergens(
|
||||
v ? [...allergens, a] : allergens.filter((x) => x !== a),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{a.replace('_', ' ').toLowerCase()}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Other allergens / notes (optional)</Label>
|
||||
<Textarea
|
||||
value={allergenOther}
|
||||
onChange={(e) => setAllergenOther(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!name.trim() || submitting}
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
email: email.trim() || undefined,
|
||||
projectId: projectId || null,
|
||||
roleNote: roleNote.trim() || undefined,
|
||||
dishId: dishId || null,
|
||||
allergens,
|
||||
allergenOther: allergenOther.trim() || null,
|
||||
})
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
254
src/components/admin/logistics/lunch-manifest.tsx
Normal file
254
src/components/admin/logistics/lunch-manifest.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from '@/components/ui/sheet'
|
||||
import { LunchPickForm } from '@/components/applicant/lunch-pick-form'
|
||||
import { Pencil, Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
function formatAllergens(allergens: string[], other: string | null): string {
|
||||
return [...allergens.map((a) => a.replace('_', ' ').toLowerCase()), other]
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
function DownloadCsvButton({ programId }: { programId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const [pending, setPending] = useState(false)
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={async () => {
|
||||
setPending(true)
|
||||
try {
|
||||
const csv = await utils.client.lunch.exportManifestCsv.query({ programId })
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'lunch-manifest.csv'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download className="mr-1 h-4 w-4" /> Download CSV
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function LunchManifest({
|
||||
programId,
|
||||
onEditExternal,
|
||||
}: {
|
||||
programId: string
|
||||
onEditExternal?: (externalId: string) => void
|
||||
}) {
|
||||
const { data } = trpc.lunch.getManifest.useQuery({ programId })
|
||||
const [search, setSearch] = useState('')
|
||||
const [missingOnly, setMissingOnly] = useState(false)
|
||||
const [editingMemberId, setEditingMemberId] = useState<string | null>(null)
|
||||
const editingMember = data?.members.find(
|
||||
(m) => m.attendingMemberId === editingMemberId,
|
||||
)
|
||||
|
||||
type Row =
|
||||
| (NonNullable<typeof data>['members'][number] & { sortKey: string })
|
||||
| (NonNullable<typeof data>['externals'][number] & { sortKey: string })
|
||||
|
||||
const rows: Row[] = useMemo(() => {
|
||||
if (!data) return []
|
||||
const all: Row[] = [
|
||||
...data.members.map((m) => ({
|
||||
...m,
|
||||
sortKey: `0-${m.project?.name ?? ''}-${m.name}`,
|
||||
})),
|
||||
...data.externals.map((e) => ({
|
||||
...e,
|
||||
sortKey: `1-${e.project?.name ?? ''}-${e.name}`,
|
||||
})),
|
||||
]
|
||||
return all
|
||||
.filter(
|
||||
(r) =>
|
||||
!search ||
|
||||
(r.project?.name ?? '').toLowerCase().includes(search.toLowerCase()) ||
|
||||
r.name.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
.filter((r) => !missingOnly || !r.dish)
|
||||
.sort((a, b) => a.sortKey.localeCompare(b.sortKey))
|
||||
}, [data, search, missingOnly])
|
||||
|
||||
if (!data) return null
|
||||
|
||||
// Aggregate dietary + allergen counts client-side for the summary chip
|
||||
const dietaryCounts: Record<string, number> = {}
|
||||
const allergenCounts: Record<string, number> = {}
|
||||
const allRows = [...data.members, ...data.externals]
|
||||
for (const r of allRows) {
|
||||
if (r.dish) {
|
||||
for (const t of r.dish.dietaryTags) {
|
||||
dietaryCounts[t] = (dietaryCounts[t] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
for (const a of r.allergens) {
|
||||
allergenCounts[a] = (allergenCounts[a] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex flex-wrap items-center gap-2">
|
||||
<span>Manifest</span>
|
||||
<Badge variant="outline">
|
||||
{data.summary.picked}/{data.summary.total} picked
|
||||
{data.summary.missing > 0 ? ` · ${data.summary.missing} missing` : ''}
|
||||
</Badge>
|
||||
{Object.entries(dietaryCounts).map(([tag, n]) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{n} {tag.replace('_', ' ').toLowerCase()}
|
||||
</Badge>
|
||||
))}
|
||||
{Object.entries(allergenCounts).map(([a, n]) => (
|
||||
<Badge key={a} variant="destructive">
|
||||
{n} {a.replace('_', ' ').toLowerCase()}
|
||||
</Badge>
|
||||
))}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Input
|
||||
placeholder="Filter by team or name"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="missing-only"
|
||||
checked={missingOnly}
|
||||
onCheckedChange={setMissingOnly}
|
||||
/>
|
||||
<Label htmlFor="missing-only">Missing picks only</Label>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<DownloadCsvButton programId={programId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-muted-foreground border-b text-left">
|
||||
<tr>
|
||||
<th className="py-2 font-medium">Team</th>
|
||||
<th className="font-medium">Attendee</th>
|
||||
<th className="font-medium">Type</th>
|
||||
<th className="font-medium">Dish</th>
|
||||
<th className="font-medium">Allergens</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const id =
|
||||
r.kind === 'MEMBER' ? r.attendingMemberId : r.externalId
|
||||
return (
|
||||
<tr key={id} className="border-b">
|
||||
<td className="py-2">{r.project?.name ?? '—'}</td>
|
||||
<td>{r.name}</td>
|
||||
<td>
|
||||
<Badge variant="outline">
|
||||
{r.kind === 'MEMBER' ? 'Member' : 'External'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>
|
||||
{r.dish ? (
|
||||
r.dish.name
|
||||
) : (
|
||||
<span className="text-muted-foreground">not picked</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-muted-foreground">
|
||||
{formatAllergens(r.allergens, r.allergenOther)}
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (r.kind === 'EXTERNAL') {
|
||||
onEditExternal?.(r.externalId)
|
||||
} else {
|
||||
setEditingMemberId(r.attendingMemberId)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-muted-foreground py-6 text-center">
|
||||
No rows match the current filter.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<Sheet
|
||||
open={!!editingMemberId}
|
||||
onOpenChange={(o) => { if (!o) setEditingMemberId(null) }}
|
||||
>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Edit lunch pick</SheetTitle>
|
||||
{editingMember && (
|
||||
<SheetDescription>
|
||||
{editingMember.name} · {editingMember.project?.name}
|
||||
</SheetDescription>
|
||||
)}
|
||||
</SheetHeader>
|
||||
{editingMemberId && data?.event && (
|
||||
<div className="mt-6">
|
||||
<LunchPickForm
|
||||
attendingMemberId={editingMemberId}
|
||||
programId={programId}
|
||||
lunchEventId={data.event.id}
|
||||
canEdit
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
147
src/components/admin/logistics/lunch-recap-actions.tsx
Normal file
147
src/components/admin/logistics/lunch-recap-actions.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Send, Eye } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function LunchRecapActions({
|
||||
programId,
|
||||
recapSentAt,
|
||||
extraRecipientCount,
|
||||
}: {
|
||||
programId: string
|
||||
recapSentAt: Date | null
|
||||
extraRecipientCount: number
|
||||
}) {
|
||||
const utils = trpc.useUtils()
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
|
||||
const send = trpc.lunch.sendRecap.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.lunch.getEvent.invalidate({ programId })
|
||||
toast.success('Recap sent')
|
||||
},
|
||||
onError: (e) => {
|
||||
if (e.data?.code === 'PRECONDITION_FAILED') {
|
||||
if (
|
||||
confirm(
|
||||
"You've already sent a recap. Send updated version to all recipients?",
|
||||
)
|
||||
) {
|
||||
send.mutate({ programId, forceUpdate: true })
|
||||
}
|
||||
} else {
|
||||
toast.error(e.message)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const { data: preview, isLoading: loadingPreview } =
|
||||
trpc.lunch.getRecapPreview.useQuery(
|
||||
{ programId },
|
||||
{ enabled: previewOpen },
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recap</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => setPreviewOpen(true)}>
|
||||
<Eye className="mr-2 h-4 w-4" /> Preview recap
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => send.mutate({ programId })}
|
||||
disabled={send.isPending}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" /> Send recap now
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{recapSentAt
|
||||
? `Last sent: ${new Date(recapSentAt).toLocaleString()}. Recipients: edition admins${extraRecipientCount > 0 ? ` + ${extraRecipientCount} extra` : ''}.`
|
||||
: 'Recap has not been sent yet.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Recap preview</DialogTitle>
|
||||
</DialogHeader>
|
||||
{loadingPreview && (
|
||||
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||
)}
|
||||
{preview && (
|
||||
<div className="space-y-4 text-sm">
|
||||
<p>
|
||||
<strong>
|
||||
{preview.summary.picked}/{preview.summary.total}
|
||||
</strong>{' '}
|
||||
picked
|
||||
{preview.summary.missing > 0
|
||||
? ` · ${preview.summary.missing} missing`
|
||||
: ''}
|
||||
</p>
|
||||
{Object.keys(preview.dishCounts).length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium">Dishes</h4>
|
||||
<ul className="ml-4 list-disc">
|
||||
{Object.entries(preview.dishCounts).map(([n, c]) => (
|
||||
<li key={n}>
|
||||
{c}× {n}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(preview.dietaryCounts).length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium">Dietary tags</h4>
|
||||
<ul className="ml-4 list-disc">
|
||||
{Object.entries(preview.dietaryCounts).map(([n, c]) => (
|
||||
<li key={n}>
|
||||
{c}× {n.replace('_', ' ').toLowerCase()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h4 className="font-medium">Allergens</h4>
|
||||
{Object.keys(preview.allergenCounts).length === 0 ? (
|
||||
<p className="text-muted-foreground">None reported.</p>
|
||||
) : (
|
||||
<ul className="ml-4 list-disc">
|
||||
{Object.entries(preview.allergenCounts).map(([n, c]) => (
|
||||
<li key={n}>
|
||||
{c}× {n.replace('_', ' ').toLowerCase()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPreviewOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
42
src/components/admin/logistics/lunch-tab.tsx
Normal file
42
src/components/admin/logistics/lunch-tab.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { LunchEventConfig } from './lunch-event-config'
|
||||
import { LunchDishes } from './lunch-dishes'
|
||||
import { LunchManifest } from './lunch-manifest'
|
||||
import { LunchExternals, type LunchExternalsHandle } from './lunch-externals'
|
||||
import { LunchRecapActions } from './lunch-recap-actions'
|
||||
|
||||
export function LunchTab({ programId }: { programId: string }) {
|
||||
const { data: event, isLoading } = trpc.lunch.getEvent.useQuery({ programId })
|
||||
const externalsRef = useRef<LunchExternalsHandle>(null)
|
||||
if (isLoading || !event) {
|
||||
return <Skeleton className="h-48 w-full" />
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<LunchEventConfig programId={programId} event={event} />
|
||||
{event.enabled && (
|
||||
<>
|
||||
<LunchDishes programId={programId} lunchEventId={event.id} />
|
||||
<LunchManifest
|
||||
programId={programId}
|
||||
onEditExternal={(id) => externalsRef.current?.openEditDialog(id)}
|
||||
/>
|
||||
<LunchExternals
|
||||
ref={externalsRef}
|
||||
programId={programId}
|
||||
lunchEventId={event.id}
|
||||
/>
|
||||
<LunchRecapActions
|
||||
programId={programId}
|
||||
recapSentAt={event.recapSentAt}
|
||||
extraRecipientCount={event.extraRecipients.length}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
426
src/components/admin/logistics/travel-tab.tsx
Normal file
426
src/components/admin/logistics/travel-tab.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Loader2, Plane } from 'lucide-react'
|
||||
import type { FlightDetailStatus } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
programId: string
|
||||
}
|
||||
|
||||
type AttendeeRow = {
|
||||
id: string
|
||||
needsVisa: boolean
|
||||
user: { id: string; name: string | null; email: string; country: string | null }
|
||||
confirmation: {
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
country: string | null
|
||||
competitionCategory: string | null
|
||||
}
|
||||
}
|
||||
flightDetail: {
|
||||
id: string
|
||||
arrivalAt: Date | null
|
||||
arrivalFlightNumber: string | null
|
||||
arrivalAirport: string | null
|
||||
departureAt: Date | null
|
||||
departureFlightNumber: string | null
|
||||
departureAirport: string | null
|
||||
status: FlightDetailStatus
|
||||
adminNotes: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
type StatusFilter = 'all' | 'PENDING' | 'CONFIRMED' | 'unfilled'
|
||||
|
||||
function formatDateTime(d: Date | null): string {
|
||||
if (!d) return '—'
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(d))
|
||||
}
|
||||
|
||||
function isoLocalForInput(d: Date | null): string {
|
||||
if (!d) return ''
|
||||
// Format as 'YYYY-MM-DDTHH:mm' for datetime-local input
|
||||
const local = new Date(d.getTime() - new Date().getTimezoneOffset() * 60000)
|
||||
return local.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function FlightEditorSheet({
|
||||
attendee,
|
||||
programId,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
attendee: AttendeeRow | null
|
||||
programId: string
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const utils = trpc.useUtils()
|
||||
const [arrivalAt, setArrivalAt] = useState('')
|
||||
const [arrivalFlightNumber, setArrivalFlightNumber] = useState('')
|
||||
const [arrivalAirport, setArrivalAirport] = useState('')
|
||||
const [departureAt, setDepartureAt] = useState('')
|
||||
const [departureFlightNumber, setDepartureFlightNumber] = useState('')
|
||||
const [departureAirport, setDepartureAirport] = useState('')
|
||||
const [adminNotes, setAdminNotes] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!attendee) return
|
||||
const fd = attendee.flightDetail
|
||||
setArrivalAt(isoLocalForInput(fd?.arrivalAt ?? null))
|
||||
setArrivalFlightNumber(fd?.arrivalFlightNumber ?? '')
|
||||
setArrivalAirport(fd?.arrivalAirport ?? '')
|
||||
setDepartureAt(isoLocalForInput(fd?.departureAt ?? null))
|
||||
setDepartureFlightNumber(fd?.departureFlightNumber ?? '')
|
||||
setDepartureAirport(fd?.departureAirport ?? '')
|
||||
setAdminNotes(fd?.adminNotes ?? '')
|
||||
}, [attendee])
|
||||
|
||||
const upsertMutation = trpc.logistics.upsertFlightDetail.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Flight details saved')
|
||||
utils.logistics.listFlightDetails.invalidate({ programId })
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (!attendee) return null
|
||||
|
||||
const handleSave = () => {
|
||||
upsertMutation.mutate({
|
||||
attendingMemberId: attendee.id,
|
||||
arrivalAt: arrivalAt ? new Date(arrivalAt) : null,
|
||||
arrivalFlightNumber: arrivalFlightNumber.trim() || null,
|
||||
arrivalAirport: arrivalAirport.trim().toUpperCase() || null,
|
||||
departureAt: departureAt ? new Date(departureAt) : null,
|
||||
departureFlightNumber: departureFlightNumber.trim() || null,
|
||||
departureAirport: departureAirport.trim().toUpperCase() || null,
|
||||
adminNotes: adminNotes.trim() || null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<SheetContent className="sm:max-w-md overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{attendee.user.name ?? attendee.user.email}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{attendee.confirmation.project.title}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-5 py-6">
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
Arrival
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="arr-at">Date & time</Label>
|
||||
<Input
|
||||
id="arr-at"
|
||||
type="datetime-local"
|
||||
value={arrivalAt}
|
||||
onChange={(e) => setArrivalAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="arr-flight">Flight number</Label>
|
||||
<Input
|
||||
id="arr-flight"
|
||||
value={arrivalFlightNumber}
|
||||
onChange={(e) => setArrivalFlightNumber(e.target.value)}
|
||||
placeholder="AF7400"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="arr-airport">Airport (IATA)</Label>
|
||||
<Input
|
||||
id="arr-airport"
|
||||
value={arrivalAirport}
|
||||
onChange={(e) => setArrivalAirport(e.target.value)}
|
||||
placeholder="NCE"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
Departure
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dep-at">Date & time</Label>
|
||||
<Input
|
||||
id="dep-at"
|
||||
type="datetime-local"
|
||||
value={departureAt}
|
||||
onChange={(e) => setDepartureAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dep-flight">Flight number</Label>
|
||||
<Input
|
||||
id="dep-flight"
|
||||
value={departureFlightNumber}
|
||||
onChange={(e) => setDepartureFlightNumber(e.target.value)}
|
||||
placeholder="AF7405"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dep-airport">Airport (IATA)</Label>
|
||||
<Input
|
||||
id="dep-airport"
|
||||
value={departureAirport}
|
||||
onChange={(e) => setDepartureAirport(e.target.value)}
|
||||
placeholder="NCE"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="notes">Admin notes</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={adminNotes}
|
||||
onChange={(e) => setAdminNotes(e.target.value)}
|
||||
placeholder="e.g. paid by program, awaiting receipt"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={upsertMutation.isPending}>
|
||||
{upsertMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Save
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export function TravelTab({ programId }: Props) {
|
||||
const utils = trpc.useUtils()
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
const [editing, setEditing] = useState<AttendeeRow | null>(null)
|
||||
|
||||
const { data, isLoading } = trpc.logistics.listFlightDetails.useQuery(
|
||||
{ programId },
|
||||
{ refetchInterval: 60_000 },
|
||||
)
|
||||
|
||||
const setStatusMutation = trpc.logistics.setFlightStatus.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Status updated')
|
||||
utils.logistics.listFlightDetails.invalidate({ programId })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data) return []
|
||||
if (statusFilter === 'all') return data
|
||||
if (statusFilter === 'unfilled') return data.filter((r) => !r.flightDetail)
|
||||
return data.filter((r) => r.flightDetail?.status === statusFilter)
|
||||
}, [data, statusFilter])
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const c = { all: 0, PENDING: 0, CONFIRMED: 0, unfilled: 0 }
|
||||
for (const r of data ?? []) {
|
||||
c.all++
|
||||
if (!r.flightDetail) c.unfilled++
|
||||
else c[r.flightDetail.status]++
|
||||
}
|
||||
return c
|
||||
}, [data])
|
||||
|
||||
const StatusPill = ({
|
||||
value,
|
||||
label,
|
||||
count,
|
||||
}: {
|
||||
value: StatusFilter
|
||||
label: string
|
||||
count: number
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(value)}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
statusFilter === value
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{label} <span className="tabular-nums opacity-80">({count})</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plane className="text-muted-foreground h-4 w-4" />
|
||||
<CardTitle className="text-base">Travel for confirmed finalists</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<StatusPill value="all" label="All" count={totals.all} />
|
||||
<StatusPill value="unfilled" label="Unfilled" count={totals.unfilled} />
|
||||
<StatusPill value="PENDING" label="Pending" count={totals.PENDING} />
|
||||
<StatusPill value="CONFIRMED" label="Confirmed" count={totals.CONFIRMED} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground py-12 text-center text-sm">
|
||||
{data && data.length === 0
|
||||
? 'No confirmed finalist attendees yet.'
|
||||
: 'No attendees match this filter.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Attendee</TableHead>
|
||||
<TableHead>Arrival</TableHead>
|
||||
<TableHead>Departure</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((r) => {
|
||||
const fd = r.flightDetail
|
||||
return (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">
|
||||
{r.user.name ?? r.user.email}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{r.confirmation.project.title}
|
||||
{r.needsVisa ? ' · needs visa' : ''}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<div>{formatDateTime(fd?.arrivalAt ?? null)}</div>
|
||||
{(fd?.arrivalFlightNumber || fd?.arrivalAirport) && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{fd.arrivalFlightNumber ?? '—'}
|
||||
{fd.arrivalAirport ? ` · ${fd.arrivalAirport}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<div>{formatDateTime(fd?.departureAt ?? null)}</div>
|
||||
{(fd?.departureFlightNumber || fd?.departureAirport) && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{fd.departureFlightNumber ?? '—'}
|
||||
{fd.departureAirport ? ` · ${fd.departureAirport}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{fd ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setStatusMutation.mutate({
|
||||
flightDetailId: fd.id,
|
||||
status: fd.status === 'PENDING' ? 'CONFIRMED' : 'PENDING',
|
||||
})
|
||||
}
|
||||
className="cursor-pointer"
|
||||
title="Click to toggle"
|
||||
>
|
||||
<Badge
|
||||
variant={fd.status === 'CONFIRMED' ? 'default' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
{fd.status === 'CONFIRMED' ? 'Confirmed' : 'Pending'}
|
||||
</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
No info
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditing(r as AttendeeRow)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FlightEditorSheet
|
||||
attendee={editing}
|
||||
programId={programId}
|
||||
open={!!editing}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
217
src/components/admin/logistics/visa-edit-dialog.tsx
Normal file
217
src/components/admin/logistics/visa-edit-dialog.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { VisaStatus } from '@prisma/client'
|
||||
|
||||
const STATUS_OPTIONS: { value: VisaStatus; label: string }[] = [
|
||||
{ value: 'NOT_NEEDED', label: 'Not needed' },
|
||||
{ value: 'REQUESTED', label: 'Requested' },
|
||||
{ value: 'INVITATION_SENT', label: 'Invitation sent' },
|
||||
{ value: 'APPOINTMENT_BOOKED', label: 'Appointment booked' },
|
||||
{ value: 'GRANTED', label: 'Granted' },
|
||||
{ value: 'DENIED', label: 'Denied' },
|
||||
]
|
||||
|
||||
function toDateInputValue(d: Date | null | undefined): string {
|
||||
if (!d) return ''
|
||||
const dt = new Date(d)
|
||||
if (Number.isNaN(dt.getTime())) return ''
|
||||
// YYYY-MM-DD for <input type="date">
|
||||
return dt.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function fromDateInputValue(s: string): Date | null {
|
||||
if (!s) return null
|
||||
const dt = new Date(s)
|
||||
return Number.isNaN(dt.getTime()) ? null : dt
|
||||
}
|
||||
|
||||
export type VisaEditTarget = {
|
||||
id: string
|
||||
status: VisaStatus
|
||||
nationality: string | null
|
||||
invitationSentAt: Date | null
|
||||
appointmentAt: Date | null
|
||||
decisionAt: Date | null
|
||||
notes: string | null
|
||||
attendeeName: string
|
||||
projectTitle: string
|
||||
}
|
||||
|
||||
export function VisaEditDialog({
|
||||
open,
|
||||
target,
|
||||
programId,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
target: VisaEditTarget | null
|
||||
programId: string
|
||||
onOpenChange: (next: boolean) => void
|
||||
}) {
|
||||
const utils = trpc.useUtils()
|
||||
const [status, setStatus] = useState<VisaStatus>('REQUESTED')
|
||||
const [nationality, setNationality] = useState('')
|
||||
const [invitationSent, setInvitationSent] = useState('')
|
||||
const [appointment, setAppointment] = useState('')
|
||||
const [decision, setDecision] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (target && open) {
|
||||
setStatus(target.status)
|
||||
setNationality(target.nationality ?? '')
|
||||
setInvitationSent(toDateInputValue(target.invitationSentAt))
|
||||
setAppointment(toDateInputValue(target.appointmentAt))
|
||||
setDecision(toDateInputValue(target.decisionAt))
|
||||
setNotes(target.notes ?? '')
|
||||
}
|
||||
}, [target, open])
|
||||
|
||||
const mutation = trpc.logistics.updateVisaApplication.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Visa application updated')
|
||||
utils.logistics.listVisaApplications.invalidate({ programId })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
if (!target) return
|
||||
mutation.mutate({
|
||||
id: target.id,
|
||||
status,
|
||||
nationality: nationality.trim() || null,
|
||||
invitationSentAt: fromDateInputValue(invitationSent),
|
||||
appointmentAt: fromDateInputValue(appointment),
|
||||
decisionAt: fromDateInputValue(decision),
|
||||
notes: notes.trim() || null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!mutation.isPending) onOpenChange(next)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update visa application</DialogTitle>
|
||||
<DialogDescription>
|
||||
{target
|
||||
? `${target.attendeeName} · ${target.projectTitle}`
|
||||
: 'Loading…'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visa-status">Status</Label>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as VisaStatus)}>
|
||||
<SelectTrigger id="visa-status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visa-nationality">Nationality</Label>
|
||||
<Input
|
||||
id="visa-nationality"
|
||||
value={nationality}
|
||||
onChange={(e) => setNationality(e.target.value)}
|
||||
placeholder="Self-declared, optional"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visa-invitation">Invitation sent</Label>
|
||||
<Input
|
||||
id="visa-invitation"
|
||||
type="date"
|
||||
value={invitationSent}
|
||||
onChange={(e) => setInvitationSent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visa-appointment">Appointment</Label>
|
||||
<Input
|
||||
id="visa-appointment"
|
||||
type="date"
|
||||
value={appointment}
|
||||
onChange={(e) => setAppointment(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visa-decision">Decision</Label>
|
||||
<Input
|
||||
id="visa-decision"
|
||||
type="date"
|
||||
value={decision}
|
||||
onChange={(e) => setDecision(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visa-notes">Notes</Label>
|
||||
<Textarea
|
||||
id="visa-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Free-text notes — embassy, contact, follow-ups, etc. No documents."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!target || mutation.isPending}>
|
||||
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
249
src/components/admin/logistics/visas-tab.tsx
Normal file
249
src/components/admin/logistics/visas-tab.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Settings as SettingsIcon, ShieldOff } from 'lucide-react'
|
||||
import { VisaEditDialog, type VisaEditTarget } from './visa-edit-dialog'
|
||||
import type { VisaStatus } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
programId: string
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<
|
||||
VisaStatus,
|
||||
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
|
||||
> = {
|
||||
NOT_NEEDED: { label: 'Not needed', variant: 'outline' },
|
||||
REQUESTED: { label: 'Requested', variant: 'secondary' },
|
||||
INVITATION_SENT: { label: 'Invitation sent', variant: 'secondary' },
|
||||
APPOINTMENT_BOOKED: { label: 'Appointment booked', variant: 'default' },
|
||||
GRANTED: { label: 'Granted', variant: 'default' },
|
||||
DENIED: { label: 'Denied', variant: 'destructive' },
|
||||
}
|
||||
|
||||
type StatusFilter = 'all' | VisaStatus
|
||||
|
||||
function formatDateOnly(d: Date | null | undefined): string {
|
||||
if (!d) return '—'
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(d))
|
||||
}
|
||||
|
||||
function nextDate(row: {
|
||||
invitationSentAt: Date | null
|
||||
appointmentAt: Date | null
|
||||
decisionAt: Date | null
|
||||
status: VisaStatus
|
||||
}): { label: string; date: Date | null } {
|
||||
if (row.status === 'GRANTED' || row.status === 'DENIED') {
|
||||
return { label: 'Decision', date: row.decisionAt }
|
||||
}
|
||||
if (row.appointmentAt) return { label: 'Appointment', date: row.appointmentAt }
|
||||
if (row.invitationSentAt) return { label: 'Invitation sent', date: row.invitationSentAt }
|
||||
return { label: '—', date: null }
|
||||
}
|
||||
|
||||
export function VisasTab({ programId }: Props) {
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
const [editTarget, setEditTarget] = useState<VisaEditTarget | null>(null)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
|
||||
const { data, isLoading } = trpc.logistics.listVisaApplications.useQuery({ programId })
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data) return []
|
||||
return statusFilter === 'all'
|
||||
? data
|
||||
: data.filter((r) => r.status === statusFilter)
|
||||
}, [data, statusFilter])
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const counts: Record<VisaStatus, number> = {
|
||||
NOT_NEEDED: 0,
|
||||
REQUESTED: 0,
|
||||
INVITATION_SENT: 0,
|
||||
APPOINTMENT_BOOKED: 0,
|
||||
GRANTED: 0,
|
||||
DENIED: 0,
|
||||
}
|
||||
for (const r of data ?? []) counts[r.status]++
|
||||
return counts
|
||||
}, [data])
|
||||
|
||||
const StatusPill = ({
|
||||
value,
|
||||
label,
|
||||
count,
|
||||
}: {
|
||||
value: StatusFilter
|
||||
label: string
|
||||
count: number
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(value)}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
statusFilter === value
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{label} <span className="tabular-nums opacity-80">({count})</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base">Visa applications</CardTitle>
|
||||
<p className="text-muted-foreground mt-1 max-w-2xl text-xs">
|
||||
Process metadata only — invitation letters, passport copies, and visa decisions
|
||||
continue to flow over email and are never stored on this platform.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/settings?tab=edition">
|
||||
<SettingsIcon className="mr-2 h-3.5 w-3.5" />
|
||||
Edition settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<StatusPill value="all" label="All" count={(data ?? []).length} />
|
||||
<StatusPill value="REQUESTED" label="Requested" count={totals.REQUESTED} />
|
||||
<StatusPill
|
||||
value="INVITATION_SENT"
|
||||
label="Invitation sent"
|
||||
count={totals.INVITATION_SENT}
|
||||
/>
|
||||
<StatusPill
|
||||
value="APPOINTMENT_BOOKED"
|
||||
label="Appointment booked"
|
||||
count={totals.APPOINTMENT_BOOKED}
|
||||
/>
|
||||
<StatusPill value="GRANTED" label="Granted" count={totals.GRANTED} />
|
||||
<StatusPill value="DENIED" label="Denied" count={totals.DENIED} />
|
||||
<StatusPill value="NOT_NEEDED" label="Not needed" count={totals.NOT_NEEDED} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||
<ShieldOff className="mx-auto mb-2 h-6 w-6 opacity-60" />
|
||||
{statusFilter === 'all'
|
||||
? 'No visa applications yet. They are auto-created when a team confirms with needsVisa=true.'
|
||||
: 'No applications match this filter.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Member</TableHead>
|
||||
<TableHead>Nationality</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Next date</TableHead>
|
||||
<TableHead>Notes</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((r) => {
|
||||
const badge = STATUS_BADGE[r.status]
|
||||
const next = nextDate(r)
|
||||
return (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">{r.project.title}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
{r.attendee.user.name ?? r.attendee.user.email}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{r.attendee.user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{r.nationality ?? <span className="text-muted-foreground">—</span>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={badge.variant} className="text-xs">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{next.date ? (
|
||||
<>
|
||||
<div>{formatDateOnly(next.date)}</div>
|
||||
<div className="text-muted-foreground text-xs">{next.label}</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-[18rem] truncate text-xs">
|
||||
{r.notes ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditTarget({
|
||||
id: r.id,
|
||||
status: r.status,
|
||||
nationality: r.nationality,
|
||||
invitationSentAt: r.invitationSentAt,
|
||||
appointmentAt: r.appointmentAt,
|
||||
decisionAt: r.decisionAt,
|
||||
notes: r.notes,
|
||||
attendeeName: r.attendee.user.name ?? r.attendee.user.email,
|
||||
projectTitle: r.project.title,
|
||||
})
|
||||
setEditOpen(true)
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<VisaEditDialog
|
||||
open={editOpen}
|
||||
target={editTarget}
|
||||
programId={programId}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -191,6 +191,20 @@ export function MembersContent() {
|
||||
},
|
||||
})
|
||||
|
||||
const bulkUpdateRoles = trpc.user.bulkUpdateRoles.useMutation({
|
||||
onSuccess: (r) => {
|
||||
const parts: string[] = []
|
||||
if (r.updated > 0) parts.push(`Updated ${r.updated} user${r.updated === 1 ? '' : 's'}`)
|
||||
if (r.alreadyHadRole > 0) parts.push(`${r.alreadyHadRole} already had role`)
|
||||
toast.success(parts.join(' · ') || 'No changes')
|
||||
setSelectedIds(new Set())
|
||||
utils.user.list.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to update roles')
|
||||
},
|
||||
})
|
||||
|
||||
const selectableUsers = useMemo(
|
||||
() => data?.users ?? [],
|
||||
[data?.users]
|
||||
@@ -321,9 +335,29 @@ export function MembersContent() {
|
||||
<Card>
|
||||
<CardContent className="py-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Selection persists across pages and filters.
|
||||
{selectedIds.size > 0
|
||||
? `${selectedIds.size} selected. Selection persists across pages and filters.`
|
||||
: 'Selection persists across pages and filters.'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIds.size > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
bulkUpdateRoles.mutate({
|
||||
userIds: Array.from(selectedIds),
|
||||
addRole: 'MENTOR',
|
||||
})
|
||||
}
|
||||
disabled={bulkUpdateRoles.isPending}
|
||||
>
|
||||
{bulkUpdateRoles.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : null}
|
||||
Add MENTOR role
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
257
src/components/admin/mentor/mentor-detail-sheet.tsx
Normal file
257
src/components/admin/mentor/mentor-detail-sheet.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Mail,
|
||||
MapPin,
|
||||
GraduationCap,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
MessageSquare,
|
||||
FileText,
|
||||
Target,
|
||||
Calendar,
|
||||
} from 'lucide-react'
|
||||
import { formatEnumLabel } from '@/lib/utils'
|
||||
|
||||
function formatDateOnly(d: Date | string | null | undefined): string {
|
||||
if (!d) return '—'
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(d))
|
||||
}
|
||||
|
||||
function formatRelativePast(d: Date | string | null): string {
|
||||
if (!d) return '—'
|
||||
const dt = typeof d === 'string' ? new Date(d) : d
|
||||
const ms = Date.now() - dt.getTime()
|
||||
const days = Math.floor(ms / 86_400_000)
|
||||
const hours = Math.floor(ms / 3_600_000)
|
||||
if (days >= 1) return `${days}d ago`
|
||||
if (hours >= 1) return `${hours}h ago`
|
||||
const minutes = Math.floor(ms / 60_000)
|
||||
return `${Math.max(0, minutes)}m ago`
|
||||
}
|
||||
|
||||
export function MentorDetailSheet({
|
||||
mentorId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
mentorId: string | null
|
||||
open: boolean
|
||||
onOpenChange: (next: boolean) => void
|
||||
}) {
|
||||
const { data, isLoading } = trpc.mentor.getMentorDetail.useQuery(
|
||||
{ mentorId: mentorId ?? '' },
|
||||
{ enabled: open && !!mentorId },
|
||||
)
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full overflow-y-auto sm:max-w-xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{isLoading || !data ? (
|
||||
<Skeleton className="h-6 w-48" />
|
||||
) : (
|
||||
data.mentor.name ?? 'Unnamed mentor'
|
||||
)}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{isLoading || !data ? (
|
||||
<Skeleton className="h-4 w-64" />
|
||||
) : (
|
||||
<span className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Mail className="h-3 w-3" /> {data.mentor.email}
|
||||
</span>
|
||||
{data.mentor.country && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" /> {data.mentor.country}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{isLoading || !data ? (
|
||||
<div className="mt-6 space-y-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 space-y-6">
|
||||
{/* Profile summary */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted-foreground text-xs font-semibold uppercase tracking-wide">
|
||||
Profile
|
||||
</h3>
|
||||
<div className="space-y-2 rounded-md border p-4">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<GraduationCap className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Expertise</div>
|
||||
{data.mentor.expertiseTags.length > 0 ? (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{data.mentor.expertiseTags.map((t) => (
|
||||
<Badge key={t} variant="secondary" className="text-xs">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm italic">
|
||||
None declared
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<span className="text-muted-foreground text-xs">Joined</span>
|
||||
<span>{formatDateOnly(data.mentor.createdAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground text-xs">Max assignments</span>
|
||||
<span className="tabular-nums">
|
||||
{data.mentor.maxAssignments ?? '∞'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Assignments */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-muted-foreground text-xs font-semibold uppercase tracking-wide">
|
||||
Teams ({data.assignments.length})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{data.assignments.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border py-8 text-center text-sm">
|
||||
This mentor has no assignments yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data.assignments.map((a) => {
|
||||
const isCompleted = a.completionStatus === 'completed'
|
||||
const isDropped = !!a.droppedAt
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`rounded-md border p-4 ${isDropped ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/admin/projects/${a.project.id}`}
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
{a.project.title}
|
||||
</Link>
|
||||
<div className="text-muted-foreground mt-0.5 flex flex-wrap items-center gap-2 text-xs">
|
||||
{a.project.competitionCategory && (
|
||||
<span>
|
||||
{formatEnumLabel(a.project.competitionCategory)}
|
||||
</span>
|
||||
)}
|
||||
{a.project.country && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{a.project.country}</span>
|
||||
</>
|
||||
)}
|
||||
<span>·</span>
|
||||
<span>Assigned {formatDateOnly(a.assignedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
{isCompleted ? (
|
||||
<Badge variant="default" className="gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" /> Completed
|
||||
</Badge>
|
||||
) : isDropped ? (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<XCircle className="h-3 w-3" /> Dropped
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDropped && a.droppedReason && (
|
||||
<div className="text-muted-foreground bg-muted/50 mt-3 rounded-md p-2 text-xs">
|
||||
<strong>Drop reason</strong>
|
||||
{a.droppedBy ? ` (by ${a.droppedBy})` : ''}: {a.droppedReason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
{/* Activity counts */}
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="flex items-start gap-2 rounded-md border p-2">
|
||||
<MessageSquare className="text-muted-foreground mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<div>
|
||||
<div className="font-semibold tabular-nums">
|
||||
{a.messageCount}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{a.lastMessageAt
|
||||
? formatRelativePast(a.lastMessageAt)
|
||||
: 'no messages'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-md border p-2">
|
||||
<FileText className="text-muted-foreground mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<div>
|
||||
<div className="font-semibold tabular-nums">
|
||||
{a.fileCount}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{a.lastFileAt
|
||||
? formatRelativePast(a.lastFileAt)
|
||||
: 'no files'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-md border p-2">
|
||||
<Target className="text-muted-foreground mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<div>
|
||||
<div className="font-semibold tabular-nums">
|
||||
{a.milestoneCompletionCount}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{a.lastMilestoneAt
|
||||
? formatRelativePast(a.lastMilestoneAt)
|
||||
: 'no milestones'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
177
src/components/admin/project-email-dialog.tsx
Normal file
177
src/components/admin/project-email-dialog.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Mail, Send, Eye } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
projectId: string
|
||||
projectTitle: string
|
||||
}
|
||||
|
||||
function useDebounced<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(value), delayMs)
|
||||
return () => clearTimeout(t)
|
||||
}, [value, delayMs])
|
||||
return debounced
|
||||
}
|
||||
|
||||
export function ProjectEmailDialog({ open, onClose, projectId, projectTitle }: Props) {
|
||||
const initialBody = useMemo(() => `Hello ${projectTitle} team,\n\n`, [projectTitle])
|
||||
const [subject, setSubject] = useState('')
|
||||
const [body, setBody] = useState(initialBody)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
|
||||
// Reset state whenever the dialog opens for a new project
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSubject('')
|
||||
setBody(initialBody)
|
||||
setShowPreview(false)
|
||||
}
|
||||
}, [open, initialBody])
|
||||
|
||||
const debouncedSubject = useDebounced(subject, 300)
|
||||
const debouncedBody = useDebounced(body, 300)
|
||||
|
||||
const recipientPreview = trpc.message.previewRecipients.useQuery(
|
||||
{ recipientType: 'PROJECT_TEAM', recipientFilter: { projectId } },
|
||||
{ enabled: open }
|
||||
)
|
||||
|
||||
const emailPreview = trpc.message.previewEmail.useQuery(
|
||||
{ subject: debouncedSubject, body: debouncedBody },
|
||||
{ enabled: showPreview && debouncedSubject.length > 0 && debouncedBody.length > 0 }
|
||||
)
|
||||
|
||||
const sendTestMutation = trpc.message.sendTest.useMutation({
|
||||
onSuccess: ({ to }) => toast.success(`Test email sent to ${to}`),
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const sendMutation = trpc.message.send.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Email sent to ${recipientPreview.data?.totalApplicants ?? 0} team members`)
|
||||
onClose()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const recipientCount = recipientPreview.data?.totalApplicants ?? 0
|
||||
const canSend = subject.length > 0 && body.length > 0 && recipientCount > 0
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
Email Team — {projectTitle}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Compose a custom email to all members of this project's team.
|
||||
{recipientPreview.isLoading
|
||||
? ' Loading recipients…'
|
||||
: ` Will be sent to ${recipientCount} team member${recipientCount === 1 ? '' : 's'}.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email-subject">Subject</Label>
|
||||
<Input
|
||||
id="email-subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="Subject of your email"
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email-body">Body</Label>
|
||||
<Textarea
|
||||
id="email-body"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={10}
|
||||
className="font-mono text-sm"
|
||||
placeholder={initialBody}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The greeting is pre-filled — edit freely. The full email is wrapped in the standard
|
||||
MOPC styled template when sent. Click "Show preview" to see exactly what recipients will see.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview((s) => !s)}
|
||||
disabled={subject.length === 0 || body.length === 0}
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{showPreview ? 'Hide preview' : 'Show preview'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => sendTestMutation.mutate({ subject, body })}
|
||||
disabled={!canSend || sendTestMutation.isPending}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{sendTestMutation.isPending ? 'Sending…' : 'Send test to me'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showPreview && emailPreview.data && (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<div className="bg-muted text-xs px-3 py-2 border-b">Email preview</div>
|
||||
<iframe
|
||||
title="Email preview"
|
||||
srcDoc={emailPreview.data.html}
|
||||
className="w-full h-96 bg-white"
|
||||
sandbox=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
sendMutation.mutate({
|
||||
recipientType: 'PROJECT_TEAM',
|
||||
recipientFilter: { projectId },
|
||||
subject,
|
||||
body,
|
||||
deliveryChannels: ['EMAIL'],
|
||||
linkType: 'NONE',
|
||||
})
|
||||
}
|
||||
disabled={!canSend || sendMutation.isPending}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{sendMutation.isPending ? 'Sending…' : `Send to ${recipientCount}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -259,7 +259,7 @@ export function AwardShortlist({
|
||||
}
|
||||
</p>
|
||||
{eligibilityMode === 'SEPARATE_POOL' && !hasAwardRounds && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p className="text-sm">
|
||||
No award rounds have been created yet. Projects will be confirmed but <strong>not routed</strong> to an evaluation track. Create rounds on the award page first.
|
||||
|
||||
@@ -328,13 +328,13 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
||||
<div className="space-y-6">
|
||||
{/* Grace Period Banner */}
|
||||
{summary.isGracePeriodActive && (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20">
|
||||
<Card className="border-amber-200 bg-amber-50">
|
||||
<CardContent className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-5 w-5 text-amber-600" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-800 dark:text-amber-200">Grace Period Active</p>
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||
<p className="font-medium text-amber-800">Grace Period Active</p>
|
||||
<p className="text-sm text-amber-600">
|
||||
Applicants can still submit until{' '}
|
||||
{summary.gracePeriodEndsAt
|
||||
? new Date(summary.gracePeriodEndsAt).toLocaleString()
|
||||
@@ -358,12 +358,12 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
||||
|
||||
{/* Finalized Banner */}
|
||||
{summary.isFinalized && (
|
||||
<Card className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
|
||||
<Card className="border-green-200 bg-green-50">
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
<div>
|
||||
<p className="font-medium text-green-800 dark:text-green-200">Round Finalized</p>
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
<p className="font-medium text-green-800">Round Finalized</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Finalized on{' '}
|
||||
{summary.finalizedAt
|
||||
? new Date(summary.finalizedAt).toLocaleString()
|
||||
@@ -376,13 +376,13 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
||||
|
||||
{/* Needs Processing Banner */}
|
||||
{!summary.isFinalized && !summary.isGracePeriodActive && summary.projects.length > 0 && summary.projects.every((p) => !p.proposedOutcome) && (
|
||||
<Card className="border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20">
|
||||
<Card className="border-blue-200 bg-blue-50">
|
||||
<CardContent className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-blue-600" />
|
||||
<div>
|
||||
<p className="font-medium text-blue-800 dark:text-blue-200">Projects Need Processing</p>
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400">
|
||||
<p className="font-medium text-blue-800">Projects Need Processing</p>
|
||||
<p className="text-sm text-blue-600">
|
||||
{summary.projects.length} project{summary.projects.length !== 1 ? 's' : ''} in this round have no proposed outcome.
|
||||
Click "Process" to auto-assign outcomes based on round type and project activity.
|
||||
</p>
|
||||
@@ -666,7 +666,9 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm font-medium">Advancement Message</label>
|
||||
<label className="text-sm font-medium">
|
||||
{summary.winnerContext ? 'Winner Message' : 'Advancement Message'}
|
||||
</label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -681,7 +683,11 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="Custom message for projects that are advancing (added to the standard email template)..."
|
||||
placeholder={
|
||||
summary.winnerContext
|
||||
? 'Custom message for winners (added to the standard winner email template)...'
|
||||
: 'Custom message for projects that are advancing (added to the standard email template)...'
|
||||
}
|
||||
value={advancementMessage}
|
||||
onChange={(e) => setAdvancementMessage(e.target.value)}
|
||||
rows={3}
|
||||
@@ -715,7 +721,13 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{summary.nextRound ? (
|
||||
{summary.winnerContext ? (
|
||||
<span>
|
||||
<strong>{passedCount}</strong>{' '}
|
||||
{passedCount !== 1 ? 'winners' : 'winner'} will be notified for{' '}
|
||||
<strong>{summary.winnerContext.label}</strong>
|
||||
</span>
|
||||
) : summary.nextRound ? (
|
||||
<span>
|
||||
<strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} will advance to{' '}
|
||||
<strong>{summary.nextRound.name}</strong>
|
||||
@@ -751,9 +763,11 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Mark <strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} as <strong>PASSED</strong></li>
|
||||
<li>Mark <strong>{rejectedCount}</strong> project{rejectedCount !== 1 ? 's' : ''} as <strong>REJECTED</strong></li>
|
||||
{summary.nextRound && (
|
||||
{summary.winnerContext ? (
|
||||
<li>Notify <strong>{passedCount}</strong> {passedCount !== 1 ? 'winners' : 'winner'} for <strong>{summary.winnerContext.label}</strong> (no further round)</li>
|
||||
) : summary.nextRound ? (
|
||||
<li>Advance passed projects to <strong>{summary.nextRound.name}</strong></li>
|
||||
)}
|
||||
) : null}
|
||||
<li>Send email notifications to all affected teams</li>
|
||||
</ul>
|
||||
{undecidedCount > 0 && (
|
||||
|
||||
738
src/components/admin/round/mentoring-projects-table.tsx
Normal file
738
src/components/admin/round/mentoring-projects-table.tsx
Normal file
@@ -0,0 +1,738 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { toast } from 'sonner'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Search,
|
||||
UserPlus,
|
||||
ArrowRight,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
Download,
|
||||
X,
|
||||
Plus,
|
||||
} from 'lucide-react'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
import { AddProjectDialog } from '@/components/admin/round/project-states-table'
|
||||
|
||||
type Filter = 'all' | 'unassigned' | 'assigned' | 'wants_only'
|
||||
|
||||
type CompetitionRound = {
|
||||
id: string
|
||||
name: string
|
||||
sortOrder: number
|
||||
_count: { projectRoundStates: number }
|
||||
}
|
||||
|
||||
export function MentoringProjectsTable({
|
||||
roundId,
|
||||
competitionId,
|
||||
competitionRounds,
|
||||
currentSortOrder,
|
||||
}: {
|
||||
roundId: string
|
||||
competitionId: string
|
||||
competitionRounds?: CompetitionRound[]
|
||||
currentSortOrder?: number
|
||||
}) {
|
||||
const [addProjectOpen, setAddProjectOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [bulkOpen, setBulkOpen] = useState(false)
|
||||
const [chosenMentorIds, setChosenMentorIds] = useState<Set<string>>(new Set())
|
||||
const [mentorSearch, setMentorSearch] = useState('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data, isLoading } = trpc.round.listMentoringProjects.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
|
||||
const { data: importCandidates } =
|
||||
trpc.round.getMentoringImportCandidates.useQuery({ roundId })
|
||||
|
||||
const { data: mentorPool } = trpc.mentor.getMentorPool.useQuery(
|
||||
{},
|
||||
{ enabled: bulkOpen },
|
||||
)
|
||||
|
||||
const bulkAssignMutation = trpc.mentor.bulkAssign.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.totalAssigned === 0 && result.totalSkipped > 0) {
|
||||
toast.info(
|
||||
`No new assignments — every selected mentor is already on every selected project (${result.totalSkipped} pair${result.totalSkipped === 1 ? '' : 's'} skipped).`,
|
||||
)
|
||||
} else if (result.totalAssigned === 0 && result.ineligibleProjectCount > 0) {
|
||||
toast.warning(
|
||||
`${result.ineligibleProjectCount} project${result.ineligibleProjectCount === 1 ? '' : 's'} aren't in a mentoring round and were skipped.`,
|
||||
)
|
||||
} else {
|
||||
const mentorCount = result.perMentor.filter((m) => m.assigned > 0).length
|
||||
toast.success(
|
||||
`Created ${result.totalAssigned} assignment${
|
||||
result.totalAssigned === 1 ? '' : 's'
|
||||
} across ${result.touchedProjectCount} project${
|
||||
result.touchedProjectCount === 1 ? '' : 's'
|
||||
}${result.totalSkipped > 0 ? ` (${result.totalSkipped} pair${result.totalSkipped === 1 ? '' : 's'} already existed)` : ''}${
|
||||
result.emailsSent > 0
|
||||
? ` · ${result.emailsSent} mentor email${result.emailsSent === 1 ? '' : 's'} sent`
|
||||
: ''
|
||||
}`,
|
||||
{
|
||||
description:
|
||||
mentorCount > 1
|
||||
? `Each of ${mentorCount} mentors gets a single combined email listing only their new projects.`
|
||||
: undefined,
|
||||
},
|
||||
)
|
||||
}
|
||||
utils.round.listMentoringProjects.invalidate({ roundId })
|
||||
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
|
||||
utils.round.getMentoringImportCandidates.invalidate({ roundId })
|
||||
utils.mentor.getMentorPool.invalidate()
|
||||
utils.mentor.getRoundStats.invalidate({ roundId })
|
||||
utils.project.list.invalidate()
|
||||
setSelected(new Set())
|
||||
setChosenMentorIds(new Set())
|
||||
setMentorSearch('')
|
||||
setBulkOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const advanceMutation = trpc.round.advanceProjects.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(
|
||||
`Imported ${result.advancedCount} project${
|
||||
result.advancedCount === 1 ? '' : 's'
|
||||
} from ${result.targetRoundName ? '' : ''}${
|
||||
importCandidates?.priorRound?.name ?? 'the prior round'
|
||||
}`,
|
||||
)
|
||||
utils.round.listMentoringProjects.invalidate({ roundId })
|
||||
utils.round.getMentoringImportCandidates.invalidate({ roundId })
|
||||
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const importBanner = importCandidates?.priorRound &&
|
||||
importCandidates.pendingCount > 0 && (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-amber-900">
|
||||
<span className="font-medium">
|
||||
{importCandidates.pendingCount} PASSED project
|
||||
{importCandidates.pendingCount === 1 ? '' : 's'}
|
||||
</span>{' '}
|
||||
from{' '}
|
||||
<span className="font-medium">
|
||||
{importCandidates.priorRound.name}
|
||||
</span>{' '}
|
||||
{importCandidates.pendingCount === 1 ? "isn't" : "aren't"} in this
|
||||
mentoring round yet.
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
advanceMutation.mutate({
|
||||
roundId: importCandidates.priorRound!.id,
|
||||
targetRoundId: roundId,
|
||||
})
|
||||
}
|
||||
disabled={advanceMutation.isPending}
|
||||
>
|
||||
{advanceMutation.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-1.5 h-4 w-4" />
|
||||
)}
|
||||
Import {importCandidates.pendingCount}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data) return []
|
||||
const q = search.trim().toLowerCase()
|
||||
return data.projects.filter((p) => {
|
||||
if (filter === 'unassigned' && p.mentors.length > 0) return false
|
||||
if (filter === 'assigned' && p.mentors.length === 0) return false
|
||||
if (filter === 'wants_only' && !p.wantsMentorship) return false
|
||||
if (!q) return true
|
||||
const hay = [
|
||||
p.title,
|
||||
p.teamName ?? '',
|
||||
p.country ?? '',
|
||||
...p.mentors.map((m) => m.name ?? m.email),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return hay.includes(q)
|
||||
})
|
||||
}, [data, search, filter])
|
||||
|
||||
const totals = useMemo(() => {
|
||||
if (!data)
|
||||
return { total: 0, unassigned: 0, assigned: 0, wants: 0 }
|
||||
return {
|
||||
total: data.projects.length,
|
||||
unassigned: data.projects.filter((p) => p.mentors.length === 0).length,
|
||||
assigned: data.projects.filter((p) => p.mentors.length > 0).length,
|
||||
wants: data.projects.filter((p) => p.wantsMentorship).length,
|
||||
}
|
||||
}, [data])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.projects.length === 0) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{importBanner}
|
||||
<div className="flex items-center justify-end">
|
||||
<Button size="sm" onClick={() => setAddProjectOpen(true)}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
Add Project to Round
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/30 px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
No projects in this mentoring round yet. Click{' '}
|
||||
<span className="font-medium text-foreground">Add Project to Round</span>{' '}
|
||||
above to populate it.
|
||||
</div>
|
||||
|
||||
<AddProjectDialog
|
||||
open={addProjectOpen}
|
||||
onOpenChange={setAddProjectOpen}
|
||||
roundId={roundId}
|
||||
competitionId={competitionId}
|
||||
competitionRounds={competitionRounds}
|
||||
currentSortOrder={currentSortOrder}
|
||||
onAssigned={() => {
|
||||
utils.round.listMentoringProjects.invalidate({ roundId })
|
||||
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
|
||||
utils.round.getMentoringImportCandidates.invalidate({ roundId })
|
||||
utils.mentor.getRoundStats.invalidate({ roundId })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Pill = ({
|
||||
value,
|
||||
label,
|
||||
count,
|
||||
}: {
|
||||
value: Filter
|
||||
label: string
|
||||
count: number
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilter(value)}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
filter === value
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'bg-background hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{label}{' '}
|
||||
<span className="tabular-nums opacity-80">({count})</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{importBanner}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Pill value="all" label="All" count={totals.total} />
|
||||
<Pill value="unassigned" label="No mentor" count={totals.unassigned} />
|
||||
<Pill value="assigned" label="Has mentor" count={totals.assigned} />
|
||||
<Pill value="wants_only" label="Wants mentorship" count={totals.wants} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-full sm:w-72">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search projects, teams, or mentors…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setAddProjectOpen(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected.size > 0 ? (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-primary/30 bg-primary/5 px-4 py-2.5 text-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<span className="font-medium">{selected.size}</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
project{selected.size === 1 ? '' : 's'} selected
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => setBulkOpen(true)}>
|
||||
<UserPlus className="mr-1.5 h-4 w-4" />
|
||||
Assign mentor…
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSelected(new Set())}
|
||||
>
|
||||
<X className="mr-1 h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between rounded-md border border-dashed bg-muted/20 px-4 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Tip: tick checkboxes to bulk-assign one mentor to multiple
|
||||
projects in a single click (mentor gets one combined email).
|
||||
</span>
|
||||
{totals.unassigned > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-medium text-foreground hover:underline"
|
||||
onClick={() => {
|
||||
setFilter('unassigned')
|
||||
setSelected(
|
||||
new Set(
|
||||
data.projects
|
||||
.filter((p) => p.mentors.length === 0)
|
||||
.map((p) => p.id),
|
||||
),
|
||||
)
|
||||
}}
|
||||
>
|
||||
Select all {totals.unassigned} without a mentor
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={
|
||||
filtered.length > 0 &&
|
||||
filtered.every((p) => selected.has(p.id))
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) {
|
||||
filtered.forEach((p) => next.add(p.id))
|
||||
} else {
|
||||
filtered.forEach((p) => next.delete(p.id))
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
aria-label="Select all visible"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Wants?</TableHead>
|
||||
<TableHead>Mentors</TableHead>
|
||||
<TableHead className="w-32 text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
No projects match the current filter.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filtered.map((p) => (
|
||||
<TableRow
|
||||
key={p.id}
|
||||
data-state={selected.has(p.id) ? 'selected' : undefined}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selected.has(p.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(p.id)
|
||||
else next.delete(p.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
aria-label={`Select ${p.title}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">{p.title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{p.teamName ?? '—'}
|
||||
{p.country && (
|
||||
<>
|
||||
{' · '}
|
||||
<CountryDisplay country={p.country} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
{p.wantsMentorship ? (
|
||||
<Badge variant="secondary" className="w-fit text-xs">
|
||||
Requested
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No</span>
|
||||
)}
|
||||
{p.finalistConfirmationStatus !== 'CONFIRMED' && (
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wide text-amber-700"
|
||||
title="Auto-fill skips projects whose team has not confirmed attendance."
|
||||
>
|
||||
{p.finalistConfirmationStatus
|
||||
? p.finalistConfirmationStatus.toLowerCase()
|
||||
: 'no confirmation'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{p.mentors.length === 0 ? (
|
||||
<span className="text-xs italic text-muted-foreground">
|
||||
Unassigned
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.mentors.map((m) => (
|
||||
<Badge
|
||||
key={m.assignmentId}
|
||||
variant="outline"
|
||||
className="gap-1 text-xs"
|
||||
title={m.email}
|
||||
>
|
||||
{(m.method === 'AI_AUTO' ||
|
||||
m.method === 'AI_SUGGESTED') && (
|
||||
<Sparkles className="h-3 w-3 text-amber-500" />
|
||||
)}
|
||||
{m.name ?? m.email}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={`/admin/projects/${p.id}/mentor`}>
|
||||
{p.mentors.length === 0 ? (
|
||||
<>
|
||||
<UserPlus className="mr-1 h-3.5 w-3.5" />
|
||||
Assign
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Open
|
||||
<ArrowRight className="ml-1 h-3.5 w-3.5" />
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={bulkOpen}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
setBulkOpen(false)
|
||||
setChosenMentorIds(new Set())
|
||||
setMentorSearch('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Assign mentors to {selected.size} project
|
||||
{selected.size === 1 ? '' : 's'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tick any number of mentors. Each chosen mentor will be added to
|
||||
every selected project they aren't already on. Each mentor
|
||||
receives one combined email; each team receives one intro email
|
||||
listing all of their mentors.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{(() => {
|
||||
const allMentors = mentorPool?.mentors ?? []
|
||||
const chosenMentors = allMentors.filter((m) =>
|
||||
chosenMentorIds.has(m.id),
|
||||
)
|
||||
const upperBound = chosenMentorIds.size * selected.size
|
||||
|
||||
return (
|
||||
<>
|
||||
{chosenMentors.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 rounded-md border bg-muted/30 p-2">
|
||||
{chosenMentors.map((m) => (
|
||||
<Badge
|
||||
key={m.id}
|
||||
variant="secondary"
|
||||
className="gap-1 pl-2 pr-1"
|
||||
>
|
||||
{m.name ?? m.email}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${m.name ?? m.email}`}
|
||||
className="rounded-full p-0.5 hover:bg-foreground/10"
|
||||
onClick={() =>
|
||||
setChosenMentorIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(m.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={mentorSearch}
|
||||
onChange={(e) => setMentorSearch(e.target.value)}
|
||||
placeholder="Search mentor by name, email, country, or expertise…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto rounded-md border">
|
||||
{(() => {
|
||||
const q = mentorSearch.trim().toLowerCase()
|
||||
const filteredMentors = q
|
||||
? allMentors.filter((m) =>
|
||||
[
|
||||
m.name ?? '',
|
||||
m.email,
|
||||
m.country ?? '',
|
||||
...(m.expertiseTags ?? []),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(q),
|
||||
)
|
||||
: allMentors
|
||||
if (allMentors.length === 0) {
|
||||
return (
|
||||
<p className="p-4 text-center text-sm text-muted-foreground">
|
||||
No mentors in the pool yet.{' '}
|
||||
<Link
|
||||
href="/admin/members?tab=mentors"
|
||||
className="underline-offset-2 hover:underline"
|
||||
>
|
||||
Add mentors
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (filteredMentors.length === 0) {
|
||||
return (
|
||||
<p className="p-4 text-center text-sm text-muted-foreground">
|
||||
No mentors match “{mentorSearch}”.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return filteredMentors.map((m) => {
|
||||
const isChosen = chosenMentorIds.has(m.id)
|
||||
return (
|
||||
<label
|
||||
key={m.id}
|
||||
className={`flex cursor-pointer items-start gap-3 border-b px-3 py-2 text-sm last:border-b-0 ${
|
||||
isChosen ? 'bg-accent' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
className="mt-1"
|
||||
checked={isChosen}
|
||||
onCheckedChange={(checked) =>
|
||||
setChosenMentorIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(m.id)
|
||||
else next.delete(m.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
aria-label={`Toggle ${m.name ?? m.email}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">
|
||||
{m.name ?? 'Unnamed'}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{m.email}
|
||||
{m.country && <> · {m.country}</>}
|
||||
</div>
|
||||
{m.expertiseTags && m.expertiseTags.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{m.expertiseTags.slice(0, 4).map((t) => (
|
||||
<Badge
|
||||
key={t}
|
||||
variant="secondary"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
{m.expertiseTags.length > 4 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px]"
|
||||
>
|
||||
+{m.expertiseTags.length - 4}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-xs tabular-nums text-muted-foreground">
|
||||
{m.currentAssignments}
|
||||
{m.maxAssignments != null && `/${m.maxAssignments}`}{' '}
|
||||
load
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{chosenMentorIds.size > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Will create up to{' '}
|
||||
<span className="font-medium tabular-nums text-foreground">
|
||||
{upperBound}
|
||||
</span>{' '}
|
||||
assignment{upperBound === 1 ? '' : 's'} (
|
||||
{chosenMentorIds.size} mentor
|
||||
{chosenMentorIds.size === 1 ? '' : 's'} × {selected.size}{' '}
|
||||
project{selected.size === 1 ? '' : 's'}). Pairs that
|
||||
already exist are skipped.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setBulkOpen(false)
|
||||
setChosenMentorIds(new Set())
|
||||
setMentorSearch('')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
bulkAssignMutation.mutate({
|
||||
mentorIds: Array.from(chosenMentorIds),
|
||||
projectIds: Array.from(selected),
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
chosenMentorIds.size === 0 || bulkAssignMutation.isPending
|
||||
}
|
||||
>
|
||||
{bulkAssignMutation.isPending && (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Assign {chosenMentorIds.size} mentor
|
||||
{chosenMentorIds.size === 1 ? '' : 's'} to {selected.size} project
|
||||
{selected.size === 1 ? '' : 's'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AddProjectDialog
|
||||
open={addProjectOpen}
|
||||
onOpenChange={setAddProjectOpen}
|
||||
roundId={roundId}
|
||||
competitionId={competitionId}
|
||||
competitionRounds={competitionRounds}
|
||||
currentSortOrder={currentSortOrder}
|
||||
onAssigned={() => {
|
||||
utils.round.listMentoringProjects.invalidate({ roundId })
|
||||
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
|
||||
utils.round.getMentoringImportCandidates.invalidate({ roundId })
|
||||
utils.mentor.getRoundStats.invalidate({ roundId })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
267
src/components/admin/round/mentoring-round-overview.tsx
Normal file
267
src/components/admin/round/mentoring-round-overview.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowRight,
|
||||
Clock,
|
||||
FileText,
|
||||
Inbox,
|
||||
MessageCircle,
|
||||
Target,
|
||||
UserCheck,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
function formatRelativeFuture(date: Date | null): { label: string; tone: 'normal' | 'amber' | 'red' } {
|
||||
if (!date) return { label: '—', tone: 'normal' }
|
||||
const ms = date.getTime() - Date.now()
|
||||
if (ms <= 0) return { label: 'Closed', tone: 'red' }
|
||||
const hours = Math.floor(ms / 3_600_000)
|
||||
const days = Math.floor(hours / 24)
|
||||
const tone: 'normal' | 'amber' | 'red' =
|
||||
hours <= 12 ? 'red' : hours <= 48 ? 'amber' : 'normal'
|
||||
const label = days > 0 ? `Closes in ${days}d` : `Closes in ${hours}h`
|
||||
return { label, tone }
|
||||
}
|
||||
|
||||
function formatRelativePast(date: Date | null): string {
|
||||
if (!date) return '—'
|
||||
const ms = Date.now() - date.getTime()
|
||||
const minutes = Math.floor(ms / 60_000)
|
||||
const hours = Math.floor(ms / 3_600_000)
|
||||
const days = Math.floor(ms / 86_400_000)
|
||||
if (days > 0) return `${days}d ago`
|
||||
if (hours > 0) return `${hours}h ago`
|
||||
return `${Math.max(0, minutes)}m ago`
|
||||
}
|
||||
|
||||
export function MentoringRoundOverview({ roundId }: Props) {
|
||||
const { data: stats, isLoading: statsLoading } = trpc.mentor.getRoundStats.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
const { data: pool, isLoading: poolLoading } = trpc.mentor.getMentorPool.useQuery({})
|
||||
const { data: pendingChangeRequests } = trpc.mentor.listChangeRequests.useQuery(
|
||||
{ status: 'PENDING' },
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
|
||||
if (statsLoading || poolLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-32 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!stats || !pool) return null
|
||||
|
||||
const pendingCount = pendingChangeRequests?.length ?? 0
|
||||
// If there's at least one pending request, deep-link directly into the
|
||||
// first one's project (admins can resolve / view siblings from there).
|
||||
// Otherwise the card stays static.
|
||||
const firstPendingProjectId = pendingChangeRequests?.[0]?.project.id ?? null
|
||||
const changeRequestsHref = firstPendingProjectId
|
||||
? `/admin/projects/${firstPendingProjectId}/mentor`
|
||||
: null
|
||||
|
||||
const requestedPct = stats.totalProjects
|
||||
? Math.round((stats.requestedCount / stats.totalProjects) * 100)
|
||||
: 0
|
||||
const assignedPct = stats.totalProjects
|
||||
? Math.round((stats.assignedCount / stats.totalProjects) * 100)
|
||||
: 0
|
||||
|
||||
const window = formatRelativeFuture(
|
||||
stats.requestWindow.deadline ? new Date(stats.requestWindow.deadline) : null,
|
||||
)
|
||||
|
||||
const avgLoad =
|
||||
pool.poolSize > 0 ? (pool.totalCurrentAssignments / pool.poolSize).toFixed(1) : '—'
|
||||
const lastActivity = stats.workspaceActivity.lastActivityAt
|
||||
? new Date(stats.workspaceActivity.lastActivityAt)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
Requested mentoring
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold tabular-nums">
|
||||
{stats.requestedCount}
|
||||
<span className="text-muted-foreground ml-2 text-sm font-normal">
|
||||
/ {stats.totalProjects}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{requestedPct}% of round</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
Mentor assigned
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="text-3xl font-bold tabular-nums">{stats.assignedCount}</div>
|
||||
<UserCheck className="text-muted-foreground h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{assignedPct}% of round{' '}
|
||||
{stats.awaitingAssignment > 0 && (
|
||||
<span className="text-amber-700">
|
||||
· {stats.awaitingAssignment} awaiting
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
Request window
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<Clock className="text-muted-foreground h-5 w-5" />
|
||||
<Badge
|
||||
variant={
|
||||
window.tone === 'red'
|
||||
? 'destructive'
|
||||
: window.tone === 'amber'
|
||||
? 'secondary'
|
||||
: 'outline'
|
||||
}
|
||||
className="text-sm"
|
||||
>
|
||||
{window.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{stats.requestWindow.deadline
|
||||
? `Closes ${new Date(stats.requestWindow.deadline).toLocaleDateString()} · ${stats.requestWindow.deadlineDays}-day window`
|
||||
: 'No window deadline set'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
Mentor pool
|
||||
</CardTitle>
|
||||
<Link
|
||||
href="/admin/mentors"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-xs"
|
||||
>
|
||||
View all
|
||||
<ArrowRight className="ml-0.5 h-3 w-3" />
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="text-3xl font-bold tabular-nums">{pool.poolSize}</div>
|
||||
<Users className="text-muted-foreground h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Avg load <span className="text-foreground font-medium">{avgLoad}</span> ·{' '}
|
||||
{pool.totalCurrentAssignments} active
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={`md:col-span-2 xl:col-span-4 ${
|
||||
pendingCount > 0 ? 'border-amber-300' : ''
|
||||
}`}
|
||||
>
|
||||
<CardContent className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Inbox
|
||||
className={`h-5 w-5 ${
|
||||
pendingCount > 0 ? 'text-amber-600' : 'text-muted-foreground'
|
||||
}`}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium">Pending change requests</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Team members asking admin to swap a mentor
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-2xl font-bold tabular-nums">{pendingCount}</div>
|
||||
{changeRequestsHref ? (
|
||||
<Link
|
||||
href={changeRequestsHref}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-xs"
|
||||
>
|
||||
Review
|
||||
<ArrowRight className="ml-0.5 h-3 w-3" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">All clear</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2 xl:col-span-4">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Workspace activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm md:grid-cols-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="text-muted-foreground h-4 w-4" />
|
||||
<div>
|
||||
<div className="font-bold tabular-nums">{stats.workspaceActivity.messageCount}</div>
|
||||
<div className="text-muted-foreground text-xs">messages</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
<div>
|
||||
<div className="font-bold tabular-nums">{stats.workspaceActivity.fileCount}</div>
|
||||
<div className="text-muted-foreground text-xs">files</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="text-muted-foreground h-4 w-4" />
|
||||
<div>
|
||||
<div className="font-bold tabular-nums">
|
||||
{stats.workspaceActivity.milestoneCount}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">milestones</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="text-muted-foreground h-4 w-4" />
|
||||
<div>
|
||||
<div className="font-medium">{formatRelativePast(lastActivity)}</div>
|
||||
<div className="text-muted-foreground text-xs">last activity</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -290,8 +290,8 @@ export function ProjectStatesTable({ competitionId, roundId, roundStatus, compet
|
||||
<div className="space-y-4">
|
||||
{/* Finalization hint for closed rounds */}
|
||||
{(roundStatus === 'ROUND_CLOSED' || roundStatus === 'ROUND_ARCHIVED') && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20 px-4 py-3 text-sm">
|
||||
<span className="text-blue-700 dark:text-blue-300">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm">
|
||||
<span className="text-blue-700">
|
||||
This round is closed. Use the <strong>Finalization</strong> tab to review proposed outcomes and confirm advancement.
|
||||
</span>
|
||||
</div>
|
||||
@@ -785,7 +785,7 @@ function QuickAddDialog({
|
||||
* Create New: form to create a project and assign it directly to the round.
|
||||
* From Pool: search existing projects not yet in this round and assign them.
|
||||
*/
|
||||
function AddProjectDialog({
|
||||
export function AddProjectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
roundId,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user