Compare commits
131 Commits
67f6fc3aba
...
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 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -62,3 +62,4 @@ build-output.txt
|
|||||||
# Private keys and secrets
|
# Private keys and secrets
|
||||||
private/
|
private/
|
||||||
public/build-id.json
|
public/build-id.json
|
||||||
|
.remember/
|
||||||
|
|||||||
@@ -6,15 +6,38 @@ MIGRATION_RETRY_DELAY_SECONDS="${MIGRATION_RETRY_DELAY_SECONDS:-2}"
|
|||||||
ATTEMPT=1
|
ATTEMPT=1
|
||||||
|
|
||||||
# Auto-resolve any previously failed migrations so deploy can proceed.
|
# Auto-resolve any previously failed migrations so deploy can proceed.
|
||||||
# This handles the case where a migration partially applied and was fixed
|
# This handles the case where a migration failed mid-flight and was then
|
||||||
# in a subsequent deploy — without this, Prisma refuses to run anything.
|
# fixed in a subsequent deploy — without this, Prisma refuses to run
|
||||||
|
# anything else (P3009).
|
||||||
|
#
|
||||||
|
# We query `_prisma_migrations` directly rather than parsing the output of
|
||||||
|
# `prisma migrate status`, because that output's wording has shifted between
|
||||||
|
# Prisma versions and any drift means failed migrations slip through and
|
||||||
|
# the container crash-loops. Truth lives in the table: a row with
|
||||||
|
# `finished_at IS NULL AND rolled_back_at IS NULL` is an unresolved failure.
|
||||||
echo "==> Checking for failed migrations..."
|
echo "==> Checking for failed migrations..."
|
||||||
MIGRATE_STATUS=$(npx prisma migrate status 2>&1 || true)
|
RESOLVE_ATTEMPTS=0
|
||||||
FAILED=$(echo "$MIGRATE_STATUS" | sed -n 's/.*The `\([^`]*\)` migration.*failed.*/\1/p' | head -1)
|
while [ "$RESOLVE_ATTEMPTS" -lt 5 ]; do
|
||||||
if [ -n "$FAILED" ]; then
|
FAILED=$(node -e "
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient();
|
||||||
|
p.\$queryRaw\`
|
||||||
|
SELECT migration_name FROM _prisma_migrations
|
||||||
|
WHERE finished_at IS NULL AND rolled_back_at IS NULL
|
||||||
|
ORDER BY started_at ASC LIMIT 1
|
||||||
|
\`.then(r => { console.log(r[0]?.migration_name || ''); p.\$disconnect(); })
|
||||||
|
.catch(() => { console.log(''); p.\$disconnect(); });
|
||||||
|
" 2>/dev/null || echo "")
|
||||||
|
if [ -z "$FAILED" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
echo "==> Found failed migration: $FAILED — marking as rolled back..."
|
echo "==> Found failed migration: $FAILED — marking as rolled back..."
|
||||||
npx prisma migrate resolve --rolled-back "$FAILED"
|
npx prisma migrate resolve --rolled-back "$FAILED" || {
|
||||||
fi
|
echo "WARNING: prisma migrate resolve failed for $FAILED"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
RESOLVE_ATTEMPTS=$((RESOLVE_ATTEMPTS + 1))
|
||||||
|
done
|
||||||
|
|
||||||
echo "==> Running database migrations (with retry)..."
|
echo "==> Running database migrations (with retry)..."
|
||||||
until npx prisma migrate deploy; do
|
until npx prisma migrate deploy; do
|
||||||
|
|||||||
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,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",
|
"name": "mopc-platform",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mopc-platform",
|
"name": "mopc-platform",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.78.0",
|
"@anthropic-ai/sdk": "^0.78.0",
|
||||||
"@auth/prisma-adapter": "^2.7.4",
|
"@auth/prisma-adapter": "^2.7.4",
|
||||||
@@ -61,7 +61,6 @@
|
|||||||
"motion": "^11.15.0",
|
"motion": "^11.15.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"nodemailer": "^7.0.7",
|
"nodemailer": "^7.0.7",
|
||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
@@ -12143,16 +12142,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next-themes": {
|
|
||||||
"version": "0.4.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
|
||||||
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
|
||||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mopc-platform",
|
"name": "mopc-platform",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -75,7 +75,6 @@
|
|||||||
"motion": "^11.15.0",
|
"motion": "^11.15.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"nodemailer": "^7.0.7",
|
"nodemailer": "^7.0.7",
|
||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
|
|||||||
@@ -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
|
MENTOR
|
||||||
OBSERVER
|
OBSERVER
|
||||||
APPLICANT
|
APPLICANT
|
||||||
AWARD_MASTER
|
|
||||||
AUDIENCE
|
AUDIENCE
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +118,6 @@ enum NotificationChannel {
|
|||||||
NONE
|
NONE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
enum PartnerVisibility {
|
enum PartnerVisibility {
|
||||||
ADMIN_ONLY
|
ADMIN_ONLY
|
||||||
JURY_VISIBLE
|
JURY_VISIBLE
|
||||||
@@ -134,7 +132,6 @@ enum PartnerType {
|
|||||||
OTHER
|
OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// COMPETITION / ROUND ENGINE ENUMS
|
// COMPETITION / ROUND ENGINE ENUMS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -172,7 +169,6 @@ enum ProjectRoundStateValue {
|
|||||||
WITHDRAWN
|
WITHDRAWN
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
enum CapMode {
|
enum CapMode {
|
||||||
HARD
|
HARD
|
||||||
SOFT
|
SOFT
|
||||||
@@ -302,6 +298,9 @@ model User {
|
|||||||
institution String? // User's institution/organization
|
institution String? // User's institution/organization
|
||||||
metadataJson Json? @db.JsonB
|
metadataJson Json? @db.JsonB
|
||||||
|
|
||||||
|
// Mentor onboarding email idempotency: stamped once when MENTOR role is first added.
|
||||||
|
mentorOnboardingSentAt DateTime?
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
bio String? // User bio for matching with project descriptions
|
bio String? // User bio for matching with project descriptions
|
||||||
profileImageKey String? // Storage key (e.g., "avatars/user123/1234567890.jpg")
|
profileImageKey String? // Storage key (e.g., "avatars/user123/1234567890.jpg")
|
||||||
@@ -326,8 +325,8 @@ model User {
|
|||||||
inviteTokenExpiresAt DateTime?
|
inviteTokenExpiresAt DateTime?
|
||||||
|
|
||||||
// Password reset token
|
// Password reset token
|
||||||
passwordResetToken String? @unique
|
passwordResetToken String? @unique
|
||||||
passwordResetExpiresAt DateTime?
|
passwordResetExpiresAt DateTime?
|
||||||
|
|
||||||
// Digest & availability preferences
|
// Digest & availability preferences
|
||||||
digestFrequency String @default("none") // 'none' | 'daily' | 'weekly'
|
digestFrequency String @default("none") // 'none' | 'daily' | 'weekly'
|
||||||
@@ -361,9 +360,9 @@ model User {
|
|||||||
filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy")
|
filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy")
|
||||||
|
|
||||||
// Award overrides
|
// Award overrides
|
||||||
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
|
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
|
||||||
awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer")
|
awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer")
|
||||||
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
|
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
|
||||||
|
|
||||||
// In-app notifications
|
// In-app notifications
|
||||||
notifications InAppNotification[] @relation("UserNotifications")
|
notifications InAppNotification[] @relation("UserNotifications")
|
||||||
@@ -411,17 +410,24 @@ model User {
|
|||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
|
||||||
// ── Competition/Round architecture relations ──
|
// ── Competition/Round architecture relations ──
|
||||||
juryGroupMemberships JuryGroupMember[]
|
juryGroupMemberships JuryGroupMember[]
|
||||||
mentorFilesUploaded MentorFile[] @relation("MentorFileUploader")
|
mentorFilesUploaded MentorFile[] @relation("MentorFileUploader")
|
||||||
mentorFilesPromoted MentorFile[] @relation("MentorFilePromoter")
|
mentorFilesPromoted MentorFile[] @relation("MentorFilePromoter")
|
||||||
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
|
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
|
||||||
resultLocksCreated ResultLock[] @relation("ResultLockCreator")
|
resultLocksCreated ResultLock[] @relation("ResultLockCreator")
|
||||||
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
|
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
|
||||||
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
|
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
|
||||||
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
|
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
|
||||||
|
|
||||||
// AI Ranking
|
// AI Ranking
|
||||||
rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots")
|
rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots")
|
||||||
|
|
||||||
|
// Grand-finale logistics
|
||||||
|
finalistAttendances AttendingMember[]
|
||||||
|
|
||||||
|
// Mentor change requests
|
||||||
|
mentorChangeRequestsRequested MentorChangeRequest[] @relation("MentorChangeRequester")
|
||||||
|
mentorChangeRequestsResolved MentorChangeRequest[] @relation("MentorChangeResolver")
|
||||||
|
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -480,6 +486,10 @@ model Program {
|
|||||||
description String?
|
description String?
|
||||||
settingsJson Json? @db.JsonB
|
settingsJson Json? @db.JsonB
|
||||||
|
|
||||||
|
// Grand-finale logistics
|
||||||
|
defaultAttendeeCap Int @default(3) // Max team members allowed at the grand finale per finalist team
|
||||||
|
visaStatusVisibleToMembers Boolean @default(true) // Whether team members see their own visa status on the applicant dashboard
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -493,6 +503,12 @@ model Program {
|
|||||||
mentorMilestones MentorMilestone[]
|
mentorMilestones MentorMilestone[]
|
||||||
competitions Competition[]
|
competitions Competition[]
|
||||||
|
|
||||||
|
// Grand-finale logistics
|
||||||
|
finalistSlotQuotas FinalistSlotQuota[]
|
||||||
|
waitlistEntries WaitlistEntry[]
|
||||||
|
hotel Hotel?
|
||||||
|
lunchEvent LunchEvent?
|
||||||
|
|
||||||
@@unique([name, year])
|
@@unique([name, year])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
@@ -614,7 +630,9 @@ model Project {
|
|||||||
assignments Assignment[]
|
assignments Assignment[]
|
||||||
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
|
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
|
||||||
teamMembers TeamMember[]
|
teamMembers TeamMember[]
|
||||||
mentorAssignment MentorAssignment?
|
mentorAssignments MentorAssignment[]
|
||||||
|
mentorFiles MentorFile[]
|
||||||
|
mentorChangeRequests MentorChangeRequest[]
|
||||||
filteringResults FilteringResult[]
|
filteringResults FilteringResult[]
|
||||||
awardEligibilities AwardEligibility[]
|
awardEligibilities AwardEligibility[]
|
||||||
awardVotes AwardVote[]
|
awardVotes AwardVote[]
|
||||||
@@ -627,12 +645,17 @@ model Project {
|
|||||||
cohortProjects CohortProject[]
|
cohortProjects CohortProject[]
|
||||||
|
|
||||||
// ── Competition/Round architecture relations ──
|
// ── Competition/Round architecture relations ──
|
||||||
projectRoundStates ProjectRoundState[]
|
projectRoundStates ProjectRoundState[]
|
||||||
assignmentIntents AssignmentIntent[]
|
assignmentIntents AssignmentIntent[]
|
||||||
deliberationVotes DeliberationVote[]
|
deliberationVotes DeliberationVote[]
|
||||||
deliberationResults DeliberationResult[]
|
deliberationResults DeliberationResult[]
|
||||||
submissionPromotions SubmissionPromotionEvent[]
|
submissionPromotions SubmissionPromotionEvent[]
|
||||||
notificationLogs NotificationLog[]
|
notificationLogs NotificationLog[]
|
||||||
|
|
||||||
|
// Grand-finale logistics
|
||||||
|
waitlistEntry WaitlistEntry?
|
||||||
|
finalistConfirmation FinalistConfirmation?
|
||||||
|
externalLunchAttendees ExternalAttendee[]
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -679,9 +702,9 @@ model ProjectFile {
|
|||||||
|
|
||||||
// Document analysis (optional, populated by document-analyzer service)
|
// Document analysis (optional, populated by document-analyzer service)
|
||||||
textPreview String? @db.Text // First ~2000 chars of extracted text
|
textPreview String? @db.Text // First ~2000 chars of extracted text
|
||||||
detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und')
|
detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und')
|
||||||
langConfidence Float? // 0.0–1.0 confidence
|
langConfidence Float? // 0.0–1.0 confidence
|
||||||
analyzedAt DateTime? // When analysis last ran
|
analyzedAt DateTime? // When analysis last ran
|
||||||
|
|
||||||
// MinIO location
|
// MinIO location
|
||||||
bucket String
|
bucket String
|
||||||
@@ -694,7 +717,7 @@ model ProjectFile {
|
|||||||
replacedById String? // FK to the newer file that replaced this one
|
replacedById String? // FK to the newer file that replaced this one
|
||||||
|
|
||||||
// ── Competition/Round architecture fields ──
|
// ── Competition/Round architecture fields ──
|
||||||
submissionWindowId String? // FK to SubmissionWindow
|
submissionWindowId String? // FK to SubmissionWindow
|
||||||
submissionFileRequirementId String? // FK to SubmissionFileRequirement
|
submissionFileRequirementId String? // FK to SubmissionFileRequirement
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -743,10 +766,10 @@ model Assignment {
|
|||||||
juryGroupId String?
|
juryGroupId String?
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||||
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||||
evaluation Evaluation?
|
evaluation Evaluation?
|
||||||
conflictOfInterest ConflictOfInterest?
|
conflictOfInterest ConflictOfInterest?
|
||||||
|
|
||||||
@@ -1006,12 +1029,12 @@ model NotificationEmailSetting {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
model LearningResource {
|
model LearningResource {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
programId String? // null = global resource
|
programId String? // null = global resource
|
||||||
title String
|
title String
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
contentJson Json? @db.JsonB // BlockNote document structure
|
contentJson Json? @db.JsonB // BlockNote document structure
|
||||||
accessJson Json? @db.JsonB // Fine-grained access rules
|
accessJson Json? @db.JsonB // Fine-grained access rules
|
||||||
|
|
||||||
// File storage (for uploaded resources)
|
// File storage (for uploaded resources)
|
||||||
fileName String?
|
fileName String?
|
||||||
@@ -1250,7 +1273,7 @@ model TeamMember {
|
|||||||
|
|
||||||
model MentorAssignment {
|
model MentorAssignment {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
projectId String @unique // One mentor per project
|
projectId String // Team can have multiple mentors; uniqueness enforced via composite below
|
||||||
mentorId String // User with MENTOR role or expertise
|
mentorId String // User with MENTOR role or expertise
|
||||||
|
|
||||||
// Assignment tracking
|
// Assignment tracking
|
||||||
@@ -1258,6 +1281,16 @@ model MentorAssignment {
|
|||||||
assignedAt DateTime @default(now())
|
assignedAt DateTime @default(now())
|
||||||
assignedBy String? // Admin who assigned
|
assignedBy String? // Admin who assigned
|
||||||
|
|
||||||
|
// Per-assignment email idempotency: stamped once the MENTOR-side notification
|
||||||
|
// email has been sent (the "you've been assigned a project" email to the mentor).
|
||||||
|
notificationSentAt DateTime?
|
||||||
|
|
||||||
|
// Stamped once the TEAM has been introduced to this mentor (the "meet your
|
||||||
|
// mentor" email with mentor contact info). Fired by `activateRound` for
|
||||||
|
// MENTORING rounds and by mentor.assign when the project's MENTORING round
|
||||||
|
// is already ROUND_ACTIVE. Independent from notificationSentAt above.
|
||||||
|
teamIntroducedAt DateTime?
|
||||||
|
|
||||||
// AI assignment metadata
|
// AI assignment metadata
|
||||||
aiConfidenceScore Float?
|
aiConfidenceScore Float?
|
||||||
expertiseMatchScore Float?
|
expertiseMatchScore Float?
|
||||||
@@ -1267,6 +1300,11 @@ model MentorAssignment {
|
|||||||
completionStatus String @default("in_progress") // 'in_progress' | 'completed' | 'paused'
|
completionStatus String @default("in_progress") // 'in_progress' | 'completed' | 'paused'
|
||||||
lastViewedAt DateTime?
|
lastViewedAt DateTime?
|
||||||
|
|
||||||
|
// Drop tracking — null while assignment is active
|
||||||
|
droppedAt DateTime?
|
||||||
|
droppedReason String? @db.Text
|
||||||
|
droppedBy String? // 'mentor' | 'admin' | 'finalist_unconfirmed'
|
||||||
|
|
||||||
// ── Competition/Round architecture — workspace activation ──
|
// ── Competition/Round architecture — workspace activation ──
|
||||||
workspaceEnabled Boolean @default(false)
|
workspaceEnabled Boolean @default(false)
|
||||||
workspaceOpenAt DateTime?
|
workspaceOpenAt DateTime?
|
||||||
@@ -1279,11 +1317,47 @@ model MentorAssignment {
|
|||||||
milestoneCompletions MentorMilestoneCompletion[]
|
milestoneCompletions MentorMilestoneCompletion[]
|
||||||
messages MentorMessage[]
|
messages MentorMessage[]
|
||||||
files MentorFile[]
|
files MentorFile[]
|
||||||
|
changeRequests MentorChangeRequest[] @relation("MentorChangeRequestTarget")
|
||||||
|
|
||||||
|
@@unique([projectId, mentorId])
|
||||||
|
@@index([projectId])
|
||||||
@@index([mentorId])
|
@@index([mentorId])
|
||||||
@@index([method])
|
@@index([method])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MENTOR CHANGE REQUESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
enum MentorChangeRequestStatus {
|
||||||
|
PENDING
|
||||||
|
RESOLVED
|
||||||
|
DISMISSED
|
||||||
|
}
|
||||||
|
|
||||||
|
model MentorChangeRequest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
targetAssignmentId String? // Optional: a specific co-mentor the request is about
|
||||||
|
requestedByUserId String?
|
||||||
|
reason String @db.Text
|
||||||
|
status MentorChangeRequestStatus @default(PENDING)
|
||||||
|
resolvedByUserId String?
|
||||||
|
resolvedAt DateTime?
|
||||||
|
resolutionNote String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
targetAssignment MentorAssignment? @relation("MentorChangeRequestTarget", fields: [targetAssignmentId], references: [id], onDelete: SetNull)
|
||||||
|
requestedBy User? @relation("MentorChangeRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
|
||||||
|
resolvedBy User? @relation("MentorChangeResolver", fields: [resolvedByUserId], references: [id])
|
||||||
|
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([targetAssignmentId])
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// FILTERING ROUND SYSTEM
|
// FILTERING ROUND SYSTEM
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -1418,17 +1492,17 @@ enum AssignmentJobStatus {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
enum RankingTriggerType {
|
enum RankingTriggerType {
|
||||||
MANUAL // Admin clicked "Run ranking"
|
MANUAL // Admin clicked "Run ranking"
|
||||||
AUTO // Auto-triggered by assignment completion
|
AUTO // Auto-triggered by assignment completion
|
||||||
RETROACTIVE // Retroactive scan on deployment
|
RETROACTIVE // Retroactive scan on deployment
|
||||||
QUICK // Quick-rank mode (no preview)
|
QUICK // Quick-rank mode (no preview)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RankingMode {
|
enum RankingMode {
|
||||||
PREVIEW // Parsed rules shown to admin (not yet applied)
|
PREVIEW // Parsed rules shown to admin (not yet applied)
|
||||||
CONFIRMED // Admin confirmed rules, ranking applied
|
CONFIRMED // Admin confirmed rules, ranking applied
|
||||||
QUICK // Quick-rank: parse + apply without preview
|
QUICK // Quick-rank: parse + apply without preview
|
||||||
FORMULA // Formula-only: no LLM, pure math ranking
|
FORMULA // Formula-only: no LLM, pure math ranking
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RankingSnapshotStatus {
|
enum RankingSnapshotStatus {
|
||||||
@@ -1445,7 +1519,7 @@ model RankingSnapshot {
|
|||||||
roundId String
|
roundId String
|
||||||
|
|
||||||
// Trigger metadata
|
// Trigger metadata
|
||||||
triggeredById String? // null = auto-triggered
|
triggeredById String? // null = auto-triggered
|
||||||
triggerType RankingTriggerType @default(MANUAL)
|
triggerType RankingTriggerType @default(MANUAL)
|
||||||
|
|
||||||
// Criteria used
|
// Criteria used
|
||||||
@@ -1574,7 +1648,7 @@ model SpecialAward {
|
|||||||
evaluationRoundId String?
|
evaluationRoundId String?
|
||||||
juryGroupId String?
|
juryGroupId String?
|
||||||
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
|
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
|
||||||
decisionMode String? // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION"
|
decisionMode String? // "JURY_VOTE" | "ADMIN_DECISION"
|
||||||
shortlistSize Int @default(10)
|
shortlistSize Int @default(10)
|
||||||
|
|
||||||
// Eligibility job tracking
|
// Eligibility job tracking
|
||||||
@@ -1596,10 +1670,10 @@ model SpecialAward {
|
|||||||
votes AwardVote[]
|
votes AwardVote[]
|
||||||
|
|
||||||
// Competition/Round architecture relations
|
// Competition/Round architecture relations
|
||||||
competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull)
|
competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull)
|
||||||
evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull)
|
evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull)
|
||||||
awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||||
rounds Round[] @relation("AwardRounds")
|
rounds Round[] @relation("AwardRounds")
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -1663,12 +1737,12 @@ model AwardJuror {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model AwardVote {
|
model AwardVote {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
awardId String
|
awardId String
|
||||||
userId String
|
userId String
|
||||||
projectId String
|
projectId String
|
||||||
rank Int? // For RANKED mode
|
rank Int? // For RANKED mode
|
||||||
justification String? @db.Text
|
justification String? @db.Text
|
||||||
votedAt DateTime @default(now())
|
votedAt DateTime @default(now())
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
@@ -1785,7 +1859,7 @@ model MentorMessage {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
// ── Competition/Round architecture fields ──
|
// ── Competition/Round architecture fields ──
|
||||||
workspaceId String? // FK to MentorAssignment (used as workspace)
|
workspaceId String? // FK to MentorAssignment (used as workspace)
|
||||||
senderRole MentorMessageRole?
|
senderRole MentorMessageRole?
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
@@ -2121,9 +2195,9 @@ model Competition {
|
|||||||
status CompetitionStatus @default(DRAFT)
|
status CompetitionStatus @default(DRAFT)
|
||||||
|
|
||||||
// Competition-wide settings
|
// Competition-wide settings
|
||||||
categoryMode String @default("SHARED")
|
categoryMode String @default("SHARED")
|
||||||
startupFinalistCount Int @default(3)
|
startupFinalistCount Int @default(3)
|
||||||
conceptFinalistCount Int @default(3)
|
conceptFinalistCount Int @default(3)
|
||||||
|
|
||||||
// Notification preferences
|
// Notification preferences
|
||||||
notifyOnRoundAdvance Boolean @default(true)
|
notifyOnRoundAdvance Boolean @default(true)
|
||||||
@@ -2134,7 +2208,7 @@ model Competition {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||||
rounds Round[]
|
rounds Round[]
|
||||||
juryGroups JuryGroup[]
|
juryGroups JuryGroup[]
|
||||||
submissionWindows SubmissionWindow[]
|
submissionWindows SubmissionWindow[]
|
||||||
@@ -2179,10 +2253,10 @@ model Round {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
|
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
|
||||||
specialAward SpecialAward? @relation("AwardRounds", fields: [specialAwardId], references: [id], onDelete: SetNull)
|
specialAward SpecialAward? @relation("AwardRounds", fields: [specialAwardId], references: [id], onDelete: SetNull)
|
||||||
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||||
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
|
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
|
||||||
projectRoundStates ProjectRoundState[]
|
projectRoundStates ProjectRoundState[]
|
||||||
visibleSubmissionWindows RoundSubmissionVisibility[]
|
visibleSubmissionWindows RoundSubmissionVisibility[]
|
||||||
assignmentIntents AssignmentIntent[]
|
assignmentIntents AssignmentIntent[]
|
||||||
@@ -2201,7 +2275,7 @@ model Round {
|
|||||||
filteringResults FilteringResult[]
|
filteringResults FilteringResult[]
|
||||||
filteringJobs FilteringJob[]
|
filteringJobs FilteringJob[]
|
||||||
assignmentJobs AssignmentJob[]
|
assignmentJobs AssignmentJob[]
|
||||||
rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots")
|
rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots")
|
||||||
reminderLogs ReminderLog[]
|
reminderLogs ReminderLog[]
|
||||||
evaluationSummaries EvaluationSummary[]
|
evaluationSummaries EvaluationSummary[]
|
||||||
evaluationDiscussions EvaluationDiscussion[]
|
evaluationDiscussions EvaluationDiscussion[]
|
||||||
@@ -2247,7 +2321,7 @@ model ProjectRoundState {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
model JuryGroup {
|
model JuryGroup {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
competitionId String
|
competitionId String
|
||||||
name String
|
name String
|
||||||
slug String
|
slug String
|
||||||
@@ -2305,8 +2379,8 @@ model JuryGroupMember {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
juryGroup JuryGroup @relation(fields: [juryGroupId], references: [id], onDelete: Cascade)
|
juryGroup JuryGroup @relation(fields: [juryGroupId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
assignmentIntents AssignmentIntent[]
|
assignmentIntents AssignmentIntent[]
|
||||||
deliberationVotes DeliberationVote[]
|
deliberationVotes DeliberationVote[]
|
||||||
deliberationParticipations DeliberationParticipant[]
|
deliberationParticipations DeliberationParticipant[]
|
||||||
@@ -2344,7 +2418,7 @@ model SubmissionWindow {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
|
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
|
||||||
fileRequirements SubmissionFileRequirement[]
|
fileRequirements SubmissionFileRequirement[]
|
||||||
projectFiles ProjectFile[]
|
projectFiles ProjectFile[]
|
||||||
rounds Round[]
|
rounds Round[]
|
||||||
@@ -2378,7 +2452,7 @@ model SubmissionFileRequirement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model RoundSubmissionVisibility {
|
model RoundSubmissionVisibility {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
roundId String
|
roundId String
|
||||||
submissionWindowId String
|
submissionWindowId String
|
||||||
canView Boolean @default(true)
|
canView Boolean @default(true)
|
||||||
@@ -2423,8 +2497,9 @@ model AssignmentIntent {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
model MentorFile {
|
model MentorFile {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
mentorAssignmentId String
|
projectId String // Primary access scope: files belong to the team
|
||||||
|
mentorAssignmentId String? // Nullable audit FK: which assignment uploaded; survives mentor drop
|
||||||
uploadedByUserId String
|
uploadedByUserId String
|
||||||
|
|
||||||
fileName String
|
fileName String
|
||||||
@@ -2443,13 +2518,15 @@ model MentorFile {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id])
|
mentorAssignment MentorAssignment? @relation(fields: [mentorAssignmentId], references: [id], onDelete: SetNull)
|
||||||
promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
|
uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id])
|
||||||
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
|
promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
|
||||||
|
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
|
||||||
comments MentorFileComment[]
|
comments MentorFileComment[]
|
||||||
promotionEvents SubmissionPromotionEvent[]
|
promotionEvents SubmissionPromotionEvent[]
|
||||||
|
|
||||||
|
@@index([projectId])
|
||||||
@@index([mentorAssignmentId])
|
@@index([mentorAssignmentId])
|
||||||
@@index([uploadedByUserId])
|
@@index([uploadedByUserId])
|
||||||
}
|
}
|
||||||
@@ -2467,9 +2544,9 @@ model MentorFileComment {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
mentorFile MentorFile @relation(fields: [mentorFileId], references: [id], onDelete: Cascade)
|
mentorFile MentorFile @relation(fields: [mentorFileId], references: [id], onDelete: Cascade)
|
||||||
author User @relation("MentorFileCommentAuthor", fields: [authorId], references: [id])
|
author User @relation("MentorFileCommentAuthor", fields: [authorId], references: [id])
|
||||||
parentComment MentorFileComment? @relation("CommentThread", fields: [parentCommentId], references: [id], onDelete: Cascade)
|
parentComment MentorFileComment? @relation("CommentThread", fields: [parentCommentId], references: [id], onDelete: Cascade)
|
||||||
replies MentorFileComment[] @relation("CommentThread")
|
replies MentorFileComment[] @relation("CommentThread")
|
||||||
|
|
||||||
@@index([mentorFileId])
|
@@index([mentorFileId])
|
||||||
@@ -2478,14 +2555,14 @@ model MentorFileComment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model SubmissionPromotionEvent {
|
model SubmissionPromotionEvent {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
projectId String
|
projectId String
|
||||||
roundId String
|
roundId String
|
||||||
slotKey String
|
slotKey String
|
||||||
sourceType SubmissionPromotionSource
|
sourceType SubmissionPromotionSource
|
||||||
sourceFileId String?
|
sourceFileId String?
|
||||||
promotedById String
|
promotedById String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
@@ -2623,3 +2700,273 @@ model ResultUnlockEvent {
|
|||||||
@@index([resultLockId])
|
@@index([resultLockId])
|
||||||
@@index([unlockedById])
|
@@index([unlockedById])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Grand-finale logistics (PR 1: finalist confirmation flow)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum WaitlistEntryStatus {
|
||||||
|
WAITING // available for promotion
|
||||||
|
PROMOTED // moved into a finalist slot
|
||||||
|
USED // promoted and confirmation flow completed (declined or accepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FinalistConfirmationStatus {
|
||||||
|
PENDING // sent, awaiting team response
|
||||||
|
CONFIRMED // team accepted, attendees selected
|
||||||
|
DECLINED // team explicitly declined
|
||||||
|
EXPIRED // deadline passed without response
|
||||||
|
SUPERSEDED // admin manually overrode (e.g. unconfirmed to allow quota decrease)
|
||||||
|
}
|
||||||
|
|
||||||
|
model FinalistSlotQuota {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
programId String
|
||||||
|
category CompetitionCategory
|
||||||
|
quota Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([programId, category])
|
||||||
|
@@index([programId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WaitlistEntry {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
programId String
|
||||||
|
projectId String @unique
|
||||||
|
category CompetitionCategory
|
||||||
|
rank Int
|
||||||
|
status WaitlistEntryStatus @default(WAITING)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([programId, category, rank])
|
||||||
|
@@index([programId, category, status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model FinalistConfirmation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String @unique
|
||||||
|
category CompetitionCategory
|
||||||
|
status FinalistConfirmationStatus @default(PENDING)
|
||||||
|
deadline DateTime
|
||||||
|
token String @unique
|
||||||
|
confirmedAt DateTime?
|
||||||
|
declinedAt DateTime?
|
||||||
|
declineReason String? // optional free-text on decline
|
||||||
|
expiredAt DateTime?
|
||||||
|
promotedFromWaitlistEntryId String? @unique // null for original finalists
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
attendingMembers AttendingMember[]
|
||||||
|
|
||||||
|
@@index([status, deadline]) // for cron scan
|
||||||
|
@@index([category, status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AttendingMember {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
confirmationId String
|
||||||
|
userId String // must be a TeamMember of the same project (validated at write time)
|
||||||
|
needsVisa Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
confirmation FinalistConfirmation @relation(fields: [confirmationId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
flightDetail FlightDetail?
|
||||||
|
visaApplication VisaApplication?
|
||||||
|
lunchPick MemberLunchPick?
|
||||||
|
|
||||||
|
@@unique([confirmationId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Grand-finale logistics (PR 2: hotels + flight tracking)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum FlightDetailStatus {
|
||||||
|
PENDING // team submitted details, admin not yet reviewed
|
||||||
|
CONFIRMED // admin verified booking
|
||||||
|
}
|
||||||
|
|
||||||
|
model Hotel {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
programId String @unique // 1:1 — one hotel per edition
|
||||||
|
name String
|
||||||
|
address String? @db.Text
|
||||||
|
link String? // external URL to hotel page / booking confirmation
|
||||||
|
notes String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model FlightDetail {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
attendingMemberId String @unique // 1:1
|
||||||
|
arrivalAt DateTime?
|
||||||
|
arrivalFlightNumber String?
|
||||||
|
arrivalAirport String?
|
||||||
|
departureAt DateTime?
|
||||||
|
departureFlightNumber String?
|
||||||
|
departureAirport String?
|
||||||
|
status FlightDetailStatus @default(PENDING)
|
||||||
|
adminNotes String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Grand-finale visa tracking (PR 4)
|
||||||
|
// Process metadata only — no document storage. Passport scans / invitation
|
||||||
|
// letters / decision documents are exchanged over email; this model just
|
||||||
|
// records what stage the application is at, key dates, and free-text notes.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum VisaStatus {
|
||||||
|
NOT_NEEDED
|
||||||
|
REQUESTED
|
||||||
|
INVITATION_SENT
|
||||||
|
APPOINTMENT_BOOKED
|
||||||
|
GRANTED
|
||||||
|
DENIED
|
||||||
|
}
|
||||||
|
|
||||||
|
model VisaApplication {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
attendingMemberId String @unique // 1:1
|
||||||
|
status VisaStatus @default(REQUESTED)
|
||||||
|
nationality String? // self-declared, optional
|
||||||
|
invitationSentAt DateTime?
|
||||||
|
appointmentAt DateTime?
|
||||||
|
decisionAt DateTime? // GRANTED or DENIED date
|
||||||
|
notes String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Grand-finale lunch event (PR 6)
|
||||||
|
// Single configurable lunch event per edition. Each attending member has a
|
||||||
|
// 1:1 MemberLunchPick (auto-created via lunch-pick-sync). External attendees
|
||||||
|
// can be standalone or attached to a finalist project. Allergens use the
|
||||||
|
// EU 14 regulated list; dishes carry dietary tags.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum DietaryTag {
|
||||||
|
VEGETARIAN
|
||||||
|
VEGAN
|
||||||
|
GLUTEN_FREE
|
||||||
|
PESCATARIAN
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Allergen {
|
||||||
|
GLUTEN
|
||||||
|
CRUSTACEANS
|
||||||
|
EGGS
|
||||||
|
FISH
|
||||||
|
PEANUTS
|
||||||
|
SOYBEANS
|
||||||
|
MILK
|
||||||
|
TREE_NUTS
|
||||||
|
CELERY
|
||||||
|
MUSTARD
|
||||||
|
SESAME
|
||||||
|
SULPHITES
|
||||||
|
LUPIN
|
||||||
|
MOLLUSCS
|
||||||
|
}
|
||||||
|
|
||||||
|
model LunchEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
programId String @unique
|
||||||
|
enabled Boolean @default(false)
|
||||||
|
eventAt DateTime?
|
||||||
|
endAt DateTime?
|
||||||
|
venue String?
|
||||||
|
notes String? @db.Text
|
||||||
|
changeCutoffHours Int @default(48)
|
||||||
|
reminderHoursBeforeDeadline Int?
|
||||||
|
cronEnabled Boolean @default(true)
|
||||||
|
extraRecipients String[] @default([])
|
||||||
|
reminderSentAt DateTime?
|
||||||
|
recapSentAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||||
|
dishes Dish[]
|
||||||
|
externalAttendees ExternalAttendee[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Dish {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
lunchEventId String
|
||||||
|
name String
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
dietaryTags DietaryTag[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
|
||||||
|
memberPicks MemberLunchPick[]
|
||||||
|
externals ExternalAttendee[]
|
||||||
|
|
||||||
|
@@index([lunchEventId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model MemberLunchPick {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
attendingMemberId String @unique
|
||||||
|
dishId String?
|
||||||
|
allergens Allergen[] @default([])
|
||||||
|
allergenOther String?
|
||||||
|
pickedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
|
||||||
|
dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([dishId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ExternalAttendee {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
lunchEventId String
|
||||||
|
projectId String?
|
||||||
|
name String
|
||||||
|
email String?
|
||||||
|
roleNote String?
|
||||||
|
dishId String?
|
||||||
|
allergens Allergen[] @default([])
|
||||||
|
allergenOther String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
|
||||||
|
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||||
|
dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([lunchEventId])
|
||||||
|
@@index([projectId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -317,7 +317,6 @@ async function main() {
|
|||||||
{ email: 'matt@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' },
|
{ email: 'matt@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' },
|
||||||
{ email: 'matt@letsbe.solutions', name: 'Matt (Applicant)', role: UserRole.APPLICANT, password: '195260Mp!' },
|
{ email: 'matt@letsbe.solutions', name: 'Matt (Applicant)', role: UserRole.APPLICANT, password: '195260Mp!' },
|
||||||
{ email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' },
|
{ email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' },
|
||||||
{ email: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const staffUsers: Record<string, string> = {}
|
const staffUsers: Record<string, string> = {}
|
||||||
|
|||||||
101
scripts/cleanup-test-pollution.ts
Normal file
101
scripts/cleanup-test-pollution.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* One-shot: remove leaked test data from dev DB.
|
||||||
|
*
|
||||||
|
* Test runs that crashed before reaching `afterAll` left orphan test users +
|
||||||
|
* programs. This mirrors `tests/helpers.ts#cleanupTestData` with the same
|
||||||
|
* reverse-dependency order, applied to all programs whose name matches the
|
||||||
|
* test patterns.
|
||||||
|
*
|
||||||
|
* Run: npx tsx scripts/cleanup-test-pollution.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
const TEST_PROGRAM_PATTERNS = [
|
||||||
|
'Test Program prog-%',
|
||||||
|
'getCandidates-%',
|
||||||
|
'bulk-%',
|
||||||
|
'source-flag-%',
|
||||||
|
'mentor-files-%',
|
||||||
|
'mentor-config-%',
|
||||||
|
]
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const programs = await prisma.program.findMany({
|
||||||
|
where: {
|
||||||
|
OR: TEST_PROGRAM_PATTERNS.map((p) => ({ name: { startsWith: p.replace('%', '') } })),
|
||||||
|
},
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Found ${programs.length} test programs:`)
|
||||||
|
programs.forEach((p) => console.log(` - ${p.id} ${p.name}`))
|
||||||
|
|
||||||
|
for (const program of programs) {
|
||||||
|
const programId = program.id
|
||||||
|
console.log(`\nCleaning ${program.name}...`)
|
||||||
|
|
||||||
|
// MentorAssignment isn't in cleanupTestData — kill it first
|
||||||
|
const ma = await prisma.mentorAssignment.deleteMany({
|
||||||
|
where: { project: { programId } },
|
||||||
|
})
|
||||||
|
if (ma.count > 0) console.log(` ${ma.count} MentorAssignment`)
|
||||||
|
|
||||||
|
// Mirror tests/helpers.ts#cleanupTestData order
|
||||||
|
await prisma.cohortProject.deleteMany({ where: { cohort: { round: { competition: { programId } } } } })
|
||||||
|
await prisma.cohort.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.liveProgressCursor.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.filteringResult.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.filteringRule.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.filteringJob.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.assignmentJob.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.conflictOfInterest.deleteMany({ where: { assignment: { round: { competition: { programId } } } } })
|
||||||
|
await prisma.evaluation.deleteMany({ where: { assignment: { round: { competition: { programId } } } } })
|
||||||
|
await prisma.assignment.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.evaluationForm.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.fileRequirement.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.gracePeriod.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.reminderLog.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.evaluationSummary.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.evaluationDiscussion.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.projectRoundState.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
|
await prisma.awardEligibility.deleteMany({ where: { award: { program: { id: programId } } } })
|
||||||
|
await prisma.awardVote.deleteMany({ where: { award: { program: { id: programId } } } })
|
||||||
|
await prisma.awardJuror.deleteMany({ where: { award: { program: { id: programId } } } })
|
||||||
|
await prisma.specialAward.deleteMany({ where: { programId } })
|
||||||
|
await prisma.round.deleteMany({ where: { competition: { programId } } })
|
||||||
|
await prisma.competition.deleteMany({ where: { programId } })
|
||||||
|
await prisma.projectStatusHistory.deleteMany({ where: { project: { programId } } })
|
||||||
|
await prisma.projectFile.deleteMany({ where: { project: { programId } } })
|
||||||
|
await prisma.projectTag.deleteMany({ where: { project: { programId } } })
|
||||||
|
await prisma.project.deleteMany({ where: { programId } })
|
||||||
|
await prisma.program.deleteMany({ where: { id: programId } })
|
||||||
|
console.log(' cascade complete')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete test users (@test.local). Catch any audit-log refs first.
|
||||||
|
const testUsers = await prisma.user.findMany({
|
||||||
|
where: { email: { endsWith: '@test.local' } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
const testUserIds = testUsers.map((u) => u.id)
|
||||||
|
console.log(`\nDeleting ${testUserIds.length} @test.local users...`)
|
||||||
|
if (testUserIds.length > 0) {
|
||||||
|
await prisma.decisionAuditLog.deleteMany({ where: { actorId: { in: testUserIds } } })
|
||||||
|
await prisma.auditLog.deleteMany({ where: { userId: { in: testUserIds } } })
|
||||||
|
// Any remaining MentorAssignments referencing these users (e.g., from other tests)
|
||||||
|
await prisma.mentorAssignment.deleteMany({ where: { mentorId: { in: testUserIds } } })
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: testUserIds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nDone.')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => prisma.$disconnect())
|
||||||
|
.catch(async (e) => {
|
||||||
|
console.error(e)
|
||||||
|
await prisma.$disconnect()
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -58,7 +58,7 @@ export default function EditAwardPage({
|
|||||||
const [votingEndAt, setVotingEndAt] = useState('')
|
const [votingEndAt, setVotingEndAt] = useState('')
|
||||||
const [evaluationRoundId, setEvaluationRoundId] = useState('')
|
const [evaluationRoundId, setEvaluationRoundId] = useState('')
|
||||||
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
|
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
|
||||||
const [decisionMode, setDecisionMode] = useState<'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION'>('JURY_VOTE')
|
const [decisionMode, setDecisionMode] = useState<'JURY_VOTE' | 'ADMIN_DECISION'>('JURY_VOTE')
|
||||||
|
|
||||||
// Helper to format date for datetime-local input
|
// Helper to format date for datetime-local input
|
||||||
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
||||||
@@ -236,7 +236,6 @@ export default function EditAwardPage({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="JURY_VOTE">Jury Vote — tallied from all jurors</SelectItem>
|
<SelectItem value="JURY_VOTE">Jury Vote — tallied from all jurors</SelectItem>
|
||||||
<SelectItem value="AWARD_MASTER_DECISION">Award Master — sponsor picks winner</SelectItem>
|
|
||||||
<SelectItem value="ADMIN_DECISION">Admin Decision — admin selects winner</SelectItem>
|
<SelectItem value="ADMIN_DECISION">Admin Decision — admin selects winner</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -335,20 +335,20 @@ function RoundsDndGrid({
|
|||||||
function ConfidenceBadge({ confidence }: { confidence: number }) {
|
function ConfidenceBadge({ confidence }: { confidence: number }) {
|
||||||
if (confidence > 0.8) {
|
if (confidence > 0.8) {
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs tabular-nums">
|
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700 text-xs tabular-nums">
|
||||||
{Math.round(confidence * 100)}%
|
{Math.round(confidence * 100)}%
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (confidence >= 0.5) {
|
if (confidence >= 0.5) {
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-400 text-xs tabular-nums">
|
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700 text-xs tabular-nums">
|
||||||
{Math.round(confidence * 100)}%
|
{Math.round(confidence * 100)}%
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-950/30 dark:text-red-400 text-xs tabular-nums">
|
<Badge variant="outline" className="border-red-300 bg-red-50 text-red-700 text-xs tabular-nums">
|
||||||
{Math.round(confidence * 100)}%
|
{Math.round(confidence * 100)}%
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
@@ -408,7 +408,7 @@ export default function AwardDetailPage({
|
|||||||
|
|
||||||
// Deferred queries - only load when needed
|
// Deferred queries - only load when needed
|
||||||
const { data: allUsers } = trpc.user.list.useQuery(
|
const { data: allUsers } = trpc.user.list.useQuery(
|
||||||
{ page: 1, perPage: 100, roles: ['JURY_MEMBER', 'AWARD_MASTER'] },
|
{ page: 1, perPage: 100, roles: ['JURY_MEMBER'] },
|
||||||
{ enabled: activeTab === 'jurors' }
|
{ enabled: activeTab === 'jurors' }
|
||||||
)
|
)
|
||||||
const { data: juryGroups } = trpc.juryGroup.list.useQuery(
|
const { data: juryGroups } = trpc.juryGroup.list.useQuery(
|
||||||
@@ -513,6 +513,13 @@ export default function AwardDetailPage({
|
|||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
const notifyJurors = trpc.specialAward.notifyJurors.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const failedNote = data.failed > 0 ? ` (${data.failed} failed)` : ''
|
||||||
|
toast.success(`Reminder sent to ${data.sent} of ${data.targeted} juror(s)${failedNote}`)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
const setWinner = trpc.specialAward.setWinner.useMutation({
|
const setWinner = trpc.specialAward.setWinner.useMutation({
|
||||||
onSuccess: invalidateAward,
|
onSuccess: invalidateAward,
|
||||||
})
|
})
|
||||||
@@ -890,8 +897,8 @@ export default function AwardDetailPage({
|
|||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Eligible</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Eligible</p>
|
||||||
<p className="text-2xl font-bold tabular-nums">{award.eligibleCount}</p>
|
<p className="text-2xl font-bold tabular-nums">{award.eligibleCount}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/40">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100">
|
||||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -903,8 +910,8 @@ export default function AwardDetailPage({
|
|||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p>
|
||||||
<p className="text-2xl font-bold tabular-nums">{(award as any).totalAssessed ?? award._count.eligibilities}</p>
|
<p className="text-2xl font-bold tabular-nums">{(award as any).totalAssessed ?? award._count.eligibilities}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
|
||||||
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
<ListChecks className="h-5 w-5 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -916,8 +923,8 @@ export default function AwardDetailPage({
|
|||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Jurors</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Jurors</p>
|
||||||
<p className="text-2xl font-bold tabular-nums">{award._count.jurors}</p>
|
<p className="text-2xl font-bold tabular-nums">{award._count.jurors}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-100 dark:bg-violet-950/40">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-100">
|
||||||
<Users className="h-5 w-5 text-violet-600 dark:text-violet-400" />
|
<Users className="h-5 w-5 text-violet-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -929,8 +936,8 @@ export default function AwardDetailPage({
|
|||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Votes</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Votes</p>
|
||||||
<p className="text-2xl font-bold tabular-nums">{award._count.votes}</p>
|
<p className="text-2xl font-bold tabular-nums">{award._count.votes}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-950/40">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100">
|
||||||
<Vote className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
<Vote className="h-5 w-5 text-amber-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -1335,7 +1342,7 @@ export default function AwardDetailPage({
|
|||||||
|
|
||||||
{/* Jurors Tab */}
|
{/* Jurors Tab */}
|
||||||
<TabsContent value="jurors" className="space-y-4">
|
<TabsContent value="jurors" className="space-y-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Select value={selectedJurorId} onValueChange={setSelectedJurorId}>
|
<Select value={selectedJurorId} onValueChange={setSelectedJurorId}>
|
||||||
<SelectTrigger className="w-64">
|
<SelectTrigger className="w-64">
|
||||||
<SelectValue placeholder="Select a juror..." />
|
<SelectValue placeholder="Select a juror..." />
|
||||||
@@ -1355,6 +1362,19 @@ export default function AwardDetailPage({
|
|||||||
<UserPlus className="mr-2 h-4 w-4" />
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
Add Juror
|
Add Juror
|
||||||
</Button>
|
</Button>
|
||||||
|
{jurors && jurors.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => notifyJurors.mutate({ awardId })}
|
||||||
|
disabled={notifyJurors.isPending}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
|
{notifyJurors.isPending
|
||||||
|
? 'Sending...'
|
||||||
|
: `Send reminder to all (${jurors.length})`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Import from Jury Group */}
|
{/* Import from Jury Group */}
|
||||||
@@ -1498,7 +1518,6 @@ export default function AwardDetailPage({
|
|||||||
onSubmit={async (rows) => {
|
onSubmit={async (rows) => {
|
||||||
await bulkInvite.mutateAsync({
|
await bulkInvite.mutateAsync({
|
||||||
awardId,
|
awardId,
|
||||||
role: 'AWARD_MASTER',
|
|
||||||
invitees: rows.map((r) => ({ name: r.name || undefined, email: r.email })),
|
invitees: rows.map((r) => ({ name: r.name || undefined, email: r.email })),
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
@@ -1549,11 +1568,23 @@ export default function AwardDetailPage({
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
notifyJurors.mutate({ awardId, userIds: [j.userId] })
|
||||||
|
}
|
||||||
|
disabled={notifyJurors.isPending}
|
||||||
|
title="Send reminder email"
|
||||||
|
>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleRemoveJuror(j.userId)}
|
onClick={() => handleRemoveJuror(j.userId)}
|
||||||
disabled={removeJuror.isPending}
|
disabled={removeJuror.isPending}
|
||||||
|
title="Remove juror"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1581,7 +1612,7 @@ export default function AwardDetailPage({
|
|||||||
{/* Rounds Tab */}
|
{/* Rounds Tab */}
|
||||||
<TabsContent value="rounds" className="space-y-4">
|
<TabsContent value="rounds" className="space-y-4">
|
||||||
{award.eligibilityMode !== 'SEPARATE_POOL' && (
|
{award.eligibilityMode !== 'SEPARATE_POOL' && (
|
||||||
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
|
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800">
|
||||||
<Info className="h-4 w-4 mt-0.5 shrink-0" />
|
<Info className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects.
|
Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects.
|
||||||
@@ -1589,7 +1620,7 @@ export default function AwardDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!award.competitionId && (
|
{!award.competitionId && (
|
||||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800">
|
||||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Link this award to a competition first before creating rounds.
|
Link this award to a competition first before creating rounds.
|
||||||
@@ -1719,16 +1750,16 @@ export default function AwardDetailPage({
|
|||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={r.project.id}
|
key={r.project.id}
|
||||||
className={isWinner ? 'bg-amber-50/80 dark:bg-amber-950/20' : ''}
|
className={isWinner ? 'bg-amber-50/80' : ''}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold ${
|
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold ${
|
||||||
i === 0
|
i === 0
|
||||||
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
|
? 'bg-amber-100 text-amber-800'
|
||||||
: i === 1
|
: i === 1
|
||||||
? 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-300'
|
? 'bg-slate-200 text-slate-700'
|
||||||
: i === 2
|
: i === 2
|
||||||
? 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300'
|
? 'bg-orange-100 text-orange-800'
|
||||||
: 'text-muted-foreground'
|
: 'text-muted-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { cn, formatEnumLabel } from '@/lib/utils'
|
import { cn, formatEnumLabel } from '@/lib/utils'
|
||||||
import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot } from 'lucide-react'
|
import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot, Trophy } from 'lucide-react'
|
||||||
|
|
||||||
const capModeLabels = {
|
const capModeLabels = {
|
||||||
HARD: 'Hard Cap',
|
HARD: 'Hard Cap',
|
||||||
@@ -301,10 +301,10 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Round assignments */}
|
{/* Round + Special-award assignments */}
|
||||||
{(group as any).rounds?.length > 0 && (
|
{((group as any).rounds?.length > 0 || (group as any).awards?.length > 0) && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{(group as any).rounds.map((r: any) => (
|
{(group as any).rounds?.map((r: any) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={r.id}
|
key={r.id}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -319,6 +319,21 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
|
|||||||
{r.name}
|
{r.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
{(group as any).awards?.map((a: any) => (
|
||||||
|
<Badge
|
||||||
|
key={a.id}
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] gap-1',
|
||||||
|
a.status === 'VOTING_OPEN' && 'border-amber-300 bg-amber-50 text-amber-700',
|
||||||
|
a.status === 'CLOSED' && 'border-emerald-300 bg-emerald-50 text-emerald-700',
|
||||||
|
(a.status === 'DRAFT' || a.status === 'NOMINATIONS_OPEN') && 'border-slate-200 text-slate-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trophy className="h-2.5 w-2.5" />
|
||||||
|
{a.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ const ROLE_OPTIONS = [
|
|||||||
{ value: 'MENTOR', label: 'Mentors' },
|
{ value: 'MENTOR', label: 'Mentors' },
|
||||||
{ value: 'OBSERVER', label: 'Observers' },
|
{ value: 'OBSERVER', label: 'Observers' },
|
||||||
{ value: 'APPLICANT', label: 'Applicants' },
|
{ value: 'APPLICANT', label: 'Applicants' },
|
||||||
{ value: 'AWARD_MASTER', label: 'Award Masters' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
type AccessRule =
|
type AccessRule =
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ const ROLE_OPTIONS = [
|
|||||||
{ value: 'MENTOR', label: 'Mentors' },
|
{ value: 'MENTOR', label: 'Mentors' },
|
||||||
{ value: 'OBSERVER', label: 'Observers' },
|
{ value: 'OBSERVER', label: 'Observers' },
|
||||||
{ value: 'APPLICANT', label: 'Applicants' },
|
{ value: 'APPLICANT', label: 'Applicants' },
|
||||||
{ value: 'AWARD_MASTER', label: 'Award Masters' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
type AccessRule =
|
type AccessRule =
|
||||||
|
|||||||
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,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import {
|
import {
|
||||||
@@ -69,6 +85,11 @@ import {
|
|||||||
LogIn,
|
LogIn,
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||||
import { formatRelativeTime } from '@/lib/utils'
|
import { formatRelativeTime } from '@/lib/utils'
|
||||||
@@ -97,7 +118,6 @@ const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
|||||||
PROGRAM_ADMIN: 'default',
|
PROGRAM_ADMIN: 'default',
|
||||||
SUPER_ADMIN: 'default',
|
SUPER_ADMIN: 'default',
|
||||||
APPLICANT: 'secondary',
|
APPLICANT: 'secondary',
|
||||||
AWARD_MASTER: 'outline',
|
|
||||||
AUDIENCE: 'outline',
|
AUDIENCE: 'outline',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +132,45 @@ export default function MemberDetailPage() {
|
|||||||
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
|
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
|
||||||
const updateUser = trpc.user.update.useMutation()
|
const updateUser = trpc.user.update.useMutation()
|
||||||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
||||||
|
const generateAccessLink = trpc.user.generateAccessLink.useMutation()
|
||||||
const startImpersonation = trpc.user.startImpersonation.useMutation()
|
const startImpersonation = trpc.user.startImpersonation.useMutation()
|
||||||
|
|
||||||
|
const [accessLinkOpen, setAccessLinkOpen] = useState(false)
|
||||||
|
const [accessLink, setAccessLink] = useState<{
|
||||||
|
url: string
|
||||||
|
kind: 'setup' | 'magic_login'
|
||||||
|
expiresAt: Date
|
||||||
|
} | null>(null)
|
||||||
|
const [linkCopied, setLinkCopied] = useState(false)
|
||||||
|
|
||||||
|
const handleGenerateAccessLink = async () => {
|
||||||
|
try {
|
||||||
|
const result = await generateAccessLink.mutateAsync({ userId })
|
||||||
|
setAccessLink({
|
||||||
|
url: result.url,
|
||||||
|
kind: result.kind,
|
||||||
|
expiresAt: new Date(result.expiresAt),
|
||||||
|
})
|
||||||
|
setLinkCopied(false)
|
||||||
|
setAccessLinkOpen(true)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Failed to generate access link'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyAccessLink = async () => {
|
||||||
|
if (!accessLink) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(accessLink.url)
|
||||||
|
setLinkCopied(true)
|
||||||
|
toast.success('Link copied to clipboard')
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not copy — please select and copy the link manually')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mentor assignments (only fetched for mentors)
|
// Mentor assignments (only fetched for mentors)
|
||||||
const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery(
|
const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery(
|
||||||
{ mentorId: userId, page: 1, perPage: 50 },
|
{ mentorId: userId, page: 1, perPage: 50 },
|
||||||
@@ -138,6 +195,10 @@ export default function MemberDetailPage() {
|
|||||||
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
||||||
const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false)
|
const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false)
|
||||||
const [pendingSuperAdminRole, setPendingSuperAdminRole] = useState(false)
|
const [pendingSuperAdminRole, setPendingSuperAdminRole] = useState(false)
|
||||||
|
const [pendingAdditionalRole, setPendingAdditionalRole] = useState<{
|
||||||
|
role: 'JURY_MEMBER' | 'OBSERVER' | 'MENTOR'
|
||||||
|
action: 'add' | 'remove'
|
||||||
|
} | null>(null)
|
||||||
const [additionalRoles, setAdditionalRoles] = useState<string[]>([])
|
const [additionalRoles, setAdditionalRoles] = useState<string[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -154,7 +215,7 @@ export default function MemberDetailPage() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
const allRoles = [role, ...additionalRoles.filter((r) => r !== role)] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'>
|
const allRoles = [role, ...additionalRoles.filter((r) => r !== role)] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'>
|
||||||
await updateUser.mutateAsync({
|
await updateUser.mutateAsync({
|
||||||
id: userId,
|
id: userId,
|
||||||
email: email || undefined,
|
email: email || undefined,
|
||||||
@@ -283,6 +344,21 @@ export default function MemberDetailPage() {
|
|||||||
{user.status === 'INVITED' ? 'Resend Invite' : 'Send Invitation'}
|
{user.status === 'INVITED' ? 'Resend Invite' : 'Send Invitation'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{user.status !== 'SUSPENDED' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleGenerateAccessLink}
|
||||||
|
disabled={generateAccessLink.isPending}
|
||||||
|
title="Generate a one-time link to share manually if email isn't reaching them"
|
||||||
|
>
|
||||||
|
{generateAccessLink.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LinkIcon className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Copy Access Link
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleImpersonate}
|
onClick={handleImpersonate}
|
||||||
@@ -622,7 +698,6 @@ export default function MemberDetailPage() {
|
|||||||
<SelectItem value="MENTOR">Mentor</SelectItem>
|
<SelectItem value="MENTOR">Mentor</SelectItem>
|
||||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||||
<SelectItem value="APPLICANT">Applicant</SelectItem>
|
<SelectItem value="APPLICANT">Applicant</SelectItem>
|
||||||
<SelectItem value="AWARD_MASTER">Award Master</SelectItem>
|
|
||||||
<SelectItem value="AUDIENCE">Audience</SelectItem>
|
<SelectItem value="AUDIENCE">Audience</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -630,26 +705,72 @@ export default function MemberDetailPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Additional Roles</Label>
|
<Label>Additional Roles</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Grant additional dashboard access beyond the primary role
|
Grant additional dashboard access beyond the primary role.
|
||||||
|
Click the menu to add or remove a role — you'll be
|
||||||
|
asked to confirm each change.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{(['JURY_MEMBER', 'OBSERVER', 'MENTOR', 'AWARD_MASTER'] as const)
|
{additionalRoles.length === 0 ? (
|
||||||
.filter((r) => r !== role)
|
<span className="text-sm text-muted-foreground italic">
|
||||||
.map((r) => (
|
None — only the primary role above
|
||||||
<label key={r} className="flex items-center gap-2 text-sm cursor-pointer">
|
</span>
|
||||||
<Checkbox
|
) : (
|
||||||
checked={additionalRoles.includes(r)}
|
additionalRoles.map((r) => (
|
||||||
onCheckedChange={(checked) => {
|
<Badge
|
||||||
if (checked) {
|
key={r}
|
||||||
setAdditionalRoles((prev) => [...prev, r])
|
variant={roleColors[r] || 'secondary'}
|
||||||
} else {
|
className="gap-1.5 pl-2 pr-1 py-0.5"
|
||||||
setAdditionalRoles((prev) => prev.filter((x) => x !== r))
|
>
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{r.replace(/_/g, ' ')}
|
{r.replace(/_/g, ' ')}
|
||||||
</label>
|
<button
|
||||||
))}
|
type="button"
|
||||||
|
aria-label={`Remove ${r.replace(/_/g, ' ')} role`}
|
||||||
|
className="rounded-full hover:bg-foreground/10 p-0.5 transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
setPendingAdditionalRole({
|
||||||
|
role: r as 'JURY_MEMBER' | 'OBSERVER' | 'MENTOR',
|
||||||
|
action: 'remove',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" type="button">
|
||||||
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Manage roles
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
|
<DropdownMenuLabel>Toggle additional roles</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{(['JURY_MEMBER', 'OBSERVER', 'MENTOR'] as const)
|
||||||
|
.filter((r) => r !== role)
|
||||||
|
.map((r) => {
|
||||||
|
const isAssigned = additionalRoles.includes(r)
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={r}
|
||||||
|
checked={isAssigned}
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setPendingAdditionalRole({
|
||||||
|
role: r,
|
||||||
|
action: isAssigned ? 'remove' : 'add',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.replace(/_/g, ' ')}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -821,6 +942,81 @@ export default function MemberDetailPage() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Super Admin Confirmation Dialog */}
|
{/* Super Admin Confirmation Dialog */}
|
||||||
|
<AlertDialog
|
||||||
|
open={pendingAdditionalRole !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setPendingAdditionalRole(null) }}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{pendingAdditionalRole?.action === 'add' ? 'Add' : 'Remove'}{' '}
|
||||||
|
{pendingAdditionalRole?.role.replace(/_/g, ' ')} role?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{pendingAdditionalRole?.action === 'add' ? (
|
||||||
|
<>
|
||||||
|
This will give <strong>{name || user?.name || 'this user'}</strong>{' '}
|
||||||
|
the {pendingAdditionalRole.role.replace(/_/g, ' ')} dashboard
|
||||||
|
in addition to their primary role. They'll be able to
|
||||||
|
switch between dashboards from the role switcher. Click
|
||||||
|
“Save changes” below to apply.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
This will revoke the {pendingAdditionalRole?.role.replace(/_/g, ' ')}
|
||||||
|
{' '}dashboard from <strong>{name || user?.name || 'this user'}</strong>.
|
||||||
|
They'll keep their primary role and any other additional
|
||||||
|
roles. Click “Save changes” below to apply.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setPendingAdditionalRole(null)}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={async () => {
|
||||||
|
if (!pendingAdditionalRole) return
|
||||||
|
const { role: r, action } = pendingAdditionalRole
|
||||||
|
const nextAdditional =
|
||||||
|
action === 'add'
|
||||||
|
? additionalRoles.includes(r)
|
||||||
|
? additionalRoles
|
||||||
|
: [...additionalRoles, r]
|
||||||
|
: additionalRoles.filter((x) => x !== r)
|
||||||
|
const nextAllRoles = [
|
||||||
|
role,
|
||||||
|
...nextAdditional.filter((x) => x !== role),
|
||||||
|
] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'>
|
||||||
|
try {
|
||||||
|
await updateUser.mutateAsync({
|
||||||
|
id: userId,
|
||||||
|
roles: nextAllRoles,
|
||||||
|
})
|
||||||
|
setAdditionalRoles(nextAdditional)
|
||||||
|
utils.user.get.invalidate({ id: userId })
|
||||||
|
utils.user.list.invalidate()
|
||||||
|
toast.success(
|
||||||
|
action === 'add'
|
||||||
|
? `${r.replace(/_/g, ' ')} role added`
|
||||||
|
: `${r.replace(/_/g, ' ')} role removed`,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Failed to update roles',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setPendingAdditionalRole(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pendingAdditionalRole?.action === 'add' ? 'Add role' : 'Remove role'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
<AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}>
|
<AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@@ -848,6 +1044,67 @@ export default function MemberDetailPage() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<Dialog open={accessLinkOpen} onOpenChange={setAccessLinkOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
Access link ready
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{accessLink?.kind === 'magic_login'
|
||||||
|
? `Share this with ${user.name || user.email} via Slack, WhatsApp, or any channel that reaches them. Clicking it will sign them in directly (their existing password is preserved) and take them to their dashboard.`
|
||||||
|
: `Share this with ${user.name || user.email} via Slack, WhatsApp, or any channel that reaches them. They'll be walked through setting a password and onboarding.`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-md border bg-muted/40 p-3">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={accessLink?.url ?? ''}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className="font-mono text-xs bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Expires {accessLink?.expiresAt.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
|
||||||
|
{' · '}consumed on first successful login
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Don't paste this in a public channel. Anyone with the link
|
||||||
|
can sign in as this user until it's consumed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setAccessLinkOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCopyAccessLink}>
|
||||||
|
{linkCopied ? (
|
||||||
|
<>
|
||||||
|
<Check className="mr-2 h-4 w-4" />
|
||||||
|
Copied
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Copy link
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ import { useSession } from 'next-auth/react'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type Step = 'input' | 'preview' | 'sending' | 'complete'
|
type Step = 'input' | 'preview' | 'sending' | 'complete'
|
||||||
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||||
|
|
||||||
interface Assignment {
|
interface Assignment {
|
||||||
projectId: string
|
projectId: string
|
||||||
@@ -106,7 +106,6 @@ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|||||||
const ROLE_LABELS: Record<Role, string> = {
|
const ROLE_LABELS: Record<Role, string> = {
|
||||||
SUPER_ADMIN: 'Super Admin',
|
SUPER_ADMIN: 'Super Admin',
|
||||||
PROGRAM_ADMIN: 'Program Admin',
|
PROGRAM_ADMIN: 'Program Admin',
|
||||||
AWARD_MASTER: 'Award Master',
|
|
||||||
JURY_MEMBER: 'Jury Member',
|
JURY_MEMBER: 'Jury Member',
|
||||||
MENTOR: 'Mentor',
|
MENTOR: 'Mentor',
|
||||||
OBSERVER: 'Observer',
|
OBSERVER: 'Observer',
|
||||||
@@ -289,7 +288,7 @@ export default function MemberInvitePage() {
|
|||||||
const availableRoles = useMemo((): Role[] => {
|
const availableRoles = useMemo((): Role[] => {
|
||||||
const roles: Role[] = []
|
const roles: Role[] = []
|
||||||
if (isSuperAdmin) roles.push('SUPER_ADMIN')
|
if (isSuperAdmin) roles.push('SUPER_ADMIN')
|
||||||
if (isAdmin) roles.push('PROGRAM_ADMIN', 'AWARD_MASTER')
|
if (isAdmin) roles.push('PROGRAM_ADMIN')
|
||||||
roles.push('JURY_MEMBER', 'MENTOR', 'OBSERVER')
|
roles.push('JURY_MEMBER', 'MENTOR', 'OBSERVER')
|
||||||
return roles
|
return roles
|
||||||
}, [isSuperAdmin, isAdmin])
|
}, [isSuperAdmin, isAdmin])
|
||||||
@@ -423,13 +422,11 @@ export default function MemberInvitePage() {
|
|||||||
? 'SUPER_ADMIN'
|
? 'SUPER_ADMIN'
|
||||||
: rawRole === 'PROGRAM_ADMIN'
|
: rawRole === 'PROGRAM_ADMIN'
|
||||||
? 'PROGRAM_ADMIN'
|
? 'PROGRAM_ADMIN'
|
||||||
: rawRole === 'AWARD_MASTER'
|
: rawRole === 'MENTOR'
|
||||||
? 'AWARD_MASTER'
|
? 'MENTOR'
|
||||||
: rawRole === 'MENTOR'
|
: rawRole === 'OBSERVER'
|
||||||
? 'MENTOR'
|
? 'OBSERVER'
|
||||||
: rawRole === 'OBSERVER'
|
: 'JURY_MEMBER'
|
||||||
? 'OBSERVER'
|
|
||||||
: 'JURY_MEMBER'
|
|
||||||
const isValidFormat = emailRegex.test(email)
|
const isValidFormat = emailRegex.test(email)
|
||||||
const isDuplicate = email ? seenEmails.has(email) : false
|
const isDuplicate = email ? seenEmails.has(email) : false
|
||||||
const isUnauthorizedAdmin = role === 'SUPER_ADMIN' && !isSuperAdmin
|
const isUnauthorizedAdmin = role === 'SUPER_ADMIN' && !isSuperAdmin
|
||||||
@@ -910,7 +907,7 @@ export default function MemberInvitePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!sendInvitation && (
|
{!sendInvitation && (
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-blue-500/10 p-4 text-blue-700 dark:text-blue-400">
|
<div className="flex items-start gap-3 rounded-lg bg-blue-500/10 p-4 text-blue-700">
|
||||||
<MailX className="h-5 w-5 shrink-0 mt-0.5" />
|
<MailX className="h-5 w-5 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">No invitations will be sent</p>
|
<p className="font-medium">No invitations will be sent</p>
|
||||||
|
|||||||
@@ -1,5 +1,470 @@
|
|||||||
import { redirect } from 'next/navigation'
|
'use client'
|
||||||
|
|
||||||
export default function MentorsPage() {
|
import { useMemo, useState } from 'react'
|
||||||
redirect('/admin/members')
|
import Link from 'next/link'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { ArrowUpDown, GraduationCap, Search, Users } from 'lucide-react'
|
||||||
|
import { formatEnumLabel } from '@/lib/utils'
|
||||||
|
import { MentorDetailSheet } from '@/components/admin/mentor/mentor-detail-sheet'
|
||||||
|
|
||||||
|
type SortKey = 'name' | 'load' | 'capacity' | 'lastActivity'
|
||||||
|
|
||||||
|
function formatRelativePast(date: Date | string | null): string {
|
||||||
|
if (!date) return '—'
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date
|
||||||
|
const ms = Date.now() - d.getTime()
|
||||||
|
const days = Math.floor(ms / 86_400_000)
|
||||||
|
const hours = Math.floor(ms / 3_600_000)
|
||||||
|
if (days >= 1) return `${days}d ago`
|
||||||
|
if (hours >= 1) return `${hours}h ago`
|
||||||
|
const minutes = Math.floor(ms / 60_000)
|
||||||
|
return `${Math.max(0, minutes)}m ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<
|
||||||
|
'unassigned' | 'assigned' | 'active' | 'stalled',
|
||||||
|
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
|
||||||
|
> = {
|
||||||
|
unassigned: { label: 'Unassigned', variant: 'outline' },
|
||||||
|
assigned: { label: 'Assigned', variant: 'secondary' },
|
||||||
|
active: { label: 'Active', variant: 'default' },
|
||||||
|
stalled: { label: 'Stalled', variant: 'destructive' },
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mentor = {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
email: string
|
||||||
|
country: string | null
|
||||||
|
expertiseTags: string[]
|
||||||
|
currentAssignments: number
|
||||||
|
completedAssignments: number
|
||||||
|
maxAssignments: number | null
|
||||||
|
capacityRemaining: number | null
|
||||||
|
lastActivityAt: Date | string | null
|
||||||
|
activeTeams: { id: string; title: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function MentorListPanel() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>('load')
|
||||||
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||||
|
const [detailMentorId, setDetailMentorId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = trpc.mentor.getMentorPool.useQuery({})
|
||||||
|
|
||||||
|
const filtered = useMemo<Mentor[]>(() => {
|
||||||
|
if (!data) return []
|
||||||
|
const q = search.trim().toLowerCase()
|
||||||
|
let rows: Mentor[] = data.mentors
|
||||||
|
if (q) {
|
||||||
|
rows = rows.filter((m) =>
|
||||||
|
[m.name ?? '', m.email, m.country ?? '', ...m.expertiseTags]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(q),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rows = [...rows].sort((a, b) => {
|
||||||
|
let av: string | number = 0
|
||||||
|
let bv: string | number = 0
|
||||||
|
switch (sortKey) {
|
||||||
|
case 'name':
|
||||||
|
av = (a.name ?? '').toLowerCase()
|
||||||
|
bv = (b.name ?? '').toLowerCase()
|
||||||
|
break
|
||||||
|
case 'load':
|
||||||
|
av = a.currentAssignments
|
||||||
|
bv = b.currentAssignments
|
||||||
|
break
|
||||||
|
case 'capacity':
|
||||||
|
av = a.capacityRemaining ?? Number.MAX_SAFE_INTEGER
|
||||||
|
bv = b.capacityRemaining ?? Number.MAX_SAFE_INTEGER
|
||||||
|
break
|
||||||
|
case 'lastActivity':
|
||||||
|
av = a.lastActivityAt ? new Date(a.lastActivityAt).getTime() : 0
|
||||||
|
bv = b.lastActivityAt ? new Date(b.lastActivityAt).getTime() : 0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (av < bv) return sortDir === 'asc' ? -1 : 1
|
||||||
|
if (av > bv) return sortDir === 'asc' ? 1 : -1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
}, [data, search, sortKey, sortDir])
|
||||||
|
|
||||||
|
const toggleSort = (key: SortKey) => {
|
||||||
|
if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||||
|
else {
|
||||||
|
setSortKey(key)
|
||||||
|
setSortDir(key === 'name' ? 'asc' : 'desc')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortHeader = ({
|
||||||
|
k,
|
||||||
|
children,
|
||||||
|
align = 'left',
|
||||||
|
}: {
|
||||||
|
k: SortKey
|
||||||
|
children: React.ReactNode
|
||||||
|
align?: 'left' | 'right'
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSort(k)}
|
||||||
|
className={`flex items-center gap-1 text-sm font-medium ${align === 'right' ? 'ml-auto' : ''}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ArrowUpDown
|
||||||
|
className={`h-3 w-3 ${sortKey === k ? 'text-foreground' : 'text-muted-foreground/50'}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-4">
|
||||||
|
<CardTitle className="text-base">Mentor list</CardTitle>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by name, email, country, or expertise tag…"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||||
|
{search ? 'No matching mentors.' : 'No mentors yet.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>
|
||||||
|
<SortHeader k="name">Mentor</SortHeader>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Expertise</TableHead>
|
||||||
|
<TableHead>Country</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
<SortHeader k="capacity" align="right">
|
||||||
|
Capacity
|
||||||
|
</SortHeader>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<SortHeader k="lastActivity">Last activity</SortHeader>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<SortHeader k="load">Teams</SortHeader>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map((m) => (
|
||||||
|
<TableRow
|
||||||
|
key={m.id}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setDetailMentorId(m.id)}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{m.name ?? 'Unnamed'}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">{m.email}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{m.expertiseTags.slice(0, 4).map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-xs">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{m.expertiseTags.length > 4 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{m.expertiseTags.length - 4}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{m.country ?? '—'}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm tabular-nums">
|
||||||
|
{m.capacityRemaining != null ? m.capacityRemaining : '∞'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{formatRelativePast(m.lastActivityAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{m.activeTeams.length === 0 ? (
|
||||||
|
<span className="text-muted-foreground text-xs">—</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{m.activeTeams.slice(0, 2).map((t) => (
|
||||||
|
<Badge
|
||||||
|
key={t.id}
|
||||||
|
variant="outline"
|
||||||
|
className="max-w-[12rem] truncate text-xs"
|
||||||
|
title={t.title}
|
||||||
|
>
|
||||||
|
{t.title}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{m.activeTeams.length > 2 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{m.activeTeams.length - 2}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<MentorDetailSheet
|
||||||
|
mentorId={detailMentorId}
|
||||||
|
open={!!detailMentorId}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) setDetailMentorId(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | 'unassigned' | 'assigned' | 'active' | 'stalled'
|
||||||
|
|
||||||
|
function MenteeActivityPanel() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||||
|
|
||||||
|
const { data, isLoading } = trpc.mentor.getMenteeActivity.useQuery({})
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!data) return []
|
||||||
|
const q = search.trim().toLowerCase()
|
||||||
|
return data.rows.filter((r) => {
|
||||||
|
if (statusFilter !== 'all' && r.status !== statusFilter) return false
|
||||||
|
if (!q) return true
|
||||||
|
const hay = [
|
||||||
|
r.project.title,
|
||||||
|
r.project.country ?? '',
|
||||||
|
r.teamLead?.name ?? '',
|
||||||
|
r.teamLead?.email ?? '',
|
||||||
|
r.mentor?.name ?? '',
|
||||||
|
r.mentor?.email ?? '',
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
return hay.includes(q)
|
||||||
|
})
|
||||||
|
}, [data, search, statusFilter])
|
||||||
|
|
||||||
|
const totals = data?.totals ?? { unassigned: 0, assigned: 0, active: 0, stalled: 0 }
|
||||||
|
|
||||||
|
const StatusPill = ({ value, label, count }: { value: StatusFilter; label: string; count: number }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter(value)}
|
||||||
|
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||||
|
statusFilter === value
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-background hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label} <span className="tabular-nums opacity-80">({count})</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<CardTitle className="text-base">Mentee teams</CardTitle>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<StatusPill
|
||||||
|
value="all"
|
||||||
|
label="All"
|
||||||
|
count={
|
||||||
|
totals.unassigned + totals.assigned + totals.active + totals.stalled
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatusPill value="unassigned" label="Unassigned" count={totals.unassigned} />
|
||||||
|
<StatusPill value="assigned" label="Assigned" count={totals.assigned} />
|
||||||
|
<StatusPill value="active" label="Active" count={totals.active} />
|
||||||
|
<StatusPill value="stalled" label="Stalled" count={totals.stalled} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by project, team lead, or mentor…"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||||
|
{search || statusFilter !== 'all'
|
||||||
|
? 'No matching teams.'
|
||||||
|
: 'No teams have requested mentorship yet.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Project</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Mentor</TableHead>
|
||||||
|
<TableHead className="text-right">Messages</TableHead>
|
||||||
|
<TableHead className="text-right">Files</TableHead>
|
||||||
|
<TableHead>Last activity</TableHead>
|
||||||
|
<TableHead></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map((r) => {
|
||||||
|
const badge = STATUS_BADGE[r.status]
|
||||||
|
return (
|
||||||
|
<TableRow key={r.project.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{r.project.title}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{r.teamLead?.name ?? r.teamLead?.email ?? '—'}
|
||||||
|
{r.project.oceanIssue && (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
{formatEnumLabel(r.project.oceanIssue)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={badge.variant} className="text-xs">
|
||||||
|
{badge.label}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{r.mentor ? (
|
||||||
|
<div className="text-sm">
|
||||||
|
<div>{r.mentor.name ?? r.mentor.email}</div>
|
||||||
|
<div className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
{r.mentor.currentLoad}
|
||||||
|
{r.mentor.maxAssignments != null
|
||||||
|
? `/${r.mentor.maxAssignments}`
|
||||||
|
: ''}
|
||||||
|
{' load'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{r.messageCount}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{r.fileCount}</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{formatRelativePast(r.lastActivityAt as unknown as Date | null)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<Link href={`/admin/projects/${r.project.id}/mentor`}>
|
||||||
|
{r.mentor ? 'Open' : 'Assign'}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MentorsListPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Mentors</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage the mentor pool and track mentee teams across the program.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/admin/members">
|
||||||
|
<Users className="mr-2 h-4 w-4" />
|
||||||
|
Manage Members
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="mentors" className="space-y-6">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="mentors">
|
||||||
|
<GraduationCap className="mr-2 h-4 w-4" /> Mentors
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="mentees">
|
||||||
|
<Users className="mr-2 h-4 w-4" /> Mentees & Activity
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="mentors">
|
||||||
|
<MentorListPanel />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="mentees">
|
||||||
|
<MenteeActivityPanel />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -75,7 +75,9 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
Plus,
|
Plus,
|
||||||
X,
|
X,
|
||||||
|
Mail,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { ProjectEmailDialog } from '@/components/admin/project-email-dialog'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||||
@@ -161,6 +163,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
// State for remove member confirmation
|
// State for remove member confirmation
|
||||||
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
||||||
|
const [emailDialogOpen, setEmailDialogOpen] = useState(false)
|
||||||
|
|
||||||
const addTeamMember = trpc.project.addTeamMember.useMutation({
|
const addTeamMember = trpc.project.addTeamMember.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -269,14 +272,29 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="outline" asChild>
|
<div className="flex items-center gap-2">
|
||||||
<Link href={`/admin/projects/${projectId}/edit`}>
|
<Button variant="outline" onClick={() => setEmailDialogOpen(true)}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Email Team
|
||||||
</Link>
|
</Button>
|
||||||
</Button>
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={`/admin/projects/${projectId}/edit`}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{project && (
|
||||||
|
<ProjectEmailDialog
|
||||||
|
open={emailDialogOpen}
|
||||||
|
onClose={() => setEmailDialogOpen(false)}
|
||||||
|
projectId={project.id}
|
||||||
|
projectTitle={project.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ export default function BulkUploadPage() {
|
|||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.project.id}
|
key={row.project.id}
|
||||||
className={row.isComplete ? 'bg-green-50/50 dark:bg-green-950/10' : ''}
|
className={row.isComplete ? 'bg-green-50/50' : ''}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -53,15 +53,15 @@ type TeamMemberEntry = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_COLORS: Record<string, string> = {
|
const ROLE_COLORS: Record<string, string> = {
|
||||||
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
LEAD: 'bg-red-100 text-red-700',
|
||||||
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400',
|
MEMBER: 'bg-teal-100 text-teal-700',
|
||||||
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
ADVISOR: 'bg-blue-100 text-blue-700',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_AVATAR_COLORS: Record<string, string> = {
|
const ROLE_AVATAR_COLORS: Record<string, string> = {
|
||||||
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
LEAD: 'bg-red-100 text-red-700',
|
||||||
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
|
MEMBER: 'bg-teal-100 text-teal-700',
|
||||||
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
ADVISOR: 'bg-blue-100 text-blue-700',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
|||||||
@@ -679,7 +679,7 @@ export default function ProjectsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setAiTagDialogOpen(true)}
|
onClick={() => setAiTagDialogOpen(true)}
|
||||||
className={taggingInProgress ? 'border-amber-400 bg-amber-50 dark:bg-amber-950/20' : ''}
|
className={taggingInProgress ? 'border-amber-400 bg-amber-50' : ''}
|
||||||
>
|
>
|
||||||
{taggingInProgress ? (
|
{taggingInProgress ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-amber-600" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin text-amber-600" />
|
||||||
@@ -1716,15 +1716,15 @@ export default function ProjectsPage() {
|
|||||||
<div className="space-y-6 py-4">
|
<div className="space-y-6 py-4">
|
||||||
{/* Progress Indicator (when running) */}
|
{/* Progress Indicator (when running) */}
|
||||||
{taggingInProgress && (
|
{taggingInProgress && (
|
||||||
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
|
<div className="p-4 rounded-lg bg-blue-50 border border-blue-200">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
|
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-medium text-blue-900 dark:text-blue-100">
|
<p className="font-medium text-blue-900">
|
||||||
AI Tagging in Progress
|
AI Tagging in Progress
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
<p className="text-sm text-blue-700">
|
||||||
{jobStatus?.status === 'PENDING'
|
{jobStatus?.status === 'PENDING'
|
||||||
? 'Initializing...'
|
? 'Initializing...'
|
||||||
: `Processing ${jobStatus?.totalProjects || 0} projects with AI...`}
|
: `Processing ${jobStatus?.totalProjects || 0} projects with AI...`}
|
||||||
@@ -1739,12 +1739,12 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-blue-700 dark:text-blue-300">
|
<span className="text-blue-700">
|
||||||
{jobStatus?.processedCount || 0} of {jobStatus?.totalProjects || '?'} projects processed
|
{jobStatus?.processedCount || 0} of {jobStatus?.totalProjects || '?'} projects processed
|
||||||
{jobStatus?.taggedCount ? ` (${jobStatus.taggedCount} tagged)` : ''}
|
{jobStatus?.taggedCount ? ` (${jobStatus.taggedCount} tagged)` : ''}
|
||||||
</span>
|
</span>
|
||||||
{jobStatus && jobStatus.totalProjects > 0 && (
|
{jobStatus && jobStatus.totalProjects > 0 && (
|
||||||
<span className="font-medium text-blue-900 dark:text-blue-100">
|
<span className="font-medium text-blue-900">
|
||||||
{taggingProgressPercent}%
|
{taggingProgressPercent}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1767,9 +1767,9 @@ export default function ProjectsPage() {
|
|||||||
{taggingResult && !taggingInProgress && (
|
{taggingResult && !taggingInProgress && (
|
||||||
<div className={`p-4 rounded-lg border ${
|
<div className={`p-4 rounded-lg border ${
|
||||||
taggingResult.failed > 0
|
taggingResult.failed > 0
|
||||||
? 'bg-amber-50 dark:bg-amber-950/20 border-amber-200 dark:border-amber-900'
|
? 'bg-amber-50 border-amber-200'
|
||||||
: taggingResult.processed > 0
|
: taggingResult.processed > 0
|
||||||
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900'
|
? 'bg-green-50 border-green-200'
|
||||||
: 'bg-muted border-border'
|
: 'bg-muted border-border'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
@@ -1804,12 +1804,12 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{taggingResult.errors.length > 0 && (
|
{taggingResult.errors.length > 0 && (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
<p className="text-sm font-medium text-amber-700">
|
||||||
{taggingResult.errors.length} project{taggingResult.errors.length > 1 ? 's' : ''} failed:
|
{taggingResult.errors.length} project{taggingResult.errors.length > 1 ? 's' : ''} failed:
|
||||||
</p>
|
</p>
|
||||||
<div className="max-h-32 overflow-y-auto rounded bg-background/50 p-2 text-xs space-y-1">
|
<div className="max-h-32 overflow-y-auto rounded bg-background/50 p-2 text-xs space-y-1">
|
||||||
{taggingResult.errors.map((error, i) => (
|
{taggingResult.errors.map((error, i) => (
|
||||||
<p key={i} className="text-amber-700 dark:text-amber-300">
|
<p key={i} className="text-amber-700">
|
||||||
• {error}
|
• {error}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
|
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||||
import {
|
import {
|
||||||
ScoreDistributionChart,
|
ScoreDistributionChart,
|
||||||
EvaluationTimelineChart,
|
EvaluationTimelineChart,
|
||||||
@@ -91,6 +92,17 @@ function ReportsOverview({ scope }: { scope: string | null }) {
|
|||||||
{ enabled: hasScope }
|
{ enabled: hasScope }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Applicant nationality breakdown — always runs (scope optional;
|
||||||
|
// empty scope = global view across all programs).
|
||||||
|
const { data: nationalityStats, isLoading: nationalityLoading } =
|
||||||
|
trpc.analytics.getApplicantNationalities.useQuery(scopeInput)
|
||||||
|
|
||||||
|
const nationalityScopeLabel = scopeInput.roundId
|
||||||
|
? 'in this round'
|
||||||
|
: scopeInput.programId
|
||||||
|
? `in ${programs?.find((p) => p.id === scopeInput.programId)?.year ?? ''} Edition`
|
||||||
|
: 'across all programs'
|
||||||
|
|
||||||
if (isLoading || statsLoading) {
|
if (isLoading || statsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -198,6 +210,13 @@ function ReportsOverview({ scope }: { scope: string | null }) {
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Applicant Nationalities */}
|
||||||
|
<ApplicantNationalitiesCard
|
||||||
|
data={nationalityStats}
|
||||||
|
loading={nationalityLoading}
|
||||||
|
scopeLabel={nationalityScopeLabel}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Score Distribution (if any evaluations exist) */}
|
{/* Score Distribution (if any evaluations exist) */}
|
||||||
{dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && (
|
{dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -500,6 +519,140 @@ function ReportsOverview({ scope }: { scope: string | null }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NationalityStats = {
|
||||||
|
total: number
|
||||||
|
declared: number
|
||||||
|
notDeclared: number
|
||||||
|
byCountry: Array<{ country: string; count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApplicantNationalitiesCard({
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
scopeLabel,
|
||||||
|
}: {
|
||||||
|
data: NationalityStats | undefined
|
||||||
|
loading: boolean
|
||||||
|
scopeLabel: string
|
||||||
|
}) {
|
||||||
|
const [showAll, setShowAll] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||||
|
<Globe className="h-4 w-4 text-violet-600" />
|
||||||
|
</div>
|
||||||
|
Applicant Nationalities
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Self-declared nationality of team members on projects {scopeLabel}.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
) : !data || data.total === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<Globe className="h-10 w-10 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
No applicants in this scope.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : data.declared === 0 ? (
|
||||||
|
<>
|
||||||
|
<NationalitySummary declared={data.declared} notDeclared={data.notDeclared} />
|
||||||
|
<div className="mt-4 flex flex-col items-center justify-center rounded-lg border border-dashed py-8 text-center">
|
||||||
|
<Globe className="h-8 w-8 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
No nationality data yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<NationalitySummary declared={data.declared} notDeclared={data.notDeclared} />
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Country</TableHead>
|
||||||
|
<TableHead className="text-right w-32">Applicants</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(showAll ? data.byCountry : data.byCountry.slice(0, 10)).map((row) => {
|
||||||
|
const name = getCountryName(row.country)
|
||||||
|
const flag = getCountryFlag(row.country)
|
||||||
|
return (
|
||||||
|
<TableRow key={row.country}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
{flag && <span aria-hidden>{flag}</span>}
|
||||||
|
<span>{name}</span>
|
||||||
|
{name !== row.country && (
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{row.country}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Badge variant="secondary" className="tabular-nums">
|
||||||
|
{row.count}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.byCountry.length > 10 && (
|
||||||
|
<div className="mt-3 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAll((v) => !v)}
|
||||||
|
className="gap-1 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{showAll
|
||||||
|
? 'Show top 10'
|
||||||
|
: `Show all (${data.byCountry.length} countries)`}
|
||||||
|
<ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NationalitySummary({ declared, notDeclared }: { declared: number; notDeclared: number }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg border p-3 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">Declared</p>
|
||||||
|
<p className="text-2xl font-bold tabular-nums">{declared}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-3 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">Not declared</p>
|
||||||
|
<p className="text-2xl font-bold tabular-nums text-muted-foreground">
|
||||||
|
{notDeclared}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Parse selection value: "all:programId" for edition-wide, or roundId
|
// Parse selection value: "all:programId" for edition-wide, or roundId
|
||||||
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
||||||
if (!value) return {}
|
if (!value) return {}
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||||
{isReadOnly ? 'Submitted Evaluation' : 'Fill In Evaluation'}
|
{isReadOnly ? 'Submitted Evaluation' : 'Fill In Evaluation'}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
@@ -404,8 +404,8 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={
|
className={
|
||||||
project.competitionCategory === 'STARTUP'
|
project.competitionCategory === 'STARTUP'
|
||||||
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
? 'bg-violet-100 text-violet-700 border-violet-200'
|
||||||
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
|
: 'bg-sky-100 text-sky-700 border-sky-200'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||||
@@ -415,7 +415,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-l-4 border-l-amber-500 bg-amber-50/40 dark:bg-amber-950/10">
|
<Card className="border-l-4 border-l-amber-500 bg-amber-50/40">
|
||||||
<CardContent className="flex items-start gap-3 p-4">
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
<UserCheck className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
<UserCheck className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
||||||
<div className="flex-1 text-sm">
|
<div className="flex-1 text-sm">
|
||||||
@@ -431,7 +431,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{isReadOnly && (
|
{isReadOnly && (
|
||||||
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50 dark:bg-blue-950/20">
|
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50">
|
||||||
<CardContent className="flex items-start gap-3 p-4">
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
|
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -446,7 +446,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasCOI && !isReadOnly && (
|
{hasCOI && !isReadOnly && (
|
||||||
<Card className="border-l-4 border-l-red-500 bg-red-50/40 dark:bg-red-950/10">
|
<Card className="border-l-4 border-l-red-500 bg-red-50/40">
|
||||||
<CardContent className="flex items-start gap-3 p-4">
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
<Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
|
<Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
|
||||||
<div className="flex-1 text-sm">
|
<div className="flex-1 text-sm">
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function AdminJurorProxyEvaluatePage({ params: paramsPromise }: P
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||||
Proxy Evaluations
|
Proxy Evaluations
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
@@ -205,8 +205,8 @@ function AssignmentRow({ roundId, userId, assignment, mode }: AssignmentRowProps
|
|||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0',
|
'shrink-0',
|
||||||
project.competitionCategory === 'STARTUP'
|
project.competitionCategory === 'STARTUP'
|
||||||
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
? 'bg-violet-100 text-violet-700 border-violet-200'
|
||||||
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300',
|
: 'bg-sky-100 text-sky-700 border-sky-200',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ import { ProjectStatesTable } from '@/components/admin/round/project-states-tabl
|
|||||||
// SubmissionWindowManager removed — round dates + file requirements in Config are sufficient
|
// SubmissionWindowManager removed — round dates + file requirements in Config are sufficient
|
||||||
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
|
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
|
||||||
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
||||||
|
import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview'
|
||||||
|
import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table'
|
||||||
|
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
|
||||||
|
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
|
||||||
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
|
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
|
||||||
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
||||||
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
||||||
@@ -145,6 +149,95 @@ const stateColors: Record<string, string> = Object.fromEntries(
|
|||||||
Object.entries(projectStateConfig).map(([k, v]) => [k, v.bg])
|
Object.entries(projectStateConfig).map(([k, v]) => [k, v.bg])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Mentoring round: Auto-fill remaining toolbar (Projects tab)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function MentoringBulkAssignToolbar({
|
||||||
|
roundId,
|
||||||
|
configJson,
|
||||||
|
}: {
|
||||||
|
roundId: string
|
||||||
|
configJson: Record<string, unknown>
|
||||||
|
}) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const eligibility = (configJson.eligibility as string) ?? 'requested_only'
|
||||||
|
const isAdminSelected = eligibility === 'admin_selected'
|
||||||
|
|
||||||
|
const { data: pending } = trpc.round.getProjectsNeedingMentor.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ enabled: !isAdminSelected, refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
const count = pending?.count ?? 0
|
||||||
|
const eligibleTotal = pending?.eligibleTotal ?? 0
|
||||||
|
const mentorPoolSize = pending?.mentorPoolSize ?? 0
|
||||||
|
const hasNoMentors = mentorPoolSize === 0
|
||||||
|
const hasNoEligible = eligibleTotal === 0
|
||||||
|
|
||||||
|
const bulk = trpc.mentor.autoAssignBulkForRound.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast.success(result.message)
|
||||||
|
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
|
||||||
|
utils.project.list.invalidate()
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const eligibilityLabel = eligibility.replace('_', ' ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between rounded-md border bg-muted/30 px-4 py-2.5">
|
||||||
|
<div className="text-sm">
|
||||||
|
{isAdminSelected ? (
|
||||||
|
<>
|
||||||
|
<span className="font-medium">Eligibility: admin-selected</span>
|
||||||
|
<span className="text-muted-foreground ml-2">
|
||||||
|
— auto-fill is disabled. Assign each project manually.
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : hasNoMentors ? (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
No mentors in the pool yet —{' '}
|
||||||
|
<Link
|
||||||
|
href="/admin/members?tab=mentors"
|
||||||
|
className="text-foreground underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
add mentors
|
||||||
|
</Link>{' '}
|
||||||
|
before auto-filling.
|
||||||
|
</span>
|
||||||
|
) : hasNoEligible ? (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
No projects are eligible for mentorship in this round (
|
||||||
|
{eligibilityLabel}).
|
||||||
|
</span>
|
||||||
|
) : count > 0 ? (
|
||||||
|
<>
|
||||||
|
<span className="font-medium">{count}</span>{' '}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
of {eligibleTotal} project{eligibleTotal === 1 ? '' : 's'} still
|
||||||
|
needs a mentor ({eligibilityLabel})
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
All {eligibleTotal} eligible project{eligibleTotal === 1 ? '' : 's'}{' '}
|
||||||
|
already have a mentor.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => bulk.mutate({ roundId })}
|
||||||
|
disabled={isAdminSelected || count === 0 || hasNoMentors || bulk.isPending}
|
||||||
|
>
|
||||||
|
{bulk.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
Auto-fill remaining
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// Main Page Component
|
// Main Page Component
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -514,6 +607,16 @@ export default function RoundDetailPage() {
|
|||||||
|
|
||||||
const isFiltering = round?.roundType === 'FILTERING'
|
const isFiltering = round?.roundType === 'FILTERING'
|
||||||
const isEvaluation = round?.roundType === 'EVALUATION'
|
const isEvaluation = round?.roundType === 'EVALUATION'
|
||||||
|
const isMentoring = round?.roundType === 'MENTORING'
|
||||||
|
const isGrandFinale = round?.roundType === 'LIVE_FINAL'
|
||||||
|
|
||||||
|
// Mentor pool size — used by Round Details panel below to replace the
|
||||||
|
// always-empty "Jury Group" row on MENTORING rounds.
|
||||||
|
const { data: mentorPool } = trpc.mentor.getMentorPool.useQuery(
|
||||||
|
{},
|
||||||
|
{ enabled: isMentoring },
|
||||||
|
)
|
||||||
|
const mentorPoolSize = mentorPool?.poolSize ?? 0
|
||||||
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
|
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
|
||||||
const hasAwards = roundAwards.length > 0
|
const hasAwards = roundAwards.length > 0
|
||||||
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
|
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
|
||||||
@@ -589,7 +692,8 @@ export default function RoundDetailPage() {
|
|||||||
action: undefined as Route | undefined,
|
action: undefined as Route | undefined,
|
||||||
actionLabel: undefined as string | undefined,
|
actionLabel: undefined as string | undefined,
|
||||||
},
|
},
|
||||||
...((isEvaluation && !(config.requireDocumentUpload as boolean))
|
...((isEvaluation && !(config.requireDocumentUpload as boolean)) ||
|
||||||
|
(isMentoring && !(config.filePromotionEnabled as boolean) && !config.promotionTargetWindowId)
|
||||||
? []
|
? []
|
||||||
: [{
|
: [{
|
||||||
label: 'File requirements set',
|
label: 'File requirements set',
|
||||||
@@ -1161,17 +1265,32 @@ export default function RoundDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Project Management</p>
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Project Management</p>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Link href={poolLink}>
|
{isMentoring ? (
|
||||||
<button className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full">
|
<button
|
||||||
|
onClick={() => setActiveTab('projects')}
|
||||||
|
className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full"
|
||||||
|
>
|
||||||
<Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
|
<Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Assign Projects</p>
|
<p className="text-sm font-medium">Assign Projects</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
Add projects from the pool to this round
|
Open the Projects tab to add or auto-fill teams in this round
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
) : (
|
||||||
|
<Link href={poolLink}>
|
||||||
|
<button className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full">
|
||||||
|
<Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Assign Projects</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Add projects from the pool to this round
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('projects')}
|
onClick={() => setActiveTab('projects')}
|
||||||
@@ -1400,6 +1519,17 @@ export default function RoundDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mentoring-specific stats \u2014 only on MENTORING rounds */}
|
||||||
|
{isMentoring && <MentoringRoundOverview roundId={roundId} />}
|
||||||
|
|
||||||
|
{/* Grand-finale logistics \u2014 only on LIVE_FINAL rounds */}
|
||||||
|
{isGrandFinale && programId && (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<FinalistSlotsCard programId={programId} />
|
||||||
|
<WaitlistCard programId={programId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Round Info + Project Breakdown */}
|
{/* Round Info + Project Breakdown */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<AnimatedCard index={2}>
|
<AnimatedCard index={2}>
|
||||||
@@ -1413,7 +1543,9 @@ export default function RoundDetailPage() {
|
|||||||
{ label: 'Status', value: <span className="font-medium">{statusCfg.label}</span> },
|
{ label: 'Status', value: <span className="font-medium">{statusCfg.label}</span> },
|
||||||
{ label: 'Position', value: <span className="font-medium">{`Round ${(round.sortOrder ?? 0) + 1}${competition?.rounds ? ` of ${competition.rounds.length}` : ''}`}</span> },
|
{ label: 'Position', value: <span className="font-medium">{`Round ${(round.sortOrder ?? 0) + 1}${competition?.rounds ? ` of ${competition.rounds.length}` : ''}`}</span> },
|
||||||
...(round.purposeKey ? [{ label: 'Purpose', value: <span className="font-medium">{round.purposeKey}</span> }] : []),
|
...(round.purposeKey ? [{ label: 'Purpose', value: <span className="font-medium">{round.purposeKey}</span> }] : []),
|
||||||
{ label: 'Jury Group', value: <span className="font-medium">{juryGroup ? juryGroup.name : '\u2014'}</span> },
|
isMentoring
|
||||||
|
? { label: 'Mentor Pool', value: <Link href="/admin/mentors" className="font-medium hover:underline">{mentorPoolSize} member{mentorPoolSize === 1 ? '' : 's'}</Link> }
|
||||||
|
: { label: 'Jury Group', value: <span className="font-medium">{juryGroup ? juryGroup.name : '\u2014'}</span> },
|
||||||
{ label: 'Opens', value: <span className="font-medium">{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}</span> },
|
{ label: 'Opens', value: <span className="font-medium">{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}</span> },
|
||||||
{ label: 'Closes', value: <span className="font-medium">{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}</span> },
|
{ label: 'Closes', value: <span className="font-medium">{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}</span> },
|
||||||
].map((row, i) => (
|
].map((row, i) => (
|
||||||
@@ -1475,17 +1607,30 @@ export default function RoundDetailPage() {
|
|||||||
|
|
||||||
{/* ═══════════ PROJECTS TAB ═══════════ */}
|
{/* ═══════════ PROJECTS TAB ═══════════ */}
|
||||||
<TabsContent value="projects" className="space-y-4">
|
<TabsContent value="projects" className="space-y-4">
|
||||||
<ProjectStatesTable
|
{isMentoring && (
|
||||||
competitionId={competitionId}
|
<>
|
||||||
roundId={roundId}
|
<MentoringBulkAssignToolbar roundId={roundId} configJson={config} />
|
||||||
roundStatus={round?.status}
|
<MentoringProjectsTable
|
||||||
competitionRounds={competition?.rounds}
|
roundId={roundId}
|
||||||
currentSortOrder={round?.sortOrder}
|
competitionId={competitionId}
|
||||||
onAssignProjects={() => {
|
competitionRounds={competition?.rounds}
|
||||||
setActiveTab('assignments')
|
currentSortOrder={round?.sortOrder}
|
||||||
setTimeout(() => setPreviewSheetOpen(true), 100)
|
/>
|
||||||
}}
|
</>
|
||||||
/>
|
)}
|
||||||
|
{!isMentoring && (
|
||||||
|
<ProjectStatesTable
|
||||||
|
competitionId={competitionId}
|
||||||
|
roundId={roundId}
|
||||||
|
roundStatus={round?.status}
|
||||||
|
competitionRounds={competition?.rounds}
|
||||||
|
currentSortOrder={round?.sortOrder}
|
||||||
|
onAssignProjects={() => {
|
||||||
|
setActiveTab('assignments')
|
||||||
|
setTimeout(() => setPreviewSheetOpen(true), 100)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ═══════════ FILTERING TAB ═══════════ */}
|
{/* ═══════════ FILTERING TAB ═══════════ */}
|
||||||
@@ -1977,39 +2122,39 @@ export default function RoundDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{aiAssignmentMutation.isPending && (
|
{aiAssignmentMutation.isPending && (
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-violet-50 border border-violet-200 dark:bg-violet-950/20 dark:border-violet-800">
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-violet-50 border border-violet-200">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="h-8 w-8 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
|
<div className="h-8 w-8 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-violet-800 dark:text-violet-200">AI is analyzing projects and jurors...</p>
|
<p className="text-sm font-medium text-violet-800">AI is analyzing projects and jurors...</p>
|
||||||
<p className="text-xs text-violet-600 dark:text-violet-400">
|
<p className="text-xs text-violet-600">
|
||||||
Matching expertise, reviewing bios, and balancing workloads
|
Matching expertise, reviewing bios, and balancing workloads
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{aiAssignmentMutation.error && !aiAssignmentMutation.isPending && (
|
{aiAssignmentMutation.error && !aiAssignmentMutation.isPending && (
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-red-50 border border-red-200 dark:bg-red-950/20 dark:border-red-800">
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-red-50 border border-red-200">
|
||||||
<AlertTriangle className="h-5 w-5 text-red-600 shrink-0" />
|
<AlertTriangle className="h-5 w-5 text-red-600 shrink-0" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-red-800 dark:text-red-200">
|
<p className="text-sm font-medium text-red-800">
|
||||||
AI generation failed
|
AI generation failed
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-red-600 dark:text-red-400">
|
<p className="text-xs text-red-600">
|
||||||
{aiAssignmentMutation.error.message}
|
{aiAssignmentMutation.error.message}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
|
{aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 border border-emerald-200 dark:bg-emerald-950/20 dark:border-emerald-800">
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 border border-emerald-200">
|
||||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
|
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-200">
|
<p className="text-sm font-medium text-emerald-800">
|
||||||
{aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated
|
{aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-emerald-600 dark:text-emerald-400">
|
<p className="text-xs text-emerald-600">
|
||||||
{aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects
|
{aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects
|
||||||
{aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'}
|
{aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'}
|
||||||
</p>
|
</p>
|
||||||
@@ -2198,7 +2343,8 @@ export default function RoundDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* General Round Settings */}
|
{/* General Round Settings — hidden on MENTORING rounds (no advancement targets apply) */}
|
||||||
|
{!isMentoring && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="border-b">
|
<CardHeader className="border-b">
|
||||||
<ConfigSectionHeader
|
<ConfigSectionHeader
|
||||||
@@ -2321,6 +2467,7 @@ export default function RoundDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Round-type-specific config */}
|
{/* Round-type-specific config */}
|
||||||
<RoundConfigForm
|
<RoundConfigForm
|
||||||
@@ -2489,9 +2636,9 @@ export default function RoundDetailPage() {
|
|||||||
|
|
||||||
{/* Autosave error bar — only shows when save fails */}
|
{/* Autosave error bar — only shows when save fails */}
|
||||||
{autosaveStatus === 'error' && (
|
{autosaveStatus === 'error' && (
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-50 border-t bg-red-50 dark:bg-red-950/50 shadow-[0_-4px_12px_rgba(0,0,0,0.1)]">
|
<div className="fixed bottom-0 left-0 right-0 z-50 border-t bg-red-50 shadow-[0_-4px_12px_rgba(0,0,0,0.1)]">
|
||||||
<div className="container flex items-center justify-between py-3 px-4 max-w-5xl mx-auto">
|
<div className="container flex items-center justify-between py-3 px-4 max-w-5xl mx-auto">
|
||||||
<div className="flex items-center gap-2 text-sm text-red-700 dark:text-red-300">
|
<div className="flex items-center gap-2 text-sm text-red-700">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<span>Auto-save failed</span>
|
<span>Auto-save failed</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma'
|
|||||||
import { requireRole } from '@/lib/auth-redirect'
|
import { requireRole } from '@/lib/auth-redirect'
|
||||||
import { AdminSidebar } from '@/components/layouts/admin-sidebar'
|
import { AdminSidebar } from '@/components/layouts/admin-sidebar'
|
||||||
import { AdminEditionWrapper } from '@/components/layouts/admin-edition-wrapper'
|
import { AdminEditionWrapper } from '@/components/layouts/admin-edition-wrapper'
|
||||||
|
import { RoleSwitcherPill } from '@/components/layouts/role-switcher'
|
||||||
|
|
||||||
export default async function AdminLayout({
|
export default async function AdminLayout({
|
||||||
children,
|
children,
|
||||||
@@ -34,6 +35,12 @@ export default async function AdminLayout({
|
|||||||
<main className="lg:pl-64">
|
<main className="lg:pl-64">
|
||||||
{/* Spacer for mobile header */}
|
{/* Spacer for mobile header */}
|
||||||
<div className="h-16 lg:hidden" />
|
<div className="h-16 lg:hidden" />
|
||||||
|
{/* Top-bar — hosts the RoleSwitcherPill so multi-role admins
|
||||||
|
can switch dashboards from the same screen position used on
|
||||||
|
every other layout. Pill auto-hides for single-role users. */}
|
||||||
|
<div className="sticky top-0 z-30 flex h-12 items-center justify-end gap-2 border-b bg-card/80 backdrop-blur px-4">
|
||||||
|
<RoleSwitcherPill currentBasePath="/admin" />
|
||||||
|
</div>
|
||||||
<div className="container-app py-6 lg:py-8">{children}</div>
|
<div className="container-app py-6 lg:py-8">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,69 +8,72 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Progress } from '@/components/ui/progress'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import {
|
import { Star, MessageSquare, Trophy, Vote, ShieldCheck } from 'lucide-react'
|
||||||
Star,
|
|
||||||
MessageSquare,
|
|
||||||
Trophy,
|
|
||||||
Vote,
|
|
||||||
TrendingUp,
|
|
||||||
BarChart3,
|
|
||||||
Award,
|
|
||||||
ShieldCheck,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type Criterion = {
|
||||||
|
id?: string
|
||||||
|
type?: string
|
||||||
|
label?: string
|
||||||
|
name?: string
|
||||||
|
scale?: string
|
||||||
|
maxScore?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Evaluation = {
|
||||||
|
id: string
|
||||||
|
submittedAt: Date | null
|
||||||
|
globalScore: number | null
|
||||||
|
criterionScores: unknown
|
||||||
|
feedbackText: string | null
|
||||||
|
criteria: unknown
|
||||||
|
}
|
||||||
|
|
||||||
type EvaluationRound = {
|
type EvaluationRound = {
|
||||||
roundId: string
|
roundId: string
|
||||||
roundName: string
|
roundName: string
|
||||||
roundType: string
|
roundType: string
|
||||||
evaluationCount: number
|
evaluationCount: number
|
||||||
evaluations: Array<{
|
evaluations: Evaluation[]
|
||||||
id: string
|
|
||||||
submittedAt: Date | null
|
|
||||||
globalScore: number | null
|
|
||||||
criterionScores: unknown
|
|
||||||
feedbackText: string | null
|
|
||||||
criteria: unknown
|
|
||||||
}>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeRoundStats(round: EvaluationRound) {
|
const HIDDEN_CRITERION_TYPES = new Set(['boolean', 'advance'])
|
||||||
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100
|
|
||||||
|
function parseScaleMax(scale: string | undefined, fallback = 10): number {
|
||||||
|
if (!scale) return fallback
|
||||||
|
const m = scale.match(/^\s*\d+\s*-\s*(\d+)\s*$/)
|
||||||
|
if (m) return Number(m[1])
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCriterionMax(c: Criterion): number {
|
||||||
|
if (typeof c.maxScore === 'number' && c.maxScore > 0) return c.maxScore
|
||||||
|
return parseScaleMax(c.scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleCriteria(criteria: unknown): Criterion[] {
|
||||||
|
if (!Array.isArray(criteria)) return []
|
||||||
|
return (criteria as Criterion[]).filter((c) => {
|
||||||
|
if (!c) return false
|
||||||
|
if (!c.id && !c.label && !c.name) return false
|
||||||
|
if (c.type && HIDDEN_CRITERION_TYPES.has(c.type)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function globalScoreSummary(round: EvaluationRound) {
|
||||||
|
if (round.roundType === 'DELIBERATION') return null
|
||||||
const scores = round.evaluations
|
const scores = round.evaluations
|
||||||
.map((ev) => ev.globalScore)
|
.map((ev) => ev.globalScore)
|
||||||
.filter((s): s is number => s !== null)
|
.filter((s): s is number => s !== null)
|
||||||
const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null
|
if (scores.length === 0) return null
|
||||||
const highest = scores.length > 0 ? Math.max(...scores) : null
|
const max = 10
|
||||||
const lowest = scores.length > 0 ? Math.min(...scores) : null
|
const avg = scores.reduce((a, b) => a + b, 0) / scores.length
|
||||||
return { maxScore, avg, highest, lowest, scores }
|
const lowest = Math.min(...scores)
|
||||||
}
|
const highest = Math.max(...scores)
|
||||||
|
return { avg, lowest, highest, max }
|
||||||
function ScoreBar({ score, maxScore, color }: { score: number; maxScore: number; color: string }) {
|
|
||||||
const pct = (score / maxScore) * 100
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 flex-1">
|
|
||||||
<div className="flex-1 overflow-hidden rounded-full bg-muted" style={{ height: 10 }}>
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${pct}%`, backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-semibold tabular-nums w-8 text-right">{score}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getScoreColor(score: number, maxScore: number): string {
|
|
||||||
const pct = score / maxScore
|
|
||||||
if (pct >= 0.8) return '#053d57'
|
|
||||||
if (pct >= 0.6) return '#1e7a8a'
|
|
||||||
if (pct >= 0.4) return '#557f8c'
|
|
||||||
if (pct >= 0.2) return '#c4453a'
|
|
||||||
return '#de0f1e'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoundIcon({ roundType, className }: { roundType: string; className?: string }) {
|
function RoundIcon({ roundType, className }: { roundType: string; className?: string }) {
|
||||||
@@ -85,6 +88,48 @@ function roundIconBg(roundType: string) {
|
|||||||
return 'bg-yellow-500/10'
|
return 'bg-yellow-500/10'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CriterionBar({ value, max }: { value: number; max: number }) {
|
||||||
|
const pct = Math.max(0, Math.min(100, (value / max) * 100))
|
||||||
|
return (
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-brand-blue transition-all"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumericCriterion({ label, score, max }: { label: string; score: number | undefined; max: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-semibold tabular-nums">
|
||||||
|
{score !== undefined ? score : '—'}
|
||||||
|
<span className="text-muted-foreground font-normal text-xs"> / {max}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{score !== undefined && <CriterionBar value={score} max={max} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextCriterion({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/40 px-4 py-3 border-l-3 border-brand-teal">
|
||||||
|
<p className="text-sm italic text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ApplicantEvaluationsPage() {
|
export default function ApplicantEvaluationsPage() {
|
||||||
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
|
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
|
||||||
|
|
||||||
@@ -95,14 +140,6 @@ export default function ApplicantEvaluationsPage() {
|
|||||||
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
||||||
<p className="text-muted-foreground">Anonymous evaluations from jury members</p>
|
<p className="text-muted-foreground">Anonymous evaluations from jury members</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-px overflow-hidden rounded-lg border bg-border">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className="bg-card p-4">
|
|
||||||
<Skeleton className="h-5 w-20 mb-2" />
|
|
||||||
<Skeleton className="h-8 w-16" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{[1, 2].map((i) => (
|
{[1, 2].map((i) => (
|
||||||
<Card key={i}>
|
<Card key={i}>
|
||||||
@@ -119,34 +156,14 @@ export default function ApplicantEvaluationsPage() {
|
|||||||
|
|
||||||
const hasEvaluations = rounds && rounds.length > 0
|
const hasEvaluations = rounds && rounds.length > 0
|
||||||
|
|
||||||
// Compute global stats
|
|
||||||
const allScores: number[] = []
|
|
||||||
let totalEvaluations = 0
|
|
||||||
if (rounds) {
|
|
||||||
for (const round of rounds) {
|
|
||||||
totalEvaluations += round.evaluationCount
|
|
||||||
for (const ev of round.evaluations) {
|
|
||||||
if (ev.globalScore !== null && round.roundType !== 'DELIBERATION') {
|
|
||||||
// Normalize to 0-100 for live final scores
|
|
||||||
const normalized = round.roundType === 'LIVE_FINAL'
|
|
||||||
? ev.globalScore * 10
|
|
||||||
: ev.globalScore
|
|
||||||
allScores.push(normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const globalAvg = allScores.length > 0
|
|
||||||
? allScores.reduce((a, b) => a + b, 0) / allScores.length
|
|
||||||
: null
|
|
||||||
const globalHighest = allScores.length > 0 ? Math.max(...allScores) : null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Anonymous evaluations from jury members
|
{hasEvaluations
|
||||||
|
? `Anonymous evaluations from jury members across ${rounds.length} round${rounds.length === 1 ? '' : 's'}.`
|
||||||
|
: 'Anonymous evaluations from jury members.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,174 +181,100 @@ export default function ApplicantEvaluationsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats Summary Strip */}
|
|
||||||
<AnimatedCard index={0}>
|
|
||||||
<Card className="p-0 overflow-hidden">
|
|
||||||
<div className="grid grid-cols-3 divide-x divide-border">
|
|
||||||
<div className="p-4 text-center">
|
|
||||||
<div className="flex items-center justify-center gap-1.5 mb-1">
|
|
||||||
<BarChart3 className="h-3.5 w-3.5 text-blue-500" />
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Reviews</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold tabular-nums">{totalEvaluations}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 text-center">
|
|
||||||
<div className="flex items-center justify-center gap-1.5 mb-1">
|
|
||||||
<TrendingUp className="h-3.5 w-3.5 text-emerald-500" />
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Avg Score</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold tabular-nums">
|
|
||||||
{globalAvg !== null ? globalAvg.toFixed(1) : '—'}
|
|
||||||
{globalAvg !== null && <span className="text-sm font-normal text-muted-foreground"> / 100</span>}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 text-center">
|
|
||||||
<div className="flex items-center justify-center gap-1.5 mb-1">
|
|
||||||
<Award className="h-3.5 w-3.5 text-amber-500" />
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Highest</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold tabular-nums">
|
|
||||||
{globalHighest !== null ? globalHighest : '—'}
|
|
||||||
{globalHighest !== null && <span className="text-sm font-normal text-muted-foreground"> / 100</span>}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
|
|
||||||
{/* Per-Round Cards */}
|
|
||||||
{rounds.map((round, roundIdx) => {
|
{rounds.map((round, roundIdx) => {
|
||||||
const { maxScore, avg, highest, lowest } = computeRoundStats(round)
|
const summary = globalScoreSummary(round)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedCard key={round.roundId} index={roundIdx + 1}>
|
<AnimatedCard key={round.roundId} index={roundIdx}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<CardTitle className="flex items-center gap-2.5">
|
<CardTitle className="flex items-center gap-2.5">
|
||||||
<div className={cn('rounded-lg p-1.5', roundIconBg(round.roundType))}>
|
<div className={cn('rounded-lg p-1.5', roundIconBg(round.roundType))}>
|
||||||
<RoundIcon roundType={round.roundType} />
|
<RoundIcon roundType={round.roundType} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>{round.roundName}</span>
|
<span>{round.roundName}</span>
|
||||||
{avg !== null && round.roundType !== 'DELIBERATION' && (
|
{summary && (
|
||||||
<p className="text-sm font-normal text-muted-foreground mt-0.5">
|
<p className="text-sm font-normal text-muted-foreground mt-0.5">
|
||||||
Average: <span className="font-semibold text-foreground">{avg.toFixed(1)}</span> / {maxScore}
|
Average <span className="font-semibold text-foreground">{summary.avg.toFixed(1)}</span> / {summary.max}
|
||||||
{highest !== null && lowest !== null && highest !== lowest && (
|
{summary.lowest !== summary.highest && (
|
||||||
<span className="ml-2">
|
<span className="ml-2">· Lowest {summary.lowest} · Highest {summary.highest}</span>
|
||||||
Range: {lowest}–{highest}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary" className="shrink-0">
|
||||||
{round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'evaluation'}{round.evaluationCount !== 1 ? 's' : ''}
|
{round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'review'}{round.evaluationCount !== 1 ? 's' : ''}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{/* Score Overview Bar — visual comparison across evaluators */}
|
|
||||||
{round.roundType !== 'DELIBERATION' && round.evaluations.some((ev) => ev.globalScore !== null) && (
|
|
||||||
<div className="px-6 pb-3">
|
|
||||||
<div className="rounded-lg bg-muted/40 p-3 space-y-2">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Score Comparison</p>
|
|
||||||
{round.evaluations.map((ev, idx) => {
|
|
||||||
if (ev.globalScore === null) return null
|
|
||||||
return (
|
|
||||||
<div key={ev.id} className="flex items-center gap-3">
|
|
||||||
<span className="text-xs text-muted-foreground w-6 text-right shrink-0 tabular-nums">
|
|
||||||
#{idx + 1}
|
|
||||||
</span>
|
|
||||||
<ScoreBar
|
|
||||||
score={ev.globalScore}
|
|
||||||
maxScore={maxScore}
|
|
||||||
color={getScoreColor(ev.globalScore, maxScore)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{round.evaluations.map((ev, idx) => (
|
{round.evaluations.map((ev, idx) => {
|
||||||
<div
|
const criteria = visibleCriteria(ev.criteria)
|
||||||
key={ev.id}
|
const scores = (ev.criterionScores ?? {}) as Record<string, unknown>
|
||||||
className="px-6 py-4 space-y-3"
|
|
||||||
>
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div key={ev.id} className="px-6 py-4 space-y-4">
|
||||||
<span className="font-medium text-sm">
|
<div className="flex items-center justify-between">
|
||||||
{round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`}
|
<span className="font-medium text-sm">
|
||||||
</span>
|
{round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`}
|
||||||
<div className="flex items-center gap-3">
|
</span>
|
||||||
{ev.globalScore !== null && round.roundType !== 'DELIBERATION' && (
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex items-center gap-1">
|
{ev.globalScore !== null && round.roundType !== 'DELIBERATION' && (
|
||||||
<Star className="h-3.5 w-3.5 text-yellow-500" />
|
<span className="flex items-center gap-1">
|
||||||
<span className="text-sm font-bold tabular-nums">{ev.globalScore}</span>
|
<Star className="h-3.5 w-3.5 text-yellow-500" />
|
||||||
<span className="text-xs text-muted-foreground">/ {maxScore}</span>
|
<span className="text-sm font-bold tabular-nums">{ev.globalScore}</span>
|
||||||
</span>
|
<span className="text-xs text-muted-foreground">/ 10</span>
|
||||||
)}
|
</span>
|
||||||
{ev.submittedAt && (
|
)}
|
||||||
<span className="text-xs text-muted-foreground">
|
{ev.submittedAt && (
|
||||||
{new Date(ev.submittedAt).toLocaleDateString()}
|
<span className="text-xs text-muted-foreground">
|
||||||
</span>
|
{new Date(ev.submittedAt).toLocaleDateString()}
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{criteria.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{criteria.map((c, ci) => {
|
||||||
|
const key = c.id || String(ci)
|
||||||
|
const label = c.label || c.name || `Criterion ${ci + 1}`
|
||||||
|
const raw = scores[key]
|
||||||
|
|
||||||
|
if (c.type === 'text') {
|
||||||
|
if (typeof raw !== 'string' || raw.trim() === '') return null
|
||||||
|
return <TextCriterion key={key} label={label} value={raw} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// numeric (default)
|
||||||
|
const score = typeof raw === 'number' ? raw : undefined
|
||||||
|
const max = getCriterionMax(c)
|
||||||
|
return <NumericCriterion key={key} label={label} score={score} max={max} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ev.feedbackText && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
<MessageSquare className="h-3.5 w-3.5" />
|
||||||
|
{round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/40 px-4 py-3 border-l-3 border-brand-teal">
|
||||||
|
<p className="text-sm italic text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||||
|
{ev.feedbackText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
{ev.criterionScores && ev.criteria && (
|
})}
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Criteria Breakdown</p>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
{(() => {
|
|
||||||
const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
|
|
||||||
const scores = ev.criterionScores as Record<string, number>
|
|
||||||
return criteria
|
|
||||||
.filter((c) => c.id || c.label || c.name)
|
|
||||||
.map((c, ci) => {
|
|
||||||
const key = c.id || String(ci)
|
|
||||||
const score = scores[key]
|
|
||||||
const cMax = c.maxScore || 10
|
|
||||||
const pct = score !== undefined ? (score / cMax) * 100 : 0
|
|
||||||
return (
|
|
||||||
<div key={ci} className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">{c.label || c.name || `Criterion ${ci + 1}`}</span>
|
|
||||||
<span className="font-semibold tabular-nums">
|
|
||||||
{score !== undefined ? score : '—'}
|
|
||||||
<span className="text-muted-foreground font-normal text-xs"> / {cMax}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{score !== undefined && (
|
|
||||||
<Progress value={pct} className="h-1.5" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ev.feedbackText && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
||||||
<MessageSquare className="h-3.5 w-3.5" />
|
|
||||||
{round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-muted/40 px-4 py-3 border-l-3 border-brand-teal">
|
|
||||||
<p className="text-sm italic text-muted-foreground leading-relaxed">
|
|
||||||
{ev.feedbackText}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -339,7 +282,6 @@ export default function ApplicantEvaluationsPage() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Confidentiality Footer */}
|
|
||||||
<div className="flex items-center justify-center gap-2 py-2">
|
<div className="flex items-center justify-center gap-2 py-2">
|
||||||
<ShieldCheck className="h-4 w-4 text-muted-foreground/60" />
|
<ShieldCheck className="h-4 w-4 text-muted-foreground/60" />
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
|
import { format } from 'date-fns'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -9,12 +11,17 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||||
|
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
||||||
|
import { RequestChangeDialog } from './request-change-dialog'
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
FileText,
|
FileText,
|
||||||
|
UserCog,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export default function ApplicantMentorPage() {
|
export default function ApplicantMentorPage() {
|
||||||
@@ -40,6 +47,8 @@ export default function ApplicantMentorPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [isChangeOpen, setIsChangeOpen] = useState(false)
|
||||||
|
|
||||||
if (dashLoading) {
|
if (dashLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -71,7 +80,20 @@ export default function ApplicantMentorPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const mentor = dashboardData?.project?.mentorAssignment?.mentor
|
const assignments = dashboardData?.project?.mentorAssignments ?? []
|
||||||
|
const hasMentors = assignments.length > 0
|
||||||
|
const primaryAssignment = assignments[0] ?? null
|
||||||
|
const primaryMentor = primaryAssignment?.mentor
|
||||||
|
const hasPendingChangeRequest = !!dashboardData?.hasPendingMentorChangeRequest
|
||||||
|
|
||||||
|
const dialogMentors = assignments
|
||||||
|
.filter((a) => !!a.mentor)
|
||||||
|
.map((a) => ({
|
||||||
|
assignmentId: a.id,
|
||||||
|
name: a.mentor?.name || a.mentor?.email || 'Mentor',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const teamHeading = assignments.length > 1 ? 'Your mentor team' : 'Your mentor'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -82,23 +104,72 @@ export default function ApplicantMentorPage() {
|
|||||||
Mentor Communication
|
Mentor Communication
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Chat with your assigned mentor
|
{assignments.length > 1
|
||||||
|
? 'Chat with your assigned mentor team'
|
||||||
|
: 'Chat with your assigned mentor'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mentor info */}
|
{/* Mentor list */}
|
||||||
{mentor ? (
|
{hasMentors ? (
|
||||||
<Card className="bg-muted/50">
|
<section className="space-y-3">
|
||||||
<CardContent className="p-4">
|
<h2 className="text-lg font-semibold tracking-tight">{teamHeading}</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<UserCircle className="h-10 w-10 text-muted-foreground" />
|
{assignments.map((assignment) => {
|
||||||
<div>
|
const mentor = assignment.mentor
|
||||||
<p className="font-medium">{mentor.name || 'Mentor'}</p>
|
if (!mentor) return null
|
||||||
<p className="text-sm text-muted-foreground">{mentor.email}</p>
|
const expertise = mentor.expertiseTags ?? []
|
||||||
</div>
|
return (
|
||||||
</div>
|
<Card key={assignment.id} className="bg-muted/50">
|
||||||
</CardContent>
|
<CardContent className="p-4 space-y-3">
|
||||||
</Card>
|
<div className="flex items-start gap-3">
|
||||||
|
<UserCircle className="h-10 w-10 text-muted-foreground shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium truncate">
|
||||||
|
{mentor.name || 'Mentor'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
{mentor.email}
|
||||||
|
</p>
|
||||||
|
{assignment.assignedAt && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Assigned since {format(new Date(assignment.assignedAt), 'MMM d, yyyy')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expertise.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{expertise.map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="font-normal">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Request change action */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pt-1">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{hasPendingChangeRequest
|
||||||
|
? "You have a pending mentor change request — admins will follow up soon."
|
||||||
|
: 'Need a different match? Let the program admins know.'}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsChangeOpen(true)}
|
||||||
|
disabled={hasPendingChangeRequest}
|
||||||
|
>
|
||||||
|
<UserCog className="mr-2 h-4 w-4" />
|
||||||
|
{hasPendingChangeRequest ? 'Change requested' : 'Request a mentor change'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<Card className="bg-muted/50">
|
<Card className="bg-muted/50">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||||
@@ -112,12 +183,14 @@ export default function ApplicantMentorPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chat */}
|
{/* Chat */}
|
||||||
{mentor && (
|
{primaryMentor && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Messages</CardTitle>
|
<CardTitle>Messages</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Your conversation history with {mentor.name || 'your mentor'}
|
{assignments.length > 1
|
||||||
|
? 'Your conversation history with your mentor team'
|
||||||
|
: `Your conversation history with ${primaryMentor.name || 'your mentor'}`}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -133,6 +206,25 @@ export default function ApplicantMentorPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Files */}
|
||||||
|
{primaryAssignment?.id && projectId && (
|
||||||
|
<WorkspaceFilesPanel
|
||||||
|
projectId={projectId}
|
||||||
|
mentorAssignmentId={primaryAssignment.id}
|
||||||
|
asApplicant
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Request change dialog */}
|
||||||
|
{projectId && (
|
||||||
|
<RequestChangeDialog
|
||||||
|
projectId={projectId}
|
||||||
|
mentors={dialogMentors}
|
||||||
|
open={isChangeOpen}
|
||||||
|
onOpenChange={setIsChangeOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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 { Textarea } from '@/components/ui/textarea'
|
||||||
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
|
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
|
||||||
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
|
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
|
||||||
|
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card'
|
||||||
|
import { AttendingMembersCard } from '@/components/applicant/attending-members-card'
|
||||||
|
import { LunchBanner } from '@/components/applicant/lunch-banner'
|
||||||
|
import { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
@@ -215,12 +219,12 @@ export default function ApplicantDashboardPage() {
|
|||||||
key={round.id}
|
key={round.id}
|
||||||
className={`flex flex-col sm:flex-row items-start sm:items-center gap-3 rounded-lg border px-4 py-3 ${
|
className={`flex flex-col sm:flex-row items-start sm:items-center gap-3 rounded-lg border px-4 py-3 ${
|
||||||
isUrgent
|
isUrgent
|
||||||
? 'border-amber-500/50 bg-amber-50 dark:bg-amber-950/20'
|
? 'border-amber-500/50 bg-amber-50'
|
||||||
: 'border-primary/20 bg-primary/5'
|
: 'border-primary/20 bg-primary/5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Clock className={`h-4 w-4 shrink-0 ${isUrgent ? 'text-amber-600 dark:text-amber-400' : 'text-primary'}`} />
|
<Clock className={`h-4 w-4 shrink-0 ${isUrgent ? 'text-amber-600' : 'text-primary'}`} />
|
||||||
<span className="font-medium text-sm truncate">{round.name}</span>
|
<span className="font-medium text-sm truncate">{round.name}</span>
|
||||||
<Badge variant={isUrgent ? 'warning' : 'default'} className="shrink-0">
|
<Badge variant={isUrgent ? 'warning' : 'default'} className="shrink-0">
|
||||||
{remaining > 0 ? formatCountdown(remaining) + ' left' : 'Closed'}
|
{remaining > 0 ? formatCountdown(remaining) + ' left' : 'Closed'}
|
||||||
@@ -401,6 +405,19 @@ export default function ApplicantDashboardPage() {
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Lunch banner (auto-hides when lunch event disabled or unconfigured) */}
|
||||||
|
<LunchBanner programId={project.programId} />
|
||||||
|
|
||||||
|
{/* External lunch attendees attached to this team (auto-hides if none) */}
|
||||||
|
<ExternalAttendeesStrip projectId={project.id} />
|
||||||
|
|
||||||
|
{/* Grand finale attendee roster (auto-hides until confirmation status is CONFIRMED) */}
|
||||||
|
<AttendingMembersCard />
|
||||||
|
|
||||||
|
{/* Conversation with assigned mentor (auto-hides when no mentor assigned) */}
|
||||||
|
<MentorConversationCard projectId={project.id} />
|
||||||
|
|
||||||
|
|
||||||
{/* Jury Feedback Card */}
|
{/* Jury Feedback Card */}
|
||||||
{totalEvaluations > 0 && (
|
{totalEvaluations > 0 && (
|
||||||
<AnimatedCard index={4}>
|
<AnimatedCard index={4}>
|
||||||
@@ -422,13 +439,14 @@ export default function ApplicantDashboardPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{evaluations?.map((round) => {
|
{evaluations?.map((round) => {
|
||||||
|
const showScore = round.roundType !== 'DELIBERATION'
|
||||||
const scores = round.evaluations
|
const scores = round.evaluations
|
||||||
.map((ev) => ev.globalScore)
|
.map((ev) => ev.globalScore)
|
||||||
.filter((s): s is number => s !== null)
|
.filter((s): s is number => s !== null)
|
||||||
const avgScore = scores.length > 0
|
const avgScore = showScore && scores.length > 0
|
||||||
? scores.reduce((a, b) => a + b, 0) / scores.length
|
? scores.reduce((a, b) => a + b, 0) / scores.length
|
||||||
: null
|
: null
|
||||||
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100
|
const maxScore = 10
|
||||||
const pct = avgScore !== null ? (avgScore / maxScore) * 100 : 0
|
const pct = avgScore !== null ? (avgScore / maxScore) * 100 : 0
|
||||||
const roundIcon = round.roundType === 'LIVE_FINAL'
|
const roundIcon = round.roundType === 'LIVE_FINAL'
|
||||||
? <Trophy className="h-3.5 w-3.5 text-amber-500" />
|
? <Trophy className="h-3.5 w-3.5 text-amber-500" />
|
||||||
|
|||||||
@@ -357,15 +357,33 @@ export default function ApplicantProjectPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mentor info */}
|
{(() => {
|
||||||
{project.mentorAssignment?.mentor && (
|
type MentorAssignment = {
|
||||||
<div className="rounded-lg border p-3 bg-muted/50">
|
droppedAt: Date | string | null
|
||||||
<p className="text-sm font-medium mb-1">Assigned Mentor</p>
|
mentor: { name: string | null; email: string } | null
|
||||||
<p className="text-sm text-muted-foreground">
|
}
|
||||||
{project.mentorAssignment.mentor.name} ({project.mentorAssignment.mentor.email})
|
const active = (
|
||||||
</p>
|
(project.mentorAssignments as MentorAssignment[] | undefined) ?? []
|
||||||
</div>
|
).filter((a) => !a.droppedAt && a.mentor)
|
||||||
)}
|
if (active.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border p-3 bg-muted/50">
|
||||||
|
<p className="text-sm font-medium mb-1">
|
||||||
|
{active.length === 1 ? 'Assigned Mentor' : `Assigned Mentors (${active.length})`}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{active.map((a, idx) => (
|
||||||
|
<li key={`${a.mentor!.email}-${idx}`} className="text-sm text-muted-foreground">
|
||||||
|
{a.mentor!.name ?? a.mentor!.email}
|
||||||
|
{a.mentor!.name && (
|
||||||
|
<span className="text-xs"> ({a.mentor!.email})</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
{project.tags && project.tags.length > 0 && (
|
{project.tags && project.tags.length > 0 && (
|
||||||
|
|||||||
@@ -160,8 +160,12 @@ function AcceptInviteContent() {
|
|||||||
setState('error')
|
setState('error')
|
||||||
setErrorType('AUTH_FAILED')
|
setErrorType('AUTH_FAILED')
|
||||||
} else if (result?.ok) {
|
} else if (result?.ok) {
|
||||||
// Redirect to set-password (middleware will enforce this since mustSetPassword=true)
|
// Let app/page.tsx route by role. Middleware will detour to
|
||||||
window.location.href = '/set-password'
|
// /set-password if the user still needs to set one (first-time
|
||||||
|
// setup); for users who already had a password (admin-issued
|
||||||
|
// access link, magic-login style) it'll go straight to their
|
||||||
|
// dashboard.
|
||||||
|
window.location.href = '/'
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setState('error')
|
setState('error')
|
||||||
|
|||||||
@@ -1,581 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { use, useRef, useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from '@/components/ui/alert-dialog'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Trophy,
|
|
||||||
CheckCircle2,
|
|
||||||
Loader2,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
FileText,
|
|
||||||
Star,
|
|
||||||
Users,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { CountryDisplay } from '@/components/shared/country-display'
|
|
||||||
import { ProjectFilesSection } from '@/components/jury/project-files-section'
|
|
||||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
|
||||||
|
|
||||||
export default function AwardMasterVotingPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ id: string }>
|
|
||||||
}) {
|
|
||||||
const { id: awardId } = use(params)
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
|
||||||
null
|
|
||||||
)
|
|
||||||
const [expandedProjectId, setExpandedProjectId] = useState<string | null>(
|
|
||||||
null
|
|
||||||
)
|
|
||||||
const [justification, setJustification] = useState('')
|
|
||||||
|
|
||||||
// Queries & mutations
|
|
||||||
const utils = trpc.useUtils()
|
|
||||||
const { data, isLoading } =
|
|
||||||
trpc.specialAward.getMyAwardDetailEnhanced.useQuery({ awardId })
|
|
||||||
|
|
||||||
const submitVote = trpc.specialAward.submitAwardMasterVote.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.specialAward.getMyAwardDetailEnhanced.invalidate({ awardId })
|
|
||||||
toast.success('Vote submitted')
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const confirmWinner = trpc.specialAward.confirmWinner.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.specialAward.getMyAwardDetailEnhanced.invalidate({ awardId })
|
|
||||||
toast.success('Winner confirmed and award closed')
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Initialize selection from existing vote
|
|
||||||
const initializedRef = useRef(false)
|
|
||||||
if (data && !initializedRef.current && data.myVotes.length > 0) {
|
|
||||||
initializedRef.current = true
|
|
||||||
setSelectedProjectId(data.myVotes[0].projectId)
|
|
||||||
if (data.myVotes[0].justification) {
|
|
||||||
setJustification(data.myVotes[0].justification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Skeleton className="h-9 w-48" />
|
|
||||||
<Skeleton className="h-6 w-72" />
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{[...Array(6)].map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-44" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) return null
|
|
||||||
|
|
||||||
// Destructure data
|
|
||||||
const { award, projects, myVotes, isChair, otherVotes, totalJurors } = data
|
|
||||||
const hasVoted = myVotes.length > 0
|
|
||||||
const isVotingOpen = award.status === 'VOTING_OPEN'
|
|
||||||
const isClosed = award.status === 'CLOSED'
|
|
||||||
const selectedProject = projects.find((p) => p.id === selectedProjectId)
|
|
||||||
|
|
||||||
// Toggle project expansion
|
|
||||||
const handleProjectClick = (projectId: string) => {
|
|
||||||
if (isVotingOpen) setSelectedProjectId(projectId)
|
|
||||||
setExpandedProjectId(expandedProjectId === projectId ? null : projectId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Submit vote handler
|
|
||||||
const handleSubmitVote = () => {
|
|
||||||
if (!selectedProjectId) return
|
|
||||||
submitVote.mutate({
|
|
||||||
awardId,
|
|
||||||
projectId: selectedProjectId,
|
|
||||||
justification: justification.trim() || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm winner handler
|
|
||||||
const handleConfirmWinner = () => {
|
|
||||||
confirmWinner.mutate({ awardId })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the winner project for closed state
|
|
||||||
const winnerProject = isClosed
|
|
||||||
? projects.find((p) => p.id === award.winnerProjectId)
|
|
||||||
: null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Back button */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => router.push('/award-master' as Route)}
|
|
||||||
className="-ml-4"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
|
||||||
<Trophy className="h-6 w-6 text-amber-500" />
|
|
||||||
{award.name}
|
|
||||||
</h1>
|
|
||||||
<div className="mt-1 flex items-center gap-2">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
isVotingOpen
|
|
||||||
? 'default'
|
|
||||||
: isClosed
|
|
||||||
? 'secondary'
|
|
||||||
: 'outline'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{award.status.replace('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
{hasVoted && !isClosed && (
|
|
||||||
<Badge variant="outline" className="text-green-600">
|
|
||||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
|
||||||
Voted
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{award.competition && (
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{award.competition.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{award.criteriaText && (
|
|
||||||
<Card className="mt-3 bg-muted/30">
|
|
||||||
<CardContent className="py-3 px-4">
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
||||||
<Star className="mr-1.5 inline h-4 w-4 text-amber-500" />
|
|
||||||
<span className="font-medium text-foreground">Criteria: </span>
|
|
||||||
{award.criteriaText}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Closed State */}
|
|
||||||
{isClosed ? (
|
|
||||||
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<div className="rounded-full bg-amber-100 p-4 dark:bg-amber-900/40">
|
|
||||||
<Trophy className="h-12 w-12 text-amber-500" />
|
|
||||||
</div>
|
|
||||||
<p className="mt-4 text-lg font-semibold">Award Finalized</p>
|
|
||||||
{winnerProject ? (
|
|
||||||
<div className="mt-3 space-y-1">
|
|
||||||
<p className="text-xl font-bold text-[#053d57] dark:text-blue-300">
|
|
||||||
{winnerProject.title}
|
|
||||||
</p>
|
|
||||||
{winnerProject.teamName && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{winnerProject.teamName}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
This award has been finalized
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Project Grid */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold mb-3">
|
|
||||||
Eligible Projects ({projects.length})
|
|
||||||
</h2>
|
|
||||||
{isVotingOpen && (
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Click a project to select it as your pick and expand details
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{projects.map((project) => (
|
|
||||||
<div
|
|
||||||
key={project.id}
|
|
||||||
className={cn(
|
|
||||||
expandedProjectId === project.id && 'sm:col-span-2 lg:col-span-3'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
'cursor-pointer transition-all',
|
|
||||||
selectedProjectId === project.id
|
|
||||||
? 'ring-2 ring-primary bg-primary/5'
|
|
||||||
: 'hover:bg-muted/50'
|
|
||||||
)}
|
|
||||||
onClick={() => handleProjectClick(project.id)}
|
|
||||||
>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<ProjectLogo project={project} logoUrl={project.logoUrl} size="sm" fallback="initials" className="mt-0.5" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<CardTitle className="text-base">
|
|
||||||
{project.title}
|
|
||||||
</CardTitle>
|
|
||||||
{project.teamName && (
|
|
||||||
<CardDescription className="mt-0.5">
|
|
||||||
{project.teamName}
|
|
||||||
</CardDescription>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="ml-2 shrink-0">
|
|
||||||
{expandedProjectId === project.id ? (
|
|
||||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
{project.competitionCategory && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{project.competitionCategory.replace(/_/g, ' ')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{project.country && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
<CountryDisplay country={project.country} />
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{project.evaluationScore && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="text-xs bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300"
|
|
||||||
>
|
|
||||||
<Star className="mr-0.5 h-3 w-3" />
|
|
||||||
Avg: {project.evaluationScore.avg.toFixed(1)}/10 (
|
|
||||||
{project.evaluationScore.count}{' '}
|
|
||||||
{project.evaluationScore.count === 1
|
|
||||||
? 'review'
|
|
||||||
: 'reviews'}
|
|
||||||
)
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{selectedProjectId === project.id && (
|
|
||||||
<Badge className="text-xs bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300">
|
|
||||||
<CheckCircle2 className="mr-0.5 h-3 w-3" />
|
|
||||||
Selected
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Expanded Project Detail */}
|
|
||||||
{expandedProjectId === project.id && (
|
|
||||||
<Card className="mt-2 border-dashed">
|
|
||||||
<CardContent className="space-y-4 py-4">
|
|
||||||
{project.description && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium mb-1 flex items-center gap-1.5">
|
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
||||||
Description
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-line">
|
|
||||||
{project.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{award.evaluationRoundId && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
||||||
Documents
|
|
||||||
</h4>
|
|
||||||
<ProjectFilesSection
|
|
||||||
projectId={project.id}
|
|
||||||
roundId={award.evaluationRoundId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{project.evaluationScore && (
|
|
||||||
<Card className="bg-blue-50/50 dark:bg-blue-950/20">
|
|
||||||
<CardContent className="py-3 px-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Star className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
Evaluation Score
|
|
||||||
</p>
|
|
||||||
<p className="text-lg font-bold text-blue-700 dark:text-blue-300">
|
|
||||||
{project.evaluationScore.avg.toFixed(1)} / 10
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Based on {project.evaluationScore.count}{' '}
|
|
||||||
{project.evaluationScore.count === 1
|
|
||||||
? 'evaluation'
|
|
||||||
: 'evaluations'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Vote Section */}
|
|
||||||
{isVotingOpen && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Your Vote</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{hasVoted
|
|
||||||
? 'You can update your vote until the award is finalized'
|
|
||||||
: 'Select a project above and submit your vote'}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{selectedProject ? (
|
|
||||||
<div className="rounded-lg border bg-muted/30 p-3">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Your selection
|
|
||||||
</p>
|
|
||||||
<p className="font-semibold">{selectedProject.title}</p>
|
|
||||||
{selectedProject.teamName && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{selectedProject.teamName}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground italic">
|
|
||||||
No project selected. Click a project card above to select it.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label
|
|
||||||
htmlFor="justification"
|
|
||||||
className="text-sm font-medium"
|
|
||||||
>
|
|
||||||
Justification
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
id="justification"
|
|
||||||
value={justification}
|
|
||||||
onChange={(e) => setJustification(e.target.value)}
|
|
||||||
placeholder="Why did you choose this project? (optional)"
|
|
||||||
maxLength={2000}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground text-right">
|
|
||||||
{justification.length} / 2000
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmitVote}
|
|
||||||
disabled={!selectedProjectId || submitVote.isPending}
|
|
||||||
>
|
|
||||||
{submitVote.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Chair Section */}
|
|
||||||
{isChair && isVotingOpen && (
|
|
||||||
<>
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
|
||||||
<Users className="h-5 w-5 text-muted-foreground" />
|
|
||||||
Team Votes
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
As chair, you can view team votes and confirm the winner
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{otherVotes.length > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{otherVotes.map((vote) => {
|
|
||||||
const votedProject = projects.find(
|
|
||||||
(p) => p.id === vote.projectId
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={vote.userId}
|
|
||||||
className="rounded-lg border p-3 space-y-1"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="font-medium text-sm">
|
|
||||||
{vote.userName || 'Anonymous Juror'}
|
|
||||||
</p>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
voted for
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-semibold">
|
|
||||||
{votedProject?.title || 'Unknown project'}
|
|
||||||
</p>
|
|
||||||
{vote.justification && (
|
|
||||||
<p className="text-sm text-muted-foreground italic">
|
|
||||||
“{vote.justification}”
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground italic">
|
|
||||||
Waiting for other team members to vote
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Vote tally */}
|
|
||||||
<div className="rounded-lg bg-muted/30 p-3">
|
|
||||||
<p className="text-sm font-medium">Vote Summary</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{otherVotes.length + (hasVoted ? 1 : 0)} of{' '}
|
|
||||||
{totalJurors} jurors have voted
|
|
||||||
</p>
|
|
||||||
{(() => {
|
|
||||||
const allVotes = [
|
|
||||||
...otherVotes.map((v) => v.projectId),
|
|
||||||
...(hasVoted && myVotes[0]
|
|
||||||
? [myVotes[0].projectId]
|
|
||||||
: []),
|
|
||||||
]
|
|
||||||
const tally = new Map<string, number>()
|
|
||||||
for (const pid of allVotes) {
|
|
||||||
tally.set(pid, (tally.get(pid) || 0) + 1)
|
|
||||||
}
|
|
||||||
const sorted = [...tally.entries()].sort(
|
|
||||||
(a, b) => b[1] - a[1]
|
|
||||||
)
|
|
||||||
if (sorted.length === 0) return null
|
|
||||||
return (
|
|
||||||
<div className="mt-2 space-y-1">
|
|
||||||
{sorted.map(([pid, count]) => {
|
|
||||||
const proj = projects.find((p) => p.id === pid)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={pid}
|
|
||||||
className="flex items-center justify-between text-sm"
|
|
||||||
>
|
|
||||||
<span>{proj?.title || 'Unknown'}</span>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{count} {count === 1 ? 'vote' : 'votes'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Confirm Winner button */}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
disabled={!hasVoted || confirmWinner.isPending}
|
|
||||||
>
|
|
||||||
{confirmWinner.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trophy className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Confirm Winner
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
Confirm Award Winner
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will finalize the winner and close the award.
|
|
||||||
This cannot be undone.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={handleConfirmWinner}>
|
|
||||||
Confirm Winner
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import Link from 'next/link'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { Trophy } from 'lucide-react'
|
|
||||||
|
|
||||||
export default function AwardMasterDashboard() {
|
|
||||||
const { data: awards, isLoading } = trpc.specialAward.getMyAwards.useQuery()
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Skeleton className="h-9 w-48" />
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
{[...Array(2)].map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-40" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
|
||||||
Award Master Dashboard
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Review eligible projects and select award winners
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{awards && awards.length > 0 ? (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
{awards.map((award) => (
|
|
||||||
<Link key={award.id} href={`/award-master/awards/${award.id}` as Route}>
|
|
||||||
<Card className="transition-colors hover:bg-muted/50 cursor-pointer h-full">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Trophy className="h-5 w-5 text-amber-500" />
|
|
||||||
{award.name}
|
|
||||||
</CardTitle>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
award.status === 'VOTING_OPEN' ? 'default' : 'secondary'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{award.status.replace('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{award.description && (
|
|
||||||
<CardDescription className="line-clamp-2">
|
|
||||||
{award.description}
|
|
||||||
</CardDescription>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{award._count.eligibilities} eligible projects
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<Trophy className="h-12 w-12 text-muted-foreground/50" />
|
|
||||||
<p className="mt-2 font-medium">No awards assigned</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
You will see your awards here when they are assigned to you
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { requireRole } from '@/lib/auth-redirect'
|
|
||||||
import { AwardMasterNav } from '@/components/layouts/award-master-nav'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
export default async function AwardMasterLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
const session = await requireRole('AWARD_MASTER', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background">
|
|
||||||
<AwardMasterNav
|
|
||||||
user={{
|
|
||||||
name: session.user.name,
|
|
||||||
email: session.user.email,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<main className="container-app py-6 lg:py-8">{children}</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -13,16 +13,29 @@ import {
|
|||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Trophy,
|
Trophy,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Loader2,
|
Loader2,
|
||||||
GripVertical,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Users,
|
Users,
|
||||||
Tag,
|
Tag,
|
||||||
|
Star,
|
||||||
|
Gavel,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { CountryDisplay } from '@/components/shared/country-display'
|
import { CountryDisplay } from '@/components/shared/country-display'
|
||||||
@@ -50,11 +63,19 @@ export default function JuryAwardVotingPage({
|
|||||||
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
|
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const confirmWinner = trpc.specialAward.confirmWinner.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
|
||||||
|
toast.success('Winner confirmed and award closed')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
const [rankedIds, setRankedIds] = useState<string[]>([])
|
const [rankedIds, setRankedIds] = useState<string[]>([])
|
||||||
|
const [justification, setJustification] = useState('')
|
||||||
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set())
|
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const toggleExpanded = (projectId: string) => {
|
const toggleExpanded = (projectId: string) => {
|
||||||
@@ -71,6 +92,9 @@ export default function JuryAwardVotingPage({
|
|||||||
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
|
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
|
||||||
if (data.award.scoringMode === 'PICK_WINNER') {
|
if (data.award.scoringMode === 'PICK_WINNER') {
|
||||||
setSelectedProjectId(data.myVotes[0]?.projectId || null)
|
setSelectedProjectId(data.myVotes[0]?.projectId || null)
|
||||||
|
if (data.myVotes[0]?.justification) {
|
||||||
|
setJustification(data.myVotes[0].justification)
|
||||||
|
}
|
||||||
} else if (data.award.scoringMode === 'RANKED') {
|
} else if (data.award.scoringMode === 'RANKED') {
|
||||||
const sorted = [...data.myVotes]
|
const sorted = [...data.myVotes]
|
||||||
.sort((a, b) => (a.rank || 0) - (b.rank || 0))
|
.sort((a, b) => (a.rank || 0) - (b.rank || 0))
|
||||||
@@ -84,7 +108,10 @@ export default function JuryAwardVotingPage({
|
|||||||
try {
|
try {
|
||||||
await submitVote.mutateAsync({
|
await submitVote.mutateAsync({
|
||||||
awardId,
|
awardId,
|
||||||
votes: [{ projectId: selectedProjectId }],
|
votes: [{
|
||||||
|
projectId: selectedProjectId,
|
||||||
|
justification: justification.trim() || undefined,
|
||||||
|
}],
|
||||||
})
|
})
|
||||||
toast.success('Vote submitted')
|
toast.success('Vote submitted')
|
||||||
refetch()
|
refetch()
|
||||||
@@ -136,9 +163,10 @@ export default function JuryAwardVotingPage({
|
|||||||
|
|
||||||
if (!data) return null
|
if (!data) return null
|
||||||
|
|
||||||
const { award, projects, myVotes } = data
|
const { award, projects, myVotes, isChair, otherVotes, totalJurors } = data
|
||||||
const hasVoted = myVotes.length > 0
|
const hasVoted = myVotes.length > 0
|
||||||
const isVotingOpen = award.status === 'VOTING_OPEN'
|
const isVotingOpen = award.status === 'VOTING_OPEN'
|
||||||
|
const isClosed = award.status === 'CLOSED'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -197,10 +225,30 @@ export default function JuryAwardVotingPage({
|
|||||||
isExpanded={expandedProjects.has(project.id)}
|
isExpanded={expandedProjects.has(project.id)}
|
||||||
onSelect={() => setSelectedProjectId(project.id)}
|
onSelect={() => setSelectedProjectId(project.id)}
|
||||||
onToggleExpand={() => toggleExpanded(project.id)}
|
onToggleExpand={() => toggleExpanded(project.id)}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedProjectId && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Justification (optional)</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Visible to the jury chair when they finalize the award.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder="Why this project? (optional)"
|
||||||
|
value={justification}
|
||||||
|
onChange={(e) => setJustification(e.target.value)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmitPickWinner}
|
onClick={handleSubmitPickWinner}
|
||||||
@@ -214,6 +262,18 @@ export default function JuryAwardVotingPage({
|
|||||||
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isChair && totalJurors > 1 && (
|
||||||
|
<ChairPanel
|
||||||
|
award={award}
|
||||||
|
projects={projects}
|
||||||
|
otherVotes={otherVotes}
|
||||||
|
totalJurors={totalJurors}
|
||||||
|
hasVoted={hasVoted}
|
||||||
|
onConfirm={() => confirmWinner.mutate({ awardId })}
|
||||||
|
isPending={confirmWinner.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : award.scoringMode === 'RANKED' ? (
|
) : award.scoringMode === 'RANKED' ? (
|
||||||
/* RANKED Mode */
|
/* RANKED Mode */
|
||||||
@@ -332,6 +392,7 @@ type ProjectData = {
|
|||||||
tags: string[]
|
tags: string[]
|
||||||
logoKey?: string | null
|
logoKey?: string | null
|
||||||
logoUrl?: string | null
|
logoUrl?: string | null
|
||||||
|
evaluationScore?: { avg: number; count: number } | null
|
||||||
files: Array<{
|
files: Array<{
|
||||||
id: string
|
id: string
|
||||||
fileName: string
|
fileName: string
|
||||||
@@ -355,9 +416,31 @@ type ProjectData = {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OtherVote = {
|
||||||
|
userId: string
|
||||||
|
userName: string | null
|
||||||
|
projectId: string
|
||||||
|
justification: string | null
|
||||||
|
}
|
||||||
|
|
||||||
function ProjectDetails({ project }: { project: ProjectData }) {
|
function ProjectDetails({ project }: { project: ProjectData }) {
|
||||||
return (
|
return (
|
||||||
<div className="px-4 pb-4 pt-2 space-y-4 border-t mt-2">
|
<div className="px-4 pb-4 pt-2 space-y-4 border-t mt-2">
|
||||||
|
{project.evaluationScore && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-blue-50/50 px-3 py-2">
|
||||||
|
<Star className="h-4 w-4 text-blue-600 shrink-0" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-semibold text-blue-700">
|
||||||
|
{project.evaluationScore.avg.toFixed(1)} / 10
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground ml-2">
|
||||||
|
from {project.evaluationScore.count}{' '}
|
||||||
|
{project.evaluationScore.count === 1 ? 'evaluation' : 'evaluations'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{project.description && (
|
{project.description && (
|
||||||
<p className="text-sm text-muted-foreground whitespace-pre-line">{project.description}</p>
|
<p className="text-sm text-muted-foreground whitespace-pre-line">{project.description}</p>
|
||||||
)}
|
)}
|
||||||
@@ -435,7 +518,7 @@ function ProjectCard({
|
|||||||
isExpanded && 'rotate-180'
|
isExpanded && 'rotate-180'
|
||||||
)} />
|
)} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">
|
<h3 className="font-semibold text-sm group-hover:text-brand-blue transition-colors">
|
||||||
{project.title}
|
{project.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
@@ -469,3 +552,139 @@ function ProjectCard({
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChairPanel({
|
||||||
|
award,
|
||||||
|
projects,
|
||||||
|
otherVotes,
|
||||||
|
totalJurors,
|
||||||
|
hasVoted,
|
||||||
|
onConfirm,
|
||||||
|
isPending,
|
||||||
|
}: {
|
||||||
|
award: { id: string; status: string }
|
||||||
|
projects: ProjectData[]
|
||||||
|
otherVotes: OtherVote[]
|
||||||
|
totalJurors: number
|
||||||
|
hasVoted: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
isPending: boolean
|
||||||
|
}) {
|
||||||
|
const projectMap = new Map(projects.map((p) => [p.id, p]))
|
||||||
|
const tally = new Map<string, number>()
|
||||||
|
for (const v of otherVotes) {
|
||||||
|
tally.set(v.projectId, (tally.get(v.projectId) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
const ranked = Array.from(tally.entries())
|
||||||
|
.map(([projectId, votes]) => ({
|
||||||
|
project: projectMap.get(projectId),
|
||||||
|
votes,
|
||||||
|
}))
|
||||||
|
.filter((r) => r.project)
|
||||||
|
.sort((a, b) => b.votes - a.votes)
|
||||||
|
|
||||||
|
const votedCount = new Set(otherVotes.map((v) => v.userId)).size + (hasVoted ? 1 : 0)
|
||||||
|
const isClosed = award.status === 'CLOSED'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-amber-200">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Gavel className="h-5 w-5 text-amber-600" />
|
||||||
|
<CardTitle className="text-base">Chair tools</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
{votedCount} of {totalJurors} jurors have voted. As the chair you
|
||||||
|
can review their picks and finalize the award.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{ranked.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
No other juror votes yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Tally so far
|
||||||
|
</p>
|
||||||
|
{ranked.map(({ project, votes }) => (
|
||||||
|
<div key={project!.id} className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||||
|
<span className="text-sm font-medium truncate">{project!.title}</span>
|
||||||
|
<Badge variant="secondary">{votes} {votes === 1 ? 'vote' : 'votes'}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{otherVotes.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Justifications
|
||||||
|
</p>
|
||||||
|
{otherVotes.map((v) => {
|
||||||
|
const project = projectMap.get(v.projectId)
|
||||||
|
return (
|
||||||
|
<div key={v.userId} className="rounded-md border p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{v.userName || 'Anonymous juror'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
→ {project?.title || 'Unknown project'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{v.justification && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2 whitespace-pre-line">
|
||||||
|
{v.justification}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isClosed && (
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button disabled={!hasVoted || isPending}>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trophy className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Confirm winner & close award
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Finalize the award?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
The project with the most votes will be set as the
|
||||||
|
winner. If there's a tie, your own vote breaks it.
|
||||||
|
Voting will close immediately and this can't be
|
||||||
|
reopened from this page.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={onConfirm}>
|
||||||
|
Confirm
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasVoted && (
|
||||||
|
<p className="text-xs text-muted-foreground text-right">
|
||||||
|
You must submit your own vote before finalizing.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function JuryRoundDetailPage() {
|
|||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||||
{round?.name || 'Round Details'}
|
{round?.name || 'Round Details'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<Card className="border-l-4 border-l-amber-500">
|
<Card className="border-l-4 border-l-amber-500">
|
||||||
<CardContent className="flex items-start gap-4 p-6">
|
<CardContent className="flex items-start gap-4 p-6">
|
||||||
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3">
|
<div className="rounded-xl bg-amber-50 p-3">
|
||||||
<Clock className="h-6 w-6 text-amber-600" />
|
<Clock className="h-6 w-6 text-amber-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -495,7 +495,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||||
Evaluate Project
|
Evaluate Project
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">{project.title}</p>
|
<p className="text-muted-foreground mt-1">{project.title}</p>
|
||||||
@@ -526,7 +526,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||||
Evaluate Project
|
Evaluate Project
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">{project.title}</p>
|
<p className="text-muted-foreground mt-1">{project.title}</p>
|
||||||
@@ -534,7 +534,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<Card className="border-l-4 border-l-amber-500">
|
<Card className="border-l-4 border-l-amber-500">
|
||||||
<CardContent className="flex items-start gap-4 p-6">
|
<CardContent className="flex items-start gap-4 p-6">
|
||||||
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3">
|
<div className="rounded-xl bg-amber-50 p-3">
|
||||||
<ShieldAlert className="h-6 w-6 text-amber-600" />
|
<ShieldAlert className="h-6 w-6 text-amber-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -573,7 +573,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||||
{isReadOnly ? 'Submitted Evaluation' : 'Evaluate Project'}
|
{isReadOnly ? 'Submitted Evaluation' : 'Evaluate Project'}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
@@ -583,8 +583,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={
|
className={
|
||||||
project.competitionCategory === 'STARTUP'
|
project.competitionCategory === 'STARTUP'
|
||||||
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
? 'bg-violet-100 text-violet-700 border-violet-200'
|
||||||
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
|
: 'bg-sky-100 text-sky-700 border-sky-200'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||||
@@ -595,7 +595,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isReadOnly && (
|
{isReadOnly && (
|
||||||
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50 dark:bg-blue-950/20">
|
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50">
|
||||||
<CardContent className="flex items-start gap-3 p-4">
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
|
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ export default function JuryProjectDetailPage() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={
|
className={
|
||||||
project.competitionCategory === 'STARTUP'
|
project.competitionCategory === 'STARTUP'
|
||||||
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
? 'bg-violet-100 text-violet-700 border-violet-200'
|
||||||
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
|
: 'bg-sky-100 text-sky-700 border-sky-200'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function JuryAssignmentsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||||
My Assignments
|
My Assignments
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ async function JuryDashboardContent() {
|
|||||||
<div className="h-1 w-full bg-gradient-to-r from-brand-teal/40 via-brand-blue/40 to-brand-teal/40" />
|
<div className="h-1 w-full bg-gradient-to-r from-brand-teal/40 via-brand-blue/40 to-brand-teal/40" />
|
||||||
<CardContent className="py-8 px-6">
|
<CardContent className="py-8 px-6">
|
||||||
<div className="flex flex-col items-center text-center mb-6">
|
<div className="flex flex-col items-center text-center mb-6">
|
||||||
<div className="rounded-2xl bg-gradient-to-br from-brand-teal/10 to-brand-blue/10 p-4 mb-3 dark:from-brand-teal/20 dark:to-brand-blue/20">
|
<div className="rounded-2xl bg-gradient-to-br from-brand-teal/10 to-brand-blue/10 p-4 mb-3">
|
||||||
<ClipboardList className="h-8 w-8 text-brand-teal/60" />
|
<ClipboardList className="h-8 w-8 text-brand-teal/60" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-semibold">No assignments yet</p>
|
<p className="text-lg font-semibold">No assignments yet</p>
|
||||||
@@ -273,13 +273,13 @@ async function JuryDashboardContent() {
|
|||||||
<div className={`grid gap-3 max-w-md mx-auto ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
|
<div className={`grid gap-3 max-w-md mx-auto ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
|
||||||
<Link
|
<Link
|
||||||
href="/jury/competitions"
|
href="/jury/competitions"
|
||||||
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
|
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40">
|
<div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100">
|
||||||
<ClipboardList className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
<ClipboardList className="h-4 w-4 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">All Assignments</p>
|
<p className="font-semibold text-sm group-hover:text-brand-blue transition-colors">All Assignments</p>
|
||||||
<p className="text-xs text-muted-foreground">View evaluations</p>
|
<p className="text-xs text-muted-foreground">View evaluations</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -288,7 +288,7 @@ async function JuryDashboardContent() {
|
|||||||
href="/jury/competitions"
|
href="/jury/competitions"
|
||||||
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40">
|
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100">
|
||||||
<GitCompare className="h-4 w-4 text-brand-teal" />
|
<GitCompare className="h-4 w-4 text-brand-teal" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
@@ -314,8 +314,8 @@ async function JuryDashboardContent() {
|
|||||||
<div className="rounded-[7px] bg-background">
|
<div className="rounded-[7px] bg-background">
|
||||||
<CardHeader className="pb-2 pt-4 px-5">
|
<CardHeader className="pb-2 pt-4 px-5">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/40">
|
<div className="rounded-lg bg-amber-100 p-1.5">
|
||||||
<Trophy className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
<Trophy className="h-4 w-4 text-amber-600" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-lg">Special Awards — Voting Open</CardTitle>
|
<CardTitle className="text-lg">Special Awards — Voting Open</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,27 +333,27 @@ async function JuryDashboardContent() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||||
hasVoted
|
hasVoted
|
||||||
? 'border-green-200/60 bg-green-50/30 dark:border-green-800/40 dark:bg-green-950/10'
|
? 'border-green-200/60 bg-green-50/30'
|
||||||
: isUrgent
|
: isUrgent
|
||||||
? 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20'
|
? 'border-red-200 bg-red-50/50'
|
||||||
: 'border-amber-200/60 bg-amber-50/30 dark:border-amber-800/40 dark:bg-amber-950/10'
|
: 'border-amber-200/60 bg-amber-50/30'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className={cn('font-semibold', hasVoted ? 'text-green-700 dark:text-green-400' : 'text-amber-700 dark:text-amber-400')}>{award.name}</h3>
|
<h3 className={cn('font-semibold', hasVoted ? 'text-green-700' : 'text-amber-700')}>{award.name}</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{award._count.eligibilities} project{award._count.eligibilities !== 1 ? 's' : ''} to review
|
{award._count.eligibilities} project{award._count.eligibilities !== 1 ? 's' : ''} to review
|
||||||
{record.isChair && ' · You are the Chair'}
|
{record.isChair && ' · You are the Chair'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{hasVoted ? (
|
{hasVoted ? (
|
||||||
<Badge className="bg-green-100 text-green-800 border-green-300 dark:bg-green-950 dark:text-green-300 dark:border-green-700">
|
<Badge className="bg-green-100 text-green-800 border-green-300">
|
||||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
Submitted
|
Submitted
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge className="bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-700">
|
<Badge className="bg-amber-100 text-amber-800 border-amber-300">
|
||||||
Vote Now
|
Vote Now
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -452,8 +452,8 @@ async function JuryDashboardContent() {
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="rounded-lg bg-brand-blue/10 p-1.5 dark:bg-brand-blue/20">
|
<div className="rounded-lg bg-brand-blue/10 p-1.5">
|
||||||
<ClipboardList className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
|
<ClipboardList className="h-4 w-4 text-brand-blue" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-lg">My Assignments</CardTitle>
|
<CardTitle className="text-lg">My Assignments</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
@@ -487,14 +487,14 @@ async function JuryDashboardContent() {
|
|||||||
href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}`}
|
href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}`}
|
||||||
className="flex-1 min-w-0 group"
|
className="flex-1 min-w-0 group"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium truncate group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">
|
<p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
|
||||||
{assignment.project.title}
|
{assignment.project.title}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
{assignment.project.teamName}
|
{assignment.project.teamName}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-brand-blue/5 text-brand-blue/80 dark:bg-brand-teal/10 dark:text-brand-teal/80 border-0">
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-brand-blue/5 text-brand-blue/80 border-0">
|
||||||
{assignment.round.name}
|
{assignment.round.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -506,7 +506,7 @@ async function JuryDashboardContent() {
|
|||||||
Done
|
Done
|
||||||
</Badge>
|
</Badge>
|
||||||
) : isDraft && isVotingOpen ? (
|
) : isDraft && isVotingOpen ? (
|
||||||
<Badge className="text-xs bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-700 animate-pulse">
|
<Badge className="text-xs bg-amber-100 text-amber-800 border-amber-300 animate-pulse">
|
||||||
<Send className="mr-1 h-3 w-3" />
|
<Send className="mr-1 h-3 w-3" />
|
||||||
Ready to submit
|
Ready to submit
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -571,7 +571,7 @@ async function JuryDashboardContent() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20">
|
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
||||||
<Zap className="h-4 w-4 text-brand-teal" />
|
<Zap className="h-4 w-4 text-brand-teal" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
||||||
@@ -581,13 +581,13 @@ async function JuryDashboardContent() {
|
|||||||
<div className={`grid gap-3 ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
|
<div className={`grid gap-3 ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
|
||||||
<Link
|
<Link
|
||||||
href="/jury/competitions"
|
href="/jury/competitions"
|
||||||
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
|
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="rounded-xl bg-blue-50 p-3 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40 dark:group-hover:bg-blue-950/60">
|
<div className="rounded-xl bg-blue-50 p-3 transition-colors group-hover:bg-blue-100">
|
||||||
<ClipboardList className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
<ClipboardList className="h-5 w-5 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">All Assignments</p>
|
<p className="font-semibold text-sm group-hover:text-brand-blue transition-colors">All Assignments</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">View and manage evaluations</p>
|
<p className="text-xs text-muted-foreground mt-0.5">View and manage evaluations</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -596,7 +596,7 @@ async function JuryDashboardContent() {
|
|||||||
href="/jury/competitions"
|
href="/jury/competitions"
|
||||||
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40 dark:group-hover:bg-teal-950/60">
|
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100">
|
||||||
<GitCompare className="h-5 w-5 text-brand-teal" />
|
<GitCompare className="h-5 w-5 text-brand-teal" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
@@ -620,8 +620,8 @@ async function JuryDashboardContent() {
|
|||||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="rounded-lg bg-brand-blue/10 p-1.5 dark:bg-brand-blue/20">
|
<div className="rounded-lg bg-brand-blue/10 p-1.5">
|
||||||
<Waves className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
|
<Waves className="h-4 w-4 text-brand-blue" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg">Active Voting Stages</CardTitle>
|
<CardTitle className="text-lg">Active Voting Stages</CardTitle>
|
||||||
@@ -650,13 +650,13 @@ async function JuryDashboardContent() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||||
isUrgent
|
isUrgent
|
||||||
? 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20'
|
? 'border-red-200 bg-red-50/50'
|
||||||
: 'border-border/60 bg-muted/20 dark:bg-muted/10'
|
: 'border-border/60 bg-muted/20'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{round.name}</h3>
|
<h3 className="font-semibold text-brand-blue">{round.name}</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{program.name} · {program.year}
|
{program.name} · {program.year}
|
||||||
</p>
|
</p>
|
||||||
@@ -716,7 +716,7 @@ async function JuryDashboardContent() {
|
|||||||
<AnimatedCard index={8}>
|
<AnimatedCard index={8}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-6 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
<div className="rounded-2xl bg-brand-teal/10 p-3 mb-2 dark:bg-brand-teal/20">
|
<div className="rounded-2xl bg-brand-teal/10 p-3 mb-2">
|
||||||
<Clock className="h-6 w-6 text-brand-teal/70" />
|
<Clock className="h-6 w-6 text-brand-teal/70" />
|
||||||
</div>
|
</div>
|
||||||
<p className="font-semibold text-sm">No active voting stages</p>
|
<p className="font-semibold text-sm">No active voting stages</p>
|
||||||
@@ -734,7 +734,7 @@ async function JuryDashboardContent() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20">
|
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
||||||
<BarChart3 className="h-4 w-4 text-brand-teal" />
|
<BarChart3 className="h-4 w-4 text-brand-teal" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-lg">Round Summary</CardTitle>
|
<CardTitle className="text-lg">Round Summary</CardTitle>
|
||||||
@@ -750,7 +750,7 @@ async function JuryDashboardContent() {
|
|||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="font-medium truncate">{round.name}</span>
|
<span className="font-medium truncate">{round.name}</span>
|
||||||
<div className="flex items-baseline gap-1 shrink-0 ml-2">
|
<div className="flex items-baseline gap-1 shrink-0 ml-2">
|
||||||
<span className="font-bold tabular-nums text-brand-blue dark:text-brand-teal">{pct}%</span>
|
<span className="font-bold tabular-nums text-brand-blue">{pct}%</span>
|
||||||
<span className="text-xs text-muted-foreground">({done}/{total})</span>
|
<span className="text-xs text-muted-foreground">({done}/{total})</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -852,7 +852,7 @@ export default async function JuryDashboardPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||||
{getGreeting()}, {session?.user?.name || 'Juror'}
|
{getGreeting()}, {session?.user?.name || 'Juror'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-0.5">
|
<p className="text-muted-foreground mt-0.5">
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { CountryDisplay } from '@/components/shared/country-display'
|
import { CountryDisplay } from '@/components/shared/country-display'
|
||||||
|
import { RecentMessagesCard } from '@/components/mentor/recent-messages-card'
|
||||||
|
|
||||||
// Status badge colors
|
// Status badge colors
|
||||||
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
@@ -117,6 +118,9 @@ export default function MentorDashboard() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Recent unread messages from teams */}
|
||||||
|
<RecentMessagesCard />
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<AnimatedCard index={0}>
|
<AnimatedCard index={0}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Suspense, use, useState, useEffect } from 'react'
|
import { Suspense, use, useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -31,6 +32,7 @@ import { AnimatedCard } from '@/components/shared/animated-container'
|
|||||||
import { FileViewer } from '@/components/shared/file-viewer'
|
import { FileViewer } from '@/components/shared/file-viewer'
|
||||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||||
|
import { DropAssignmentDialog } from '@/components/mentor/drop-assignment-dialog'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -76,6 +78,7 @@ const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'ou
|
|||||||
|
|
||||||
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { data: session } = useSession()
|
||||||
const { data: project, isLoading, error } = trpc.mentor.getProjectDetail.useQuery({
|
const { data: project, isLoading, error } = trpc.mentor.getProjectDetail.useQuery({
|
||||||
projectId,
|
projectId,
|
||||||
})
|
})
|
||||||
@@ -91,14 +94,18 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// TODO(PR8 Task 9): show co-mentors. For now we pick the first assignment
|
||||||
|
// to keep tracking + chat working unchanged.
|
||||||
|
const primaryAssignment = project?.mentorAssignments?.[0] ?? null
|
||||||
|
|
||||||
// Track view when project loads
|
// Track view when project loads
|
||||||
const trackView = trpc.mentor.trackView.useMutation()
|
const trackView = trpc.mentor.trackView.useMutation()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (project?.mentorAssignment?.id) {
|
if (primaryAssignment?.id) {
|
||||||
trackView.mutate({ mentorAssignmentId: project.mentorAssignment.id })
|
trackView.mutate({ mentorAssignmentId: primaryAssignment.id })
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [project?.mentorAssignment?.id])
|
}, [primaryAssignment?.id])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <ProjectDetailSkeleton />
|
return <ProjectDetailSkeleton />
|
||||||
@@ -132,8 +139,15 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
|
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
|
||||||
const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || []
|
const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || []
|
||||||
const mentorAssignmentId = project.mentorAssignment?.id
|
const mentorAssignment = primaryAssignment
|
||||||
|
const mentorAssignmentId = mentorAssignment?.id
|
||||||
const programId = project.program?.id
|
const programId = project.program?.id
|
||||||
|
const viewerIsAssignedMentor =
|
||||||
|
!!mentorAssignment && session?.user?.id === mentorAssignment.mentor?.id
|
||||||
|
const canDrop =
|
||||||
|
viewerIsAssignedMentor &&
|
||||||
|
!mentorAssignment.droppedAt &&
|
||||||
|
mentorAssignment.completionStatus !== 'completed'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -179,6 +193,12 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{canDrop && mentorAssignmentId && (
|
||||||
|
<DropAssignmentDialog
|
||||||
|
assignmentId={mentorAssignmentId}
|
||||||
|
projectTitle={project.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{project.assignedAt && (
|
{project.assignedAt && (
|
||||||
@@ -461,7 +481,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<MentorChat
|
<MentorChat
|
||||||
messages={mentorMessages || []}
|
messages={mentorMessages || []}
|
||||||
currentUserId={project.mentorAssignment?.mentor?.id || ''}
|
currentUserId={primaryAssignment?.mentor?.id || ''}
|
||||||
onSendMessage={async (message) => {
|
onSendMessage={async (message) => {
|
||||||
await sendMessage.mutateAsync({ projectId, message })
|
await sendMessage.mutateAsync({ projectId, message })
|
||||||
}}
|
}}
|
||||||
@@ -576,7 +596,7 @@ function MilestonesSection({
|
|||||||
<div
|
<div
|
||||||
key={milestone.id}
|
key={milestone.id}
|
||||||
className={`flex items-start gap-3 p-3 rounded-lg border transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm ${
|
className={`flex items-start gap-3 p-3 rounded-lg border transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm ${
|
||||||
isCompleted ? 'bg-green-50/50 border-green-200 dark:bg-green-950/20 dark:border-green-900' : ''
|
isCompleted ? 'bg-green-50/50 border-green-200' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
import { WorkspaceChat } from '@/components/mentor/workspace-chat'
|
import { WorkspaceChat } from '@/components/mentor/workspace-chat'
|
||||||
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
|
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
|
||||||
import { ArrowLeft, MessageSquare, FileText, Upload } from 'lucide-react'
|
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
||||||
|
import { ArrowLeft, MessageSquare, FileText, Upload, Users } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export default function MentorWorkspaceDetailPage() {
|
export default function MentorWorkspaceDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { data: session } = useSession()
|
||||||
const projectId = params.projectId as string
|
const projectId = params.projectId as string
|
||||||
|
|
||||||
// Get mentor assignment for this project
|
// Get mentor assignment for this project
|
||||||
@@ -26,6 +35,22 @@ export default function MentorWorkspaceDetailPage() {
|
|||||||
{ enabled: !!projectId }
|
{ enabled: !!projectId }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Co-mentor visibility (PR8 multi-mentor): show who else is on the team.
|
||||||
|
// Gracefully tolerates stale tabs where the caller no longer has access
|
||||||
|
// (assignment dropped) — query just returns nothing in that case.
|
||||||
|
const { data: projectMentors } = trpc.mentor.getProjectMentors.useQuery(
|
||||||
|
{ projectId },
|
||||||
|
{ enabled: !!projectId, retry: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentUserId = session?.user?.id
|
||||||
|
const coMentors = (projectMentors ?? []).filter(
|
||||||
|
a => a.mentor.id !== currentUserId
|
||||||
|
)
|
||||||
|
const coMentorNames = coMentors.map(a => a.mentor.name ?? 'Unnamed mentor')
|
||||||
|
const visibleCoMentors = coMentorNames.slice(0, 3)
|
||||||
|
const hiddenCoMentors = coMentorNames.slice(3)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -69,6 +94,37 @@ export default function MentorWorkspaceDetailPage() {
|
|||||||
{project.teamName && (
|
{project.teamName && (
|
||||||
<p className="text-muted-foreground mt-1">{project.teamName}</p>
|
<p className="text-muted-foreground mt-1">{project.teamName}</p>
|
||||||
)}
|
)}
|
||||||
|
{coMentors.length > 0 && (
|
||||||
|
<div className="mt-2 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
You + {coMentors.length} co-mentor
|
||||||
|
{coMentors.length === 1 ? '' : 's'}:{' '}
|
||||||
|
<span className="text-foreground">
|
||||||
|
{visibleCoMentors.join(', ')}
|
||||||
|
</span>
|
||||||
|
{hiddenCoMentors.length > 0 && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="cursor-help underline decoration-dotted underline-offset-2">
|
||||||
|
+{hiddenCoMentors.length} more
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<div className="text-xs">
|
||||||
|
{hiddenCoMentors.join(', ')}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -102,25 +158,24 @@ export default function MentorWorkspaceDetailPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="files" className="mt-6">
|
<TabsContent value="files" className="mt-6">
|
||||||
<Card>
|
{assignment ? (
|
||||||
<CardHeader>
|
<WorkspaceFilesPanel
|
||||||
<CardTitle>Workspace Files</CardTitle>
|
projectId={projectId}
|
||||||
<CardDescription>
|
mentorAssignmentId={assignment.id}
|
||||||
Files shared in the mentor workspace
|
/>
|
||||||
</CardDescription>
|
) : (
|
||||||
</CardHeader>
|
<Card>
|
||||||
<CardContent className="text-center py-8">
|
<CardContent className="text-center py-8">
|
||||||
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">Loading workspace…</p>
|
||||||
File listing feature coming soon
|
</CardContent>
|
||||||
</p>
|
</Card>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="promotion" className="mt-6">
|
<TabsContent value="promotion" className="mt-6">
|
||||||
{assignment ? (
|
{assignment ? (
|
||||||
<FilePromotionPanel mentorAssignmentId={assignment.id} />
|
<FilePromotionPanel projectId={projectId} />
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="text-center py-8">
|
<CardContent className="text-center py-8">
|
||||||
|
|||||||
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 () => {
|
const handleSaveProfile = async () => {
|
||||||
try {
|
try {
|
||||||
await updateProfile.mutateAsync({
|
await updateProfile.mutateAsync({
|
||||||
email: email || undefined,
|
|
||||||
name: name || undefined,
|
name: name || undefined,
|
||||||
bio,
|
bio,
|
||||||
phoneNumber: phoneNumber || null,
|
phoneNumber: phoneNumber || null,
|
||||||
@@ -229,11 +228,13 @@ export default function ProfileSettingsPage() {
|
|||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
readOnly
|
||||||
|
disabled
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
This will be used for login and all notification emails.
|
Used for login and notifications. Contact an administrator to
|
||||||
|
change your email address.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
17
src/app/api/cron/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
|
// Authorization: must be admin or assigned jury/mentor for this project
|
||||||
const isAdmin = userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN'
|
const isAdmin = userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN'
|
||||||
|
|
||||||
|
// Per-round scope: jurors may only pull URLs for files in rounds with
|
||||||
|
// sortOrder <= their assigned round. Mirrors file.getDownloadUrl. Without
|
||||||
|
// this, a juror assigned to EVALUATION could bulk-download LIVE_FINAL
|
||||||
|
// confidential files via this endpoint.
|
||||||
|
let priorRoundIds: string[] | null = null
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
// Check if user is assigned as jury
|
|
||||||
const juryAssignment = await prisma.assignment.findFirst({
|
const juryAssignment = await prisma.assignment.findFirst({
|
||||||
where: {
|
where: { userId, projectId },
|
||||||
userId,
|
select: { id: true, roundId: true },
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check if user is assigned as mentor
|
|
||||||
const mentorAssignment = await prisma.mentorAssignment.findFirst({
|
const mentorAssignment = await prisma.mentorAssignment.findFirst({
|
||||||
where: {
|
where: { mentorId: userId, projectId },
|
||||||
mentorId: userId,
|
select: { id: true },
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!juryAssignment && !mentorAssignment) {
|
if (!juryAssignment && !mentorAssignment) {
|
||||||
@@ -53,14 +53,41 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|||||||
{ status: 403 }
|
{ status: 403 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply the per-round filter only when access is jury-only.
|
||||||
|
if (juryAssignment && !mentorAssignment) {
|
||||||
|
const assignedRound = await prisma.round.findUnique({
|
||||||
|
where: { id: juryAssignment.roundId },
|
||||||
|
select: { competitionId: true, sortOrder: true },
|
||||||
|
})
|
||||||
|
if (assignedRound) {
|
||||||
|
const priorOrCurrent = await prisma.round.findMany({
|
||||||
|
where: {
|
||||||
|
competitionId: assignedRound.competitionId,
|
||||||
|
sortOrder: { lte: assignedRound.sortOrder },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
priorRoundIds = priorOrCurrent.map((r) => r.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch file metadata from DB
|
// Fetch file metadata from DB
|
||||||
|
const fileWhere: Record<string, unknown> = {
|
||||||
|
id: { in: fileIds },
|
||||||
|
projectId,
|
||||||
|
}
|
||||||
|
if (priorRoundIds !== null) {
|
||||||
|
fileWhere.OR = [
|
||||||
|
{ requirement: { roundId: { in: priorRoundIds } } },
|
||||||
|
{ requirementId: null, roundId: { in: priorRoundIds } },
|
||||||
|
{ requirementId: null, roundId: null },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
const files = await prisma.projectFile.findMany({
|
const files = await prisma.projectFile.findMany({
|
||||||
where: {
|
where: fileWhere,
|
||||||
id: { in: fileIds },
|
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
fileName: true,
|
fileName: true,
|
||||||
|
|||||||
@@ -218,35 +218,6 @@
|
|||||||
--info: 194 25% 44%;
|
--info: 194 25% 44%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: 220 15% 8%;
|
|
||||||
--foreground: 0 0% 98%;
|
|
||||||
|
|
||||||
--card: 220 15% 10%;
|
|
||||||
--card-foreground: 0 0% 98%;
|
|
||||||
|
|
||||||
--popover: 220 15% 10%;
|
|
||||||
--popover-foreground: 0 0% 98%;
|
|
||||||
|
|
||||||
--primary: 354 90% 50%;
|
|
||||||
--primary-foreground: 0 0% 100%;
|
|
||||||
|
|
||||||
--secondary: 220 15% 18%;
|
|
||||||
--secondary-foreground: 0 0% 98%;
|
|
||||||
|
|
||||||
--muted: 220 15% 18%;
|
|
||||||
--muted-foreground: 0 0% 64%;
|
|
||||||
|
|
||||||
--accent: 194 20% 18%;
|
|
||||||
--accent-foreground: 0 0% 98%;
|
|
||||||
|
|
||||||
--destructive: 0 84% 55%;
|
|
||||||
--destructive-foreground: 0 0% 100%;
|
|
||||||
|
|
||||||
--border: 220 15% 22%;
|
|
||||||
--input: 220 15% 22%;
|
|
||||||
--ring: 220 10% 50%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -345,13 +316,6 @@ div[class*="recharts-tooltip"] {
|
|||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark div[class*="tremor"][class*="tooltip"],
|
|
||||||
.dark .recharts-tooltip-wrapper .recharts-default-tooltip,
|
|
||||||
.dark div[class*="recharts-tooltip"] {
|
|
||||||
background-color: hsl(var(--card)) !important;
|
|
||||||
border-color: hsl(var(--border)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tremor/Recharts tooltip color indicator icons — fix rendering */
|
/* Tremor/Recharts tooltip color indicator icons — fix rendering */
|
||||||
.recharts-tooltip-wrapper svg.recharts-surface {
|
.recharts-tooltip-wrapper svg.recharts-surface {
|
||||||
display: inline-block !important;
|
display: inline-block !important;
|
||||||
|
|||||||
@@ -4,28 +4,26 @@ import Image from 'next/image'
|
|||||||
import { auth } from '@/lib/auth'
|
import { auth } from '@/lib/auth'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
|
import type { UserRole } from '@prisma/client'
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Monaco Ocean Protection Challenge' }
|
export const metadata: Metadata = { title: 'Monaco Ocean Protection Challenge' }
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
|
||||||
// Redirect authenticated users to their appropriate dashboard
|
// Redirect authenticated users to their appropriate dashboard.
|
||||||
|
// Reads the multi-role array (roles[]) so a user who is e.g. JURY_MEMBER+MENTOR
|
||||||
|
// lands on /jury (their highest-priority role) rather than always falling
|
||||||
|
// through on the singular `role` field. The context-aware variant —
|
||||||
|
// user.getDefaultDashboard tRPC procedure — exists for surfaces that can call
|
||||||
|
// tRPC; page.tsx uses static priority for simplicity.
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
if (
|
const roles = (session.user.roles as UserRole[] | undefined) ?? [session.user.role as UserRole]
|
||||||
session.user.role === 'SUPER_ADMIN' ||
|
if (roles.includes('SUPER_ADMIN') || roles.includes('PROGRAM_ADMIN')) redirect('/admin')
|
||||||
session.user.role === 'PROGRAM_ADMIN'
|
if (roles.includes('JURY_MEMBER')) redirect('/jury')
|
||||||
) {
|
if (roles.includes('MENTOR')) redirect('/mentor' as Route)
|
||||||
redirect('/admin')
|
if (roles.includes('APPLICANT')) redirect('/applicant' as Route)
|
||||||
} else if (session.user.role === 'JURY_MEMBER') {
|
if (roles.includes('OBSERVER')) redirect('/observer')
|
||||||
redirect('/jury')
|
|
||||||
} else if (session.user.role === 'MENTOR') {
|
|
||||||
redirect('/mentor' as Route)
|
|
||||||
} else if (session.user.role === 'OBSERVER') {
|
|
||||||
redirect('/observer')
|
|
||||||
} else if (session.user.role === 'APPLICANT') {
|
|
||||||
redirect('/applicant' as Route)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { SessionProvider } from 'next-auth/react'
|
import { SessionProvider } from 'next-auth/react'
|
||||||
import { ThemeProvider } from 'next-themes'
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { httpBatchLink } from '@trpc/client'
|
import { httpBatchLink } from '@trpc/client'
|
||||||
import superjson from 'superjson'
|
import superjson from 'superjson'
|
||||||
@@ -78,12 +77,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
<SessionProvider>
|
||||||
<SessionProvider>
|
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
</trpc.Provider>
|
||||||
</trpc.Provider>
|
</SessionProvider>
|
||||||
</SessionProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ export function AssignmentPreviewSheet({
|
|||||||
{mode === 'ai' && !aiResult && !isAIGenerating && (
|
{mode === 'ai' && !aiResult && !isAIGenerating && (
|
||||||
<Card className="border-dashed">
|
<Card className="border-dashed">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-8 gap-3">
|
<CardContent className="flex flex-col items-center justify-center py-8 gap-3">
|
||||||
<div className="h-12 w-12 rounded-full bg-violet-100 dark:bg-violet-950 flex items-center justify-center">
|
<div className="h-12 w-12 rounded-full bg-violet-100 flex items-center justify-center">
|
||||||
<Sparkles className="h-6 w-6 text-violet-600" />
|
<Sparkles className="h-6 w-6 text-violet-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center space-y-1">
|
<div className="text-center space-y-1">
|
||||||
@@ -463,7 +463,7 @@ export function AssignmentPreviewSheet({
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{mode === 'ai' && (
|
{mode === 'ai' && (
|
||||||
<Card className="border-violet-200 bg-violet-50/50 dark:bg-violet-950/20">
|
<Card className="border-violet-200 bg-violet-50/50">
|
||||||
<CardContent className="flex items-center gap-3 py-4">
|
<CardContent className="flex items-center gap-3 py-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="h-6 w-6 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
|
<div className="h-6 w-6 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
|
||||||
@@ -567,13 +567,13 @@ export function AssignmentPreviewSheet({
|
|||||||
|
|
||||||
{/* ── Warnings ── */}
|
{/* ── Warnings ── */}
|
||||||
{preview.warnings && preview.warnings.length > 0 && (
|
{preview.warnings && preview.warnings.length > 0 && (
|
||||||
<Card className="border-amber-300 bg-amber-50/50 dark:bg-amber-950/20">
|
<Card className="border-amber-300 bg-amber-50/50">
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" />
|
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{preview.warnings.map((w: string, idx: number) => (
|
{preview.warnings.map((w: string, idx: number) => (
|
||||||
<p key={idx} className="text-xs text-amber-800 dark:text-amber-200">
|
<p key={idx} className="text-xs text-amber-800">
|
||||||
{w}
|
{w}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
|||||||
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(
|
const selectableUsers = useMemo(
|
||||||
() => data?.users ?? [],
|
() => data?.users ?? [],
|
||||||
[data?.users]
|
[data?.users]
|
||||||
@@ -321,9 +335,29 @@ export function MembersContent() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-3 flex flex-wrap items-center justify-between gap-2">
|
<CardContent className="py-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Selection persists across pages and filters.
|
{selectedIds.size > 0
|
||||||
|
? `${selectedIds.size} selected. Selection persists across pages and filters.`
|
||||||
|
: 'Selection persists across pages and filters.'}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
bulkUpdateRoles.mutate({
|
||||||
|
userIds: Array.from(selectedIds),
|
||||||
|
addRole: 'MENTOR',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={bulkUpdateRoles.isPending}
|
||||||
|
>
|
||||||
|
{bulkUpdateRoles.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Add MENTOR role
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
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>
|
</p>
|
||||||
{eligibilityMode === 'SEPARATE_POOL' && !hasAwardRounds && (
|
{eligibilityMode === 'SEPARATE_POOL' && !hasAwardRounds && (
|
||||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800">
|
||||||
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
|
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
No award rounds have been created yet. Projects will be confirmed but <strong>not routed</strong> to an evaluation track. Create rounds on the award page first.
|
No award rounds have been created yet. Projects will be confirmed but <strong>not routed</strong> to an evaluation track. Create rounds on the award page first.
|
||||||
|
|||||||
@@ -328,13 +328,13 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Grace Period Banner */}
|
{/* Grace Period Banner */}
|
||||||
{summary.isGracePeriodActive && (
|
{summary.isGracePeriodActive && (
|
||||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20">
|
<Card className="border-amber-200 bg-amber-50">
|
||||||
<CardContent className="flex items-center justify-between py-4">
|
<CardContent className="flex items-center justify-between py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Clock className="h-5 w-5 text-amber-600" />
|
<Clock className="h-5 w-5 text-amber-600" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-amber-800 dark:text-amber-200">Grace Period Active</p>
|
<p className="font-medium text-amber-800">Grace Period Active</p>
|
||||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
<p className="text-sm text-amber-600">
|
||||||
Applicants can still submit until{' '}
|
Applicants can still submit until{' '}
|
||||||
{summary.gracePeriodEndsAt
|
{summary.gracePeriodEndsAt
|
||||||
? new Date(summary.gracePeriodEndsAt).toLocaleString()
|
? new Date(summary.gracePeriodEndsAt).toLocaleString()
|
||||||
@@ -358,12 +358,12 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
|
|
||||||
{/* Finalized Banner */}
|
{/* Finalized Banner */}
|
||||||
{summary.isFinalized && (
|
{summary.isFinalized && (
|
||||||
<Card className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
|
<Card className="border-green-200 bg-green-50">
|
||||||
<CardContent className="flex items-center gap-3 py-4">
|
<CardContent className="flex items-center gap-3 py-4">
|
||||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-green-800 dark:text-green-200">Round Finalized</p>
|
<p className="font-medium text-green-800">Round Finalized</p>
|
||||||
<p className="text-sm text-green-600 dark:text-green-400">
|
<p className="text-sm text-green-600">
|
||||||
Finalized on{' '}
|
Finalized on{' '}
|
||||||
{summary.finalizedAt
|
{summary.finalizedAt
|
||||||
? new Date(summary.finalizedAt).toLocaleString()
|
? new Date(summary.finalizedAt).toLocaleString()
|
||||||
@@ -376,13 +376,13 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
|
|
||||||
{/* Needs Processing Banner */}
|
{/* Needs Processing Banner */}
|
||||||
{!summary.isFinalized && !summary.isGracePeriodActive && summary.projects.length > 0 && summary.projects.every((p) => !p.proposedOutcome) && (
|
{!summary.isFinalized && !summary.isGracePeriodActive && summary.projects.length > 0 && summary.projects.every((p) => !p.proposedOutcome) && (
|
||||||
<Card className="border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20">
|
<Card className="border-blue-200 bg-blue-50">
|
||||||
<CardContent className="flex items-center justify-between py-4">
|
<CardContent className="flex items-center justify-between py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<AlertTriangle className="h-5 w-5 text-blue-600" />
|
<AlertTriangle className="h-5 w-5 text-blue-600" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-blue-800 dark:text-blue-200">Projects Need Processing</p>
|
<p className="font-medium text-blue-800">Projects Need Processing</p>
|
||||||
<p className="text-sm text-blue-600 dark:text-blue-400">
|
<p className="text-sm text-blue-600">
|
||||||
{summary.projects.length} project{summary.projects.length !== 1 ? 's' : ''} in this round have no proposed outcome.
|
{summary.projects.length} project{summary.projects.length !== 1 ? 's' : ''} in this round have no proposed outcome.
|
||||||
Click "Process" to auto-assign outcomes based on round type and project activity.
|
Click "Process" to auto-assign outcomes based on round type and project activity.
|
||||||
</p>
|
</p>
|
||||||
@@ -666,7 +666,9 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<label className="text-sm font-medium">Advancement Message</label>
|
<label className="text-sm font-medium">
|
||||||
|
{summary.winnerContext ? 'Winner Message' : 'Advancement Message'}
|
||||||
|
</label>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -681,7 +683,11 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Custom message for projects that are advancing (added to the standard email template)..."
|
placeholder={
|
||||||
|
summary.winnerContext
|
||||||
|
? 'Custom message for winners (added to the standard winner email template)...'
|
||||||
|
: 'Custom message for projects that are advancing (added to the standard email template)...'
|
||||||
|
}
|
||||||
value={advancementMessage}
|
value={advancementMessage}
|
||||||
onChange={(e) => setAdvancementMessage(e.target.value)}
|
onChange={(e) => setAdvancementMessage(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
@@ -715,7 +721,13 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div className="flex items-center justify-between border-t pt-4">
|
<div className="flex items-center justify-between border-t pt-4">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{summary.nextRound ? (
|
{summary.winnerContext ? (
|
||||||
|
<span>
|
||||||
|
<strong>{passedCount}</strong>{' '}
|
||||||
|
{passedCount !== 1 ? 'winners' : 'winner'} will be notified for{' '}
|
||||||
|
<strong>{summary.winnerContext.label}</strong>
|
||||||
|
</span>
|
||||||
|
) : summary.nextRound ? (
|
||||||
<span>
|
<span>
|
||||||
<strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} will advance to{' '}
|
<strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} will advance to{' '}
|
||||||
<strong>{summary.nextRound.name}</strong>
|
<strong>{summary.nextRound.name}</strong>
|
||||||
@@ -751,9 +763,11 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
<ul className="list-disc pl-5 space-y-1">
|
<ul className="list-disc pl-5 space-y-1">
|
||||||
<li>Mark <strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} as <strong>PASSED</strong></li>
|
<li>Mark <strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} as <strong>PASSED</strong></li>
|
||||||
<li>Mark <strong>{rejectedCount}</strong> project{rejectedCount !== 1 ? 's' : ''} as <strong>REJECTED</strong></li>
|
<li>Mark <strong>{rejectedCount}</strong> project{rejectedCount !== 1 ? 's' : ''} as <strong>REJECTED</strong></li>
|
||||||
{summary.nextRound && (
|
{summary.winnerContext ? (
|
||||||
|
<li>Notify <strong>{passedCount}</strong> {passedCount !== 1 ? 'winners' : 'winner'} for <strong>{summary.winnerContext.label}</strong> (no further round)</li>
|
||||||
|
) : summary.nextRound ? (
|
||||||
<li>Advance passed projects to <strong>{summary.nextRound.name}</strong></li>
|
<li>Advance passed projects to <strong>{summary.nextRound.name}</strong></li>
|
||||||
)}
|
) : null}
|
||||||
<li>Send email notifications to all affected teams</li>
|
<li>Send email notifications to all affected teams</li>
|
||||||
</ul>
|
</ul>
|
||||||
{undecidedCount > 0 && (
|
{undecidedCount > 0 && (
|
||||||
|
|||||||
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">
|
<div className="space-y-4">
|
||||||
{/* Finalization hint for closed rounds */}
|
{/* Finalization hint for closed rounds */}
|
||||||
{(roundStatus === 'ROUND_CLOSED' || roundStatus === 'ROUND_ARCHIVED') && (
|
{(roundStatus === 'ROUND_CLOSED' || roundStatus === 'ROUND_ARCHIVED') && (
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20 px-4 py-3 text-sm">
|
<div className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm">
|
||||||
<span className="text-blue-700 dark:text-blue-300">
|
<span className="text-blue-700">
|
||||||
This round is closed. Use the <strong>Finalization</strong> tab to review proposed outcomes and confirm advancement.
|
This round is closed. Use the <strong>Finalization</strong> tab to review proposed outcomes and confirm advancement.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -785,7 +785,7 @@ function QuickAddDialog({
|
|||||||
* Create New: form to create a project and assign it directly to the round.
|
* Create New: form to create a project and assign it directly to the round.
|
||||||
* From Pool: search existing projects not yet in this round and assign them.
|
* From Pool: search existing projects not yet in this round and assign them.
|
||||||
*/
|
*/
|
||||||
function AddProjectDialog({
|
export function AddProjectDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
roundId,
|
roundId,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { Textarea } from '@/components/ui/textarea'
|
|||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { ScoreExplainerDialog } from '@/components/shared/score-explainer-dialog'
|
import { ScoreExplainerDialog } from '@/components/shared/score-explainer-dialog'
|
||||||
|
import { csvCell } from '@/lib/csv'
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@@ -652,16 +653,9 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
}
|
}
|
||||||
const headers = result.columns
|
const headers = result.columns
|
||||||
const csvRows = [
|
const csvRows = [
|
||||||
headers.join(','),
|
headers.map((h: string) => csvCell(h)).join(','),
|
||||||
...result.data.map((row: Record<string, unknown>) =>
|
...result.data.map((row: Record<string, unknown>) =>
|
||||||
headers.map((h: string) => {
|
headers.map((h: string) => csvCell(row[h])).join(','),
|
||||||
const val = row[h]
|
|
||||||
if (val == null) return ''
|
|
||||||
const str = String(val)
|
|
||||||
return str.includes(',') || str.includes('"') || str.includes('\n')
|
|
||||||
? `"${str.replace(/"/g, '""')}"`
|
|
||||||
: str
|
|
||||||
}).join(','),
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' })
|
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' })
|
||||||
@@ -705,7 +699,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
This may take a minute. You can continue working — results will appear automatically.
|
This may take a minute. You can continue working — results will appear automatically.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 w-48 rounded-full bg-blue-100 dark:bg-blue-900 overflow-hidden">
|
<div className="h-2 w-48 rounded-full bg-blue-100 overflow-hidden">
|
||||||
<div className="h-full w-full rounded-full bg-blue-500 animate-pulse" />
|
<div className="h-full w-full rounded-full bg-blue-500 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -968,18 +962,18 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
|
|
||||||
{/* Ranking in-progress banner */}
|
{/* Ranking in-progress banner */}
|
||||||
{rankingInProgress && (
|
{rankingInProgress && (
|
||||||
<Card className="border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30">
|
<Card className="border-blue-200 bg-blue-50">
|
||||||
<CardContent className="flex items-center gap-3 py-4">
|
<CardContent className="flex items-center gap-3 py-4">
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
<Loader2 className="h-5 w-5 animate-spin text-blue-600 flex-shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-blue-900 dark:text-blue-200">
|
<p className="text-sm font-medium text-blue-900">
|
||||||
Ranking in progress…
|
Ranking in progress…
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-blue-700 dark:text-blue-400">
|
<p className="text-xs text-blue-700">
|
||||||
This may take a minute. You can continue working — results will appear automatically.
|
This may take a minute. You can continue working — results will appear automatically.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 w-32 rounded-full bg-blue-200 dark:bg-blue-800 overflow-hidden flex-shrink-0">
|
<div className="h-1.5 w-32 rounded-full bg-blue-200 overflow-hidden flex-shrink-0">
|
||||||
<div className="h-full w-full rounded-full bg-blue-500 animate-pulse" />
|
<div className="h-full w-full rounded-full bg-blue-500 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -1103,7 +1097,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -20 }}
|
exit={{ opacity: 0, y: -20 }}
|
||||||
className={isAdvancing
|
className={isAdvancing
|
||||||
? 'rounded-lg bg-emerald-50 border-l-4 border-emerald-400 dark:bg-emerald-950/20 dark:border-emerald-600'
|
? 'rounded-lg bg-emerald-50 border-l-4 border-emerald-400'
|
||||||
: ''}
|
: ''}
|
||||||
>
|
>
|
||||||
<SortableProjectRow
|
<SortableProjectRow
|
||||||
@@ -1126,7 +1120,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
{isCutoffRow && (
|
{isCutoffRow && (
|
||||||
<div className="flex items-center gap-2 py-1">
|
<div className="flex items-center gap-2 py-1">
|
||||||
<div className="flex-1 border-t-2 border-dashed border-emerald-400/60" />
|
<div className="flex-1 border-t-2 border-dashed border-emerald-400/60" />
|
||||||
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400 whitespace-nowrap">
|
<span className="text-xs font-medium text-emerald-600 whitespace-nowrap">
|
||||||
Advancement cutoff — {isThresholdMode ? `Score ≥ ${threshold}` : `Top ${advanceCount}`}
|
Advancement cutoff — {isThresholdMode ? `Score ≥ ${threshold}` : `Top ${advanceCount}`}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 border-t-2 border-dashed border-emerald-400/60" />
|
<div className="flex-1 border-t-2 border-dashed border-emerald-400/60" />
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ export function MentoringConfig({ config, onChange }: MentoringConfigProps) {
|
|||||||
<SelectItem value="admin_selected">Admin Selected</SelectItem>
|
<SelectItem value="admin_selected">Admin Selected</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<ul className="text-xs text-muted-foreground space-y-1 mt-2">
|
||||||
|
<li><strong>All Advancing Projects</strong> — every project that enters this round is paired with a mentor.</li>
|
||||||
|
<li><strong>Requested Only</strong> — only projects that explicitly request mentoring participate (default).</li>
|
||||||
|
<li><strong>Admin Selected</strong> — admin manually picks which projects get a mentor.</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -56,6 +61,46 @@ export function MentoringConfig({ config, onChange }: MentoringConfigProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Mentoring Request Window</CardTitle>
|
||||||
|
<CardDescription>How long teams have to request a mentor, and what happens to non-requesters</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="mentoringRequestDeadlineDays">Request deadline (days from round opening)</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">After this many days, teams can no longer submit a mentoring request. Default: 14.</p>
|
||||||
|
<Input
|
||||||
|
id="mentoringRequestDeadlineDays"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={90}
|
||||||
|
className="w-32"
|
||||||
|
value={(config.mentoringRequestDeadlineDays as number) ?? 14}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = parseInt(e.target.value, 10)
|
||||||
|
if (!Number.isNaN(v) && v >= 1 && v <= 90) update('mentoringRequestDeadlineDays', v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="passThroughIfNoRequest">Auto-pass non-requesters</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
When ON, projects that don't request mentoring auto-PASS to the next round (default).
|
||||||
|
When OFF, all projects are held in PENDING until the admin decides — useful when mentoring is mandatory.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="passThroughIfNoRequest"
|
||||||
|
checked={(config.passThroughIfNoRequest as boolean | undefined) ?? true}
|
||||||
|
onCheckedChange={(v) => update('passThroughIfNoRequest', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Communication & Files</CardTitle>
|
<CardTitle className="text-base">Communication & Files</CardTitle>
|
||||||
|
|||||||
226
src/components/admin/settings/edition-settings-tab.tsx
Normal file
226
src/components/admin/settings/edition-settings-tab.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { useEdition } from '@/contexts/edition-context'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Loader2, ScrollText, Stamp, Users } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
function NumberField({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
value,
|
||||||
|
onCommit,
|
||||||
|
disabled,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
value: number | null
|
||||||
|
onCommit: (next: number) => void
|
||||||
|
disabled?: boolean
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState<string>(value != null ? String(value) : '')
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(value != null ? String(value) : '')
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={draft}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const parsed = Number(draft)
|
||||||
|
if (!Number.isFinite(parsed) || parsed === value) return
|
||||||
|
if (min !== undefined && parsed < min) return
|
||||||
|
if (max !== undefined && parsed > max) return
|
||||||
|
onCommit(parsed)
|
||||||
|
}}
|
||||||
|
className="max-w-[12rem]"
|
||||||
|
/>
|
||||||
|
{hint && <p className="text-muted-foreground text-xs">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditionSettingsTab() {
|
||||||
|
const { currentEdition } = useEdition()
|
||||||
|
const programId = currentEdition?.id ?? null
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const { data, isLoading } = trpc.program.getEditionSettings.useQuery(
|
||||||
|
{ programId: programId ?? '' },
|
||||||
|
{ enabled: !!programId },
|
||||||
|
)
|
||||||
|
const update = trpc.program.updateEditionSettings.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
if (programId) utils.program.getEditionSettings.invalidate({ programId })
|
||||||
|
toast.success('Edition settings updated')
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!programId) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Select an edition from the sidebar dropdown to manage settings.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const noLiveFinalRound = data.liveFinalRoundId == null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Grand-finale logistics */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2.5 text-base">
|
||||||
|
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
||||||
|
<Users className="h-4 w-4 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
Grand-finale logistics
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Per-edition limits and deadlines that drive finalist confirmation, attendee
|
||||||
|
editing, and visa visibility.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<NumberField
|
||||||
|
id="default-attendee-cap"
|
||||||
|
label="Default attendee cap"
|
||||||
|
hint="Maximum number of team members allowed at the grand finale per finalist team."
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={data.defaultAttendeeCap}
|
||||||
|
disabled={update.isPending}
|
||||||
|
onCommit={(next) =>
|
||||||
|
update.mutate({ programId, defaultAttendeeCap: next })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
id="confirmation-window-hours"
|
||||||
|
label="Confirmation window (hours)"
|
||||||
|
hint={
|
||||||
|
noLiveFinalRound
|
||||||
|
? 'Will be editable once the grand-finale (LIVE_FINAL) round is created.'
|
||||||
|
: 'How long teams have to click the confirm/decline link after we send it.'
|
||||||
|
}
|
||||||
|
min={1}
|
||||||
|
max={720}
|
||||||
|
value={data.confirmationWindowHours}
|
||||||
|
disabled={update.isPending || noLiveFinalRound}
|
||||||
|
onCommit={(next) =>
|
||||||
|
update.mutate({ programId, confirmationWindowHours: next })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
id="attendee-edit-cutoff-hours"
|
||||||
|
label="Attendee edit cutoff (hours before grand finale)"
|
||||||
|
hint={
|
||||||
|
noLiveFinalRound
|
||||||
|
? 'Will be editable once the grand-finale (LIVE_FINAL) round is created.'
|
||||||
|
: 'After this many hours before the grand finale opens, the team lead can no longer change attendees.'
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
max={720}
|
||||||
|
value={data.attendeeEditCutoffHours}
|
||||||
|
disabled={update.isPending || noLiveFinalRound}
|
||||||
|
onCommit={(next) =>
|
||||||
|
update.mutate({ programId, attendeeEditCutoffHours: next })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Visa */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2.5 text-base">
|
||||||
|
<div className="rounded-lg bg-sky-500/10 p-1.5">
|
||||||
|
<Stamp className="h-4 w-4 text-sky-500" />
|
||||||
|
</div>
|
||||||
|
Visa
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Visa documents are exchanged over email and never stored on the platform —
|
||||||
|
we track only process metadata. Choose whether teams see their own status.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="visa-visibility-edition">Visible to teams</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
When on, attendees with needsVisa=true see their status on the
|
||||||
|
applicant dashboard. When off, only admins see the workflow.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="visa-visibility-edition"
|
||||||
|
checked={data.visaStatusVisibleToMembers}
|
||||||
|
disabled={update.isPending}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
update.mutate({ programId, visaStatusVisibleToMembers: v })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Coming soon */}
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-muted-foreground text-base">Coming soon</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Editable email templates land in an upcoming update and will surface here.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-muted-foreground space-y-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ScrollText className="h-4 w-4" /> Email templates — editable subject + body
|
||||||
|
for confirmation, decline-cascade, mentor onboarding, etc.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{update.isPending && (
|
||||||
|
<div className="text-muted-foreground inline-flex items-center gap-2 text-xs">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" /> Saving…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
210
src/components/applicant/attending-members-card.tsx
Normal file
210
src/components/applicant/attending-members-card.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { PlaneTakeoff, ShieldCheck, AlertTriangle } from 'lucide-react'
|
||||||
|
import { EditAttendeesDialog } from './edit-attendees-dialog'
|
||||||
|
import { LunchPickForm } from './lunch-pick-form'
|
||||||
|
import type { VisaStatus } from '@prisma/client'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
|
|
||||||
|
const VISA_BADGE: Record<
|
||||||
|
VisaStatus,
|
||||||
|
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
|
||||||
|
> = {
|
||||||
|
NOT_NEEDED: { label: 'Visa not needed', variant: 'outline' },
|
||||||
|
REQUESTED: { label: 'Visa requested', variant: 'secondary' },
|
||||||
|
INVITATION_SENT: { label: 'Invitation sent', variant: 'secondary' },
|
||||||
|
APPOINTMENT_BOOKED: { label: 'Appointment booked', variant: 'default' },
|
||||||
|
GRANTED: { label: 'Visa granted', variant: 'default' },
|
||||||
|
DENIED: { label: 'Visa denied', variant: 'destructive' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateOnly(d: Date | string): string {
|
||||||
|
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextVisaDate(v: {
|
||||||
|
invitationSentAt: Date | string | null
|
||||||
|
appointmentAt: Date | string | null
|
||||||
|
decisionAt: Date | string | null
|
||||||
|
status: VisaStatus
|
||||||
|
}): { label: string; date: Date | string } | null {
|
||||||
|
if (v.status === 'GRANTED' || v.status === 'DENIED') {
|
||||||
|
if (v.decisionAt) return { label: 'Decision', date: v.decisionAt }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (v.appointmentAt) return { label: 'Appointment', date: v.appointmentAt }
|
||||||
|
if (v.invitationSentAt) return { label: 'Invitation sent', date: v.invitationSentAt }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttendingMembersCard() {
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const { data, isLoading } = trpc.applicant.getMyFinalistConfirmation.useQuery()
|
||||||
|
const { data: myVisas } = trpc.applicant.getMyVisaApplications.useQuery()
|
||||||
|
const programId = data?.project.programId
|
||||||
|
const { data: lunchEvent } = trpc.lunch.getEventForMember.useQuery(
|
||||||
|
{ programId: programId ?? '' },
|
||||||
|
{ enabled: !!programId },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.confirmation.status !== 'CONFIRMED') return null
|
||||||
|
|
||||||
|
const cutoffAt = data.cutoffAt ? new Date(data.cutoffAt) : null
|
||||||
|
const userById = new Map(data.project.teamMembers.map((tm) => [tm.userId, tm.user]))
|
||||||
|
const attendees = data.confirmation.attendingMembers
|
||||||
|
const visaByUser = new Map(
|
||||||
|
(myVisas ?? []).map((v) => [v.userId, v] as const),
|
||||||
|
)
|
||||||
|
|
||||||
|
const editDisabled = !data.editableNow
|
||||||
|
const editDisabledReason = !data.editableNow
|
||||||
|
? 'Attendee changes are closed for this edition.'
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||||
|
<div className="rounded-lg bg-sky-500/10 p-1.5">
|
||||||
|
<PlaneTakeoff className="h-4 w-4 text-sky-500" />
|
||||||
|
</div>
|
||||||
|
Grand Finale Attendees
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
Team members confirmed to travel to Monaco
|
||||||
|
{cutoffAt && data.editableNow && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
· editable until{' '}
|
||||||
|
<strong>
|
||||||
|
{new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(cutoffAt)}
|
||||||
|
</strong>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{cutoffAt && !data.editableNow && (
|
||||||
|
<span className="text-muted-foreground inline-flex items-center gap-1">
|
||||||
|
{' '}
|
||||||
|
· <AlertTriangle className="h-3 w-3" /> editing closed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{data.isLead && (
|
||||||
|
<EditAttendeesDialog
|
||||||
|
confirmationId={data.confirmation.id}
|
||||||
|
cap={data.project.program.defaultAttendeeCap}
|
||||||
|
teamMembers={data.project.teamMembers}
|
||||||
|
attendingMembers={attendees}
|
||||||
|
cutoffAt={cutoffAt}
|
||||||
|
disabled={editDisabled}
|
||||||
|
disabledReason={editDisabledReason}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{attendees.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">No attendees selected yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{attendees.map((a) => {
|
||||||
|
const user = userById.get(a.userId)
|
||||||
|
if (!user) return null
|
||||||
|
const visa = visaByUser.get(a.userId)
|
||||||
|
const visaBadge = visa ? VISA_BADGE[visa.status] : null
|
||||||
|
const next = visa ? nextVisaDate(visa) : null
|
||||||
|
const sessionUserId = session?.user?.id
|
||||||
|
const sessionRole = session?.user?.role
|
||||||
|
const isAdmin =
|
||||||
|
sessionRole === 'SUPER_ADMIN' || sessionRole === 'PROGRAM_ADMIN'
|
||||||
|
const isSelf = sessionUserId === a.userId
|
||||||
|
const isLeadActing = data.isLead && !isSelf
|
||||||
|
const lunchDeadline = lunchEvent?.changeDeadline
|
||||||
|
? new Date(lunchEvent.changeDeadline)
|
||||||
|
: null
|
||||||
|
const lunchPastDeadline =
|
||||||
|
!!lunchDeadline && new Date() > lunchDeadline
|
||||||
|
const canEditLunch =
|
||||||
|
!!lunchEvent &&
|
||||||
|
((isSelf && !lunchPastDeadline) ||
|
||||||
|
(data.isLead && !lunchPastDeadline) ||
|
||||||
|
isAdmin)
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={a.userId}
|
||||||
|
className="space-y-3 rounded-md border px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{user.name ?? user.email}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
{visa && visaBadge ? (
|
||||||
|
<>
|
||||||
|
<Badge variant={visaBadge.variant} className="gap-1">
|
||||||
|
<ShieldCheck className="h-3 w-3" />
|
||||||
|
{visaBadge.label}
|
||||||
|
</Badge>
|
||||||
|
{next && (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{next.label}: {formatDateOnly(next.date)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
a.needsVisa && (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<ShieldCheck className="h-3 w-3" />
|
||||||
|
Visa support
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{lunchEvent && programId && (
|
||||||
|
<LunchPickForm
|
||||||
|
attendingMemberId={a.id}
|
||||||
|
programId={programId}
|
||||||
|
lunchEventId={lunchEvent.id}
|
||||||
|
canEdit={canEditLunch}
|
||||||
|
editingOnBehalfOf={
|
||||||
|
isLeadActing ? (user.name ?? user.email) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
182
src/components/applicant/edit-attendees-dialog.tsx
Normal file
182
src/components/applicant/edit-attendees-dialog.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Loader2, Pencil } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
type TeamMember = {
|
||||||
|
userId: string
|
||||||
|
role: string
|
||||||
|
user: { id: string; name: string | null; email: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttendingMember = { userId: string; needsVisa: boolean }
|
||||||
|
|
||||||
|
export function EditAttendeesDialog({
|
||||||
|
confirmationId,
|
||||||
|
cap,
|
||||||
|
teamMembers,
|
||||||
|
attendingMembers,
|
||||||
|
cutoffAt,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
}: {
|
||||||
|
confirmationId: string
|
||||||
|
cap: number
|
||||||
|
teamMembers: TeamMember[]
|
||||||
|
attendingMembers: AttendingMember[]
|
||||||
|
cutoffAt: Date | null
|
||||||
|
disabled?: boolean
|
||||||
|
disabledReason?: string
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const [visa, setVisa] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const edit = trpc.finalist.editAttendees.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Attendees updated')
|
||||||
|
utils.applicant.getMyFinalistConfirmation.invalidate()
|
||||||
|
setOpen(false)
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset form to current roster when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelected(new Set(attendingMembers.map((m) => m.userId)))
|
||||||
|
setVisa(
|
||||||
|
Object.fromEntries(attendingMembers.map((m) => [m.userId, m.needsVisa])),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [open, attendingMembers])
|
||||||
|
|
||||||
|
const toggle = (userId: string, checked: boolean) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (checked) next.add(userId)
|
||||||
|
else next.delete(userId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const overCap = selected.size > cap
|
||||||
|
const noneSelected = selected.size === 0
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const ids = Array.from(selected)
|
||||||
|
edit.mutate({
|
||||||
|
confirmationId,
|
||||||
|
attendingUserIds: ids,
|
||||||
|
visaFlags: Object.fromEntries(ids.map((id) => [id, !!visa[id]])),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!edit.isPending) setOpen(next)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" disabled={disabled} title={disabledReason}>
|
||||||
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit attendees
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit attendees</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update who from your team will travel to the grand finale. You can select up to{' '}
|
||||||
|
<strong>{cap}</strong> team members. Mark anyone who needs visa support so we can prepare
|
||||||
|
documents in time.
|
||||||
|
{cutoffAt && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
Editable until{' '}
|
||||||
|
<strong>
|
||||||
|
{new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(cutoffAt)}
|
||||||
|
</strong>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ul className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
|
||||||
|
{teamMembers.map((tm) => {
|
||||||
|
const checked = selected.has(tm.userId)
|
||||||
|
return (
|
||||||
|
<li key={tm.userId} className="flex items-start justify-between gap-4">
|
||||||
|
<label className="flex flex-1 items-start gap-3 cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(c) => toggle(tm.userId, c === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{tm.user.name ?? tm.user.email}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{tm.user.email}
|
||||||
|
{tm.role && tm.role !== 'MEMBER' ? ` · ${tm.role.toLowerCase()}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{checked && (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">Needs visa?</span>
|
||||||
|
<Switch
|
||||||
|
checked={!!visa[tm.userId]}
|
||||||
|
onCheckedChange={(c) =>
|
||||||
|
setVisa((prev) => ({ ...prev, [tm.userId]: c }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{overCap && (
|
||||||
|
<p className="text-destructive text-sm">
|
||||||
|
Please select no more than {cap} member{cap === 1 ? '' : 's'}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)} disabled={edit.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={overCap || noneSelected || edit.isPending}
|
||||||
|
>
|
||||||
|
{edit.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Save attendees
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user