Compare commits
100 Commits
f055926b6f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b40fe7726 | |||
| 1c78ecf21d | |||
| 1356809cb1 | |||
| 1ebdf5f9c9 | |||
| a68ec3fb45 | |||
| 6f55fdf81f | |||
| 94cbfec70a | |||
| b85a9b9a7b | |||
| a8b8643936 | |||
| 0390d05727 | |||
| ec30dc83d6 | |||
| 37351044ed | |||
| a1e758bc39 | |||
| a556732b46 | |||
| e7b99fff63 | |||
| 3180bfa946 | |||
| d4c946470a | |||
| 2e8ab91e07 | |||
| 60426c1f56 | |||
| 8427999578 | |||
| a358e9940d | |||
| 34fc0b81e0 | |||
| ea46d7293f | |||
| 0d9a985377 | |||
| 6852278f92 | |||
| 22731e7978 | |||
| 0d94ee1fe8 | |||
| ffe12a9e85 | |||
| 94814bd505 | |||
| 6b6f5e33f5 | |||
| 67670472f7 | |||
| 461551b489 | |||
| b7905a82e1 | |||
| fd2624f198 | |||
| 2be7318cb9 | |||
| 8d6f3ca11f | |||
| 12e4864d36 | |||
| abb6e6df83 | |||
| 8cdcc85555 | |||
| ee8e90132e | |||
| b6ba5d7145 | |||
| c6d0f90038 | |||
| 78334676d0 | |||
| 335c736219 | |||
| ca888b4eb7 | |||
| 27ecbc40b3 | |||
| 875c2e8f48 | |||
| 13f125af28 | |||
| c8c26beed2 | |||
| 503a375701 | |||
| 79ac60dc1e | |||
| 6c52e519e5 | |||
| b1a994a9d6 | |||
| f0d5599167 | |||
| 43e21c6c6e | |||
| af03c12ae5 | |||
| 267d26581d | |||
| a39e27f6ff | |||
| 1103d42439 | |||
| f24bea3df2 | |||
| 8f2f054c57 | |||
| 5854aa37a9 | |||
| ebc6331d1f | |||
| d183d98d9a | |||
| 84d90e1978 | |||
| daf50831f1 | |||
| 1d4e31ddd1 | |||
| 924f8071e1 | |||
| f79a6d1341 | |||
| 050836d522 | |||
| 43801340f8 | |||
| 2be3f9d02f | |||
| cfee3bc8a9 | |||
| 7735f3ecdf | |||
| 0285622fe1 | |||
| c0f2b9bd38 | |||
| 8c5f4998a8 | |||
| 761a203063 | |||
| cb688ba3e6 | |||
| ac86e025e2 | |||
| 5a3f8d9837 | |||
| 2bccb52a16 | |||
| 1f4f29c2cc | |||
| 8db9c72f4c | |||
| 80a7bedddc | |||
| d2e0dbdc94 | |||
| 36045bef9d | |||
| 2df9c54de2 | |||
| 19b58e4434 | |||
| c6ebd169dd | |||
| 49e706f2cf | |||
| 68aa393559 | |||
| 9b3a9f6cbf | |||
| dd004baf79 | |||
| 2f1136646e | |||
| 36560a1837 | |||
| 25e06e11e4 | |||
| f200eda692 | |||
| ba7f068b1e | |||
| 28ae934c57 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -58,3 +58,7 @@ build-output.txt
|
|||||||
# Misc
|
# Misc
|
||||||
*.log
|
*.log
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
# Private keys and secrets
|
||||||
|
private/
|
||||||
|
public/build-id.json
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ COPY . .
|
|||||||
# Generate Prisma client
|
# Generate Prisma client
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Build Next.js
|
# Build Next.js — mount .next/cache as a Docker build cache for faster rebuilds
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
RUN npm run build
|
RUN --mount=type=cache,target=/app/.next/cache npm run build
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Production image, copy all the files and run next
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
@@ -69,5 +69,8 @@ EXPOSE 7600
|
|||||||
ENV PORT=7600
|
ENV PORT=7600
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:7600/api/health || exit 1
|
||||||
|
|
||||||
# Run via entrypoint (migrate then start)
|
# Run via entrypoint (migrate then start)
|
||||||
CMD ["/app/docker-entrypoint.sh"]
|
CMD ["/app/docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: mopc-postgres-dev
|
container_name: mopc-postgres-dev
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5433:5432"
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=${POSTGRES_USER:-mopc}
|
- POSTGRES_USER=${POSTGRES_USER:-mopc}
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-devpassword}
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-devpassword}
|
||||||
@@ -68,7 +68,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ../.env
|
- ../.env
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-mopc}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-mopc}
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-mopc}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-mopc}?connection_limit=10&pool_timeout=30
|
||||||
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
|
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
|
||||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-dev-secret-key-for-local-development-only}
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-dev-secret-key-for-local-development-only}
|
||||||
- AUTH_SECRET=${AUTH_SECRET:-dev-secret-key-for-local-development-only}
|
- AUTH_SECRET=${AUTH_SECRET:-dev-secret-key-for-local-development-only}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc
|
- DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc?connection_limit=10&pool_timeout=30
|
||||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||||
- AUTH_SECRET=${NEXTAUTH_SECRET}
|
- AUTH_SECRET=${NEXTAUTH_SECRET}
|
||||||
@@ -50,6 +50,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- mopc-network
|
- mopc-network
|
||||||
|
- minio-external
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:7600/api/health').then(r=>{if(!r.ok)throw r;process.exit(0)}).catch(()=>process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://localhost:7600/api/health').then(r=>{if(!r.ok)throw r;process.exit(0)}).catch(()=>process.exit(1))"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -82,3 +83,6 @@ volumes:
|
|||||||
networks:
|
networks:
|
||||||
mopc-network:
|
mopc-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
minio-external:
|
||||||
|
external: true
|
||||||
|
name: minio_mopc-minio
|
||||||
|
|||||||
@@ -37,5 +37,40 @@ fi
|
|||||||
echo "==> Syncing notification email settings..."
|
echo "==> Syncing notification email settings..."
|
||||||
npx tsx prisma/seed-notification-settings.ts || echo "WARNING: Notification settings sync failed."
|
npx tsx prisma/seed-notification-settings.ts || echo "WARNING: Notification settings sync failed."
|
||||||
|
|
||||||
|
# Sync team lead links only if there are unlinked submitters
|
||||||
|
UNLINKED_COUNT=$(node -e "
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient();
|
||||||
|
p.\$queryRaw\`
|
||||||
|
SELECT COUNT(*)::int AS c FROM \"Project\" p
|
||||||
|
WHERE p.\"submittedByUserId\" IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM \"TeamMember\" tm
|
||||||
|
WHERE tm.\"projectId\" = p.id AND tm.\"userId\" = p.\"submittedByUserId\"
|
||||||
|
)
|
||||||
|
\`.then(r => { console.log(r[0].c); p.\$disconnect(); }).catch(() => { console.log('0'); p.\$disconnect(); });
|
||||||
|
" 2>/dev/null || echo "0")
|
||||||
|
|
||||||
|
if [ "$UNLINKED_COUNT" != "0" ]; then
|
||||||
|
echo "==> Syncing ${UNLINKED_COUNT} unlinked team lead links..."
|
||||||
|
npx tsx prisma/seed-team-leads.ts || echo "WARNING: Team lead sync failed."
|
||||||
|
else
|
||||||
|
echo "==> Team lead links already synced, skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Starting application..."
|
echo "==> Starting application..."
|
||||||
exec node server.js
|
|
||||||
|
# Graceful shutdown: forward SIGTERM/SIGINT to the Node process
|
||||||
|
# so in-flight requests can complete before the container exits.
|
||||||
|
shutdown() {
|
||||||
|
echo "==> Received shutdown signal, stopping gracefully..."
|
||||||
|
kill -TERM "$NODE_PID" 2>/dev/null
|
||||||
|
wait "$NODE_PID"
|
||||||
|
exit $?
|
||||||
|
}
|
||||||
|
|
||||||
|
trap shutdown TERM INT
|
||||||
|
|
||||||
|
node server.js &
|
||||||
|
NODE_PID=$!
|
||||||
|
wait "$NODE_PID"
|
||||||
|
|||||||
@@ -2,10 +2,20 @@ import type { NextConfig } from 'next'
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
typedRoutes: true,
|
|
||||||
serverExternalPackages: ['@prisma/client', 'minio'],
|
serverExternalPackages: ['@prisma/client', 'minio'],
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: false,
|
||||||
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ['lucide-react'],
|
optimizePackageImports: [
|
||||||
|
'lucide-react',
|
||||||
|
'sonner',
|
||||||
|
'date-fns',
|
||||||
|
'recharts',
|
||||||
|
'motion/react',
|
||||||
|
'zod',
|
||||||
|
'@radix-ui/react-icons',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
|
|||||||
1995
package-lock.json
generated
1995
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
"prebuild": "node -e \"require('fs').writeFileSync('public/build-id.json', JSON.stringify({buildId: Date.now().toString()}))\"",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
@@ -95,6 +96,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.49.1",
|
"@playwright/test": "^1.49.1",
|
||||||
|
"@react-grab/mcp": "^0.1.25",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
@@ -109,6 +111,7 @@
|
|||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
|
"react-grab": "^0.1.25",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "nationality" TEXT;
|
||||||
|
ALTER TABLE "User" ADD COLUMN "institution" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "RankingMode" ADD VALUE 'FORMULA';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "AwardEligibility" ADD COLUMN "notifiedAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
-- Round finalization fields
|
||||||
|
ALTER TABLE "Round" ADD COLUMN "gracePeriodEndsAt" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "Round" ADD COLUMN "finalizedAt" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "Round" ADD COLUMN "finalizedBy" TEXT;
|
||||||
|
|
||||||
|
-- ProjectRoundState proposed outcome for finalization pool
|
||||||
|
ALTER TABLE "ProjectRoundState" ADD COLUMN "proposedOutcome" "ProjectRoundStateValue";
|
||||||
|
|
||||||
|
-- Mark already-closed rounds as pre-finalized IF their projects were already
|
||||||
|
-- advanced to the IMMEDIATELY NEXT round (sortOrder = current + 1).
|
||||||
|
-- We check the next sequential round only, not any subsequent round, because
|
||||||
|
-- projects can appear in non-adjacent rounds (e.g. special award tracks) without
|
||||||
|
-- implying the current round was finalized.
|
||||||
|
UPDATE "Round" r
|
||||||
|
SET "finalizedAt" = NOW(), "finalizedBy" = 'system-migration'
|
||||||
|
WHERE r.status IN ('ROUND_CLOSED', 'ROUND_ARCHIVED')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "Round" next_r
|
||||||
|
JOIN "ProjectRoundState" next_prs ON next_prs."roundId" = next_r.id
|
||||||
|
JOIN "ProjectRoundState" cur_prs ON cur_prs."roundId" = r.id
|
||||||
|
AND cur_prs."projectId" = next_prs."projectId"
|
||||||
|
WHERE next_r."competitionId" = r."competitionId"
|
||||||
|
AND next_r."sortOrder" = r."sortOrder" + 1
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ─── Backfill terminal states for already-finalized rounds ───────────────────
|
||||||
|
-- These rounds were finalized manually before this system existed.
|
||||||
|
-- Set ProjectRoundState to accurate terminal states so the data matches reality.
|
||||||
|
-- All updates are guarded by current state + round type to avoid touching anything unexpected.
|
||||||
|
|
||||||
|
-- R0 (INTAKE, closed): All 214 projects completed intake successfully → PASSED
|
||||||
|
-- Guard: only touch COMPLETED states in closed INTAKE rounds marked as finalized
|
||||||
|
UPDATE "ProjectRoundState" prs
|
||||||
|
SET state = 'PASSED', "proposedOutcome" = 'PASSED'
|
||||||
|
FROM "Round" r
|
||||||
|
WHERE prs."roundId" = r.id
|
||||||
|
AND r."roundType" = 'INTAKE'
|
||||||
|
AND r.status = 'ROUND_CLOSED'
|
||||||
|
AND r."finalizedAt" IS NOT NULL
|
||||||
|
AND prs.state = 'COMPLETED';
|
||||||
|
|
||||||
|
-- R1 (FILTERING, closed): Set states based on FilteringResult outcomes
|
||||||
|
-- Projects that passed filtering → PASSED
|
||||||
|
UPDATE "ProjectRoundState" prs
|
||||||
|
SET state = 'PASSED', "proposedOutcome" = 'PASSED'
|
||||||
|
FROM "Round" r
|
||||||
|
WHERE prs."roundId" = r.id
|
||||||
|
AND r."roundType" = 'FILTERING'
|
||||||
|
AND r.status = 'ROUND_CLOSED'
|
||||||
|
AND r."finalizedAt" IS NOT NULL
|
||||||
|
AND prs.state = 'PENDING'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM "FilteringResult" fr
|
||||||
|
WHERE fr."projectId" = prs."projectId"
|
||||||
|
AND (
|
||||||
|
fr."finalOutcome" = 'PASSED'
|
||||||
|
OR (fr."finalOutcome" IS NULL AND fr.outcome IN ('PASSED', 'FLAGGED'))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Projects that were filtered out → REJECTED
|
||||||
|
UPDATE "ProjectRoundState" prs
|
||||||
|
SET state = 'REJECTED', "proposedOutcome" = 'REJECTED'
|
||||||
|
FROM "Round" r
|
||||||
|
WHERE prs."roundId" = r.id
|
||||||
|
AND r."roundType" = 'FILTERING'
|
||||||
|
AND r.status = 'ROUND_CLOSED'
|
||||||
|
AND r."finalizedAt" IS NOT NULL
|
||||||
|
AND prs.state = 'PENDING'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM "FilteringResult" fr
|
||||||
|
WHERE fr."projectId" = prs."projectId"
|
||||||
|
AND (
|
||||||
|
fr."finalOutcome" = 'FILTERED_OUT'
|
||||||
|
OR (fr."finalOutcome" IS NULL AND fr.outcome = 'FILTERED_OUT')
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "NotificationLog" DROP CONSTRAINT "NotificationLog_userId_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "NotificationLog" ADD COLUMN "batchId" TEXT,
|
||||||
|
ADD COLUMN "email" TEXT,
|
||||||
|
ADD COLUMN "projectId" TEXT,
|
||||||
|
ADD COLUMN "roundId" TEXT,
|
||||||
|
ALTER COLUMN "userId" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "channel" SET DEFAULT 'EMAIL';
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_roundId_type_idx" ON "NotificationLog"("roundId", "type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_projectId_idx" ON "NotificationLog"("projectId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_batchId_idx" ON "NotificationLog"("batchId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_email_idx" ON "NotificationLog"("email");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "passwordResetToken" TEXT,
|
||||||
|
ADD COLUMN "passwordResetExpiresAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_passwordResetToken_key" ON "User"("passwordResetToken");
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'APPLICANT';
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Assignment_roundId_isCompleted_idx" ON "Assignment"("roundId", "isCompleted");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConflictOfInterest_projectId_idx" ON "ConflictOfInterest"("projectId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConflictOfInterest_userId_hasConflict_idx" ON "ConflictOfInterest"("userId", "hasConflict");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_type_status_idx" ON "NotificationLog"("type", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProjectRoundState_roundId_state_idx" ON "ProjectRoundState"("roundId", "state");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RankingSnapshot_roundId_createdAt_idx" ON "RankingSnapshot"("roundId", "createdAt");
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "AdvancementRule" DROP CONSTRAINT "AdvancementRule_roundId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "AssignmentException" DROP CONSTRAINT "AssignmentException_approvedById_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "AssignmentException" DROP CONSTRAINT "AssignmentException_assignmentId_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ConflictOfInterest" DROP COLUMN "roundId";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Evaluation" DROP COLUMN "version";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Project" DROP COLUMN "roundId";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "AdvancementRule";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "AssignmentException";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "NotificationPolicy";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "OverrideAction";
|
||||||
|
|
||||||
|
-- DropEnum
|
||||||
|
DROP TYPE "AdvancementRuleType";
|
||||||
|
|
||||||
|
-- DropEnum
|
||||||
|
DROP TYPE "OverrideReasonCode";
|
||||||
@@ -11,6 +11,10 @@ generator client {
|
|||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
// connection_limit and pool_timeout are set via query params in DATABASE_URL:
|
||||||
|
// ?connection_limit=10&pool_timeout=30
|
||||||
|
// Defaults: connection_limit = num_cpus * 2 + 1, pool_timeout = 10s.
|
||||||
|
// Override in .env for production to prevent connection exhaustion.
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,13 +134,6 @@ enum PartnerType {
|
|||||||
OTHER
|
OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OverrideReasonCode {
|
|
||||||
DATA_CORRECTION
|
|
||||||
POLICY_EXCEPTION
|
|
||||||
JURY_CONFLICT
|
|
||||||
SPONSOR_DECISION
|
|
||||||
ADMIN_DISCRETION
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// COMPETITION / ROUND ENGINE ENUMS
|
// COMPETITION / ROUND ENGINE ENUMS
|
||||||
@@ -175,13 +172,6 @@ enum ProjectRoundStateValue {
|
|||||||
WITHDRAWN
|
WITHDRAWN
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AdvancementRuleType {
|
|
||||||
AUTO_ADVANCE
|
|
||||||
SCORE_THRESHOLD
|
|
||||||
TOP_N
|
|
||||||
ADMIN_SELECTION
|
|
||||||
AI_RECOMMENDED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CapMode {
|
enum CapMode {
|
||||||
HARD
|
HARD
|
||||||
@@ -302,12 +292,14 @@ model User {
|
|||||||
email String @unique
|
email String @unique
|
||||||
name String?
|
name String?
|
||||||
emailVerified DateTime? // Required by NextAuth Prisma adapter
|
emailVerified DateTime? // Required by NextAuth Prisma adapter
|
||||||
role UserRole @default(JURY_MEMBER)
|
role UserRole @default(APPLICANT)
|
||||||
roles UserRole[] @default([])
|
roles UserRole[] @default([])
|
||||||
status UserStatus @default(INVITED)
|
status UserStatus @default(INVITED)
|
||||||
expertiseTags String[] @default([])
|
expertiseTags String[] @default([])
|
||||||
maxAssignments Int? // Per-round limit
|
maxAssignments Int? // Per-round limit
|
||||||
country String? // User's home country (for mentor matching)
|
country String? // User's home country (for mentor matching)
|
||||||
|
nationality String? // User's nationality (for applicant profiles)
|
||||||
|
institution String? // User's institution/organization
|
||||||
metadataJson Json? @db.JsonB
|
metadataJson Json? @db.JsonB
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
@@ -333,6 +325,10 @@ model User {
|
|||||||
inviteToken String? @unique
|
inviteToken String? @unique
|
||||||
inviteTokenExpiresAt DateTime?
|
inviteTokenExpiresAt DateTime?
|
||||||
|
|
||||||
|
// Password reset token
|
||||||
|
passwordResetToken String? @unique
|
||||||
|
passwordResetExpiresAt DateTime?
|
||||||
|
|
||||||
// Digest & availability preferences
|
// Digest & availability preferences
|
||||||
digestFrequency String @default("none") // 'none' | 'daily' | 'weekly'
|
digestFrequency String @default("none") // 'none' | 'daily' | 'weekly'
|
||||||
preferredWorkload Int?
|
preferredWorkload Int?
|
||||||
@@ -421,7 +417,6 @@ model User {
|
|||||||
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
|
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
|
||||||
resultLocksCreated ResultLock[] @relation("ResultLockCreator")
|
resultLocksCreated ResultLock[] @relation("ResultLockCreator")
|
||||||
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
|
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
|
||||||
assignmentExceptionsApproved AssignmentException[] @relation("AssignmentExceptionApprover")
|
|
||||||
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
|
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
|
||||||
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
|
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
|
||||||
|
|
||||||
@@ -553,7 +548,6 @@ model EvaluationForm {
|
|||||||
model Project {
|
model Project {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
programId String
|
programId String
|
||||||
roundId String?
|
|
||||||
status ProjectStatus @default(SUBMITTED)
|
status ProjectStatus @default(SUBMITTED)
|
||||||
|
|
||||||
// Core fields
|
// Core fields
|
||||||
@@ -636,6 +630,7 @@ model Project {
|
|||||||
deliberationVotes DeliberationVote[]
|
deliberationVotes DeliberationVote[]
|
||||||
deliberationResults DeliberationResult[]
|
deliberationResults DeliberationResult[]
|
||||||
submissionPromotions SubmissionPromotionEvent[]
|
submissionPromotions SubmissionPromotionEvent[]
|
||||||
|
notificationLogs NotificationLog[]
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -752,7 +747,6 @@ model Assignment {
|
|||||||
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?
|
||||||
exceptions AssignmentException[]
|
|
||||||
|
|
||||||
@@unique([userId, projectId, roundId])
|
@@unique([userId, projectId, roundId])
|
||||||
@@index([roundId])
|
@@index([roundId])
|
||||||
@@ -761,6 +755,7 @@ model Assignment {
|
|||||||
@@index([isCompleted])
|
@@index([isCompleted])
|
||||||
@@index([projectId, userId])
|
@@index([projectId, userId])
|
||||||
@@index([juryGroupId])
|
@@index([juryGroupId])
|
||||||
|
@@index([roundId, isCompleted])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Evaluation {
|
model Evaluation {
|
||||||
@@ -778,11 +773,6 @@ model Evaluation {
|
|||||||
binaryDecision Boolean? // Yes/No for semi-finalist
|
binaryDecision Boolean? // Yes/No for semi-finalist
|
||||||
feedbackText String? @db.Text
|
feedbackText String? @db.Text
|
||||||
|
|
||||||
// Versioning (currently unused - evaluations are updated in-place.
|
|
||||||
// TODO: Implement proper versioning by creating new rows on re-submission
|
|
||||||
// if version history is needed for audit purposes)
|
|
||||||
version Int @default(1)
|
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -929,22 +919,35 @@ model AIUsageLog {
|
|||||||
|
|
||||||
model NotificationLog {
|
model NotificationLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String?
|
||||||
channel NotificationChannel
|
channel NotificationChannel @default(EMAIL)
|
||||||
provider String? // META, TWILIO, SMTP
|
provider String? // META, TWILIO, SMTP
|
||||||
type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION
|
type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION, ADVANCEMENT_NOTIFICATION, etc.
|
||||||
status String // PENDING, SENT, DELIVERED, FAILED
|
status String // PENDING, SENT, DELIVERED, FAILED
|
||||||
externalId String? // Message ID from provider
|
externalId String? // Message ID from provider
|
||||||
errorMsg String? @db.Text
|
errorMsg String? @db.Text
|
||||||
|
|
||||||
|
// Bulk notification tracking
|
||||||
|
email String? // Recipient email address
|
||||||
|
roundId String?
|
||||||
|
projectId String?
|
||||||
|
batchId String? // Groups emails from same send operation
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull)
|
||||||
|
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
@@index([roundId, type])
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([batchId])
|
||||||
|
@@index([email])
|
||||||
|
@@index([type, status])
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -1423,6 +1426,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RankingSnapshotStatus {
|
enum RankingSnapshotStatus {
|
||||||
@@ -1474,6 +1478,7 @@ model RankingSnapshot {
|
|||||||
@@index([roundId])
|
@@index([roundId])
|
||||||
@@index([triggeredById])
|
@@index([triggeredById])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
@@index([roundId, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tracks progress of long-running AI tagging jobs
|
// Tracks progress of long-running AI tagging jobs
|
||||||
@@ -1619,6 +1624,9 @@ model AwardEligibility {
|
|||||||
confirmedAt DateTime?
|
confirmedAt DateTime?
|
||||||
confirmedBy String?
|
confirmedBy String?
|
||||||
|
|
||||||
|
// Pool notification tracking
|
||||||
|
notifiedAt DateTime?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1699,7 +1707,6 @@ model ConflictOfInterest {
|
|||||||
assignmentId String @unique
|
assignmentId String @unique
|
||||||
userId String
|
userId String
|
||||||
projectId String
|
projectId String
|
||||||
roundId String? // Legacy — kept for historical data
|
|
||||||
hasConflict Boolean @default(false)
|
hasConflict Boolean @default(false)
|
||||||
conflictType String? // "financial", "personal", "organizational", "other"
|
conflictType String? // "financial", "personal", "organizational", "other"
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
@@ -1717,6 +1724,8 @@ model ConflictOfInterest {
|
|||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([hasConflict])
|
@@index([hasConflict])
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([userId, hasConflict])
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -2079,24 +2088,6 @@ model LiveProgressCursor {
|
|||||||
@@index([sessionId])
|
@@index([sessionId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model OverrideAction {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
entityType String // ProjectRoundState, FilteringResult, AwardEligibility, etc.
|
|
||||||
entityId String
|
|
||||||
previousValue Json? @db.JsonB
|
|
||||||
newValueJson Json @db.JsonB
|
|
||||||
reasonCode OverrideReasonCode
|
|
||||||
reasonText String? @db.Text
|
|
||||||
actorId String
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@index([entityType, entityId])
|
|
||||||
@@index([actorId])
|
|
||||||
@@index([reasonCode])
|
|
||||||
@@index([createdAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model DecisionAuditLog {
|
model DecisionAuditLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
eventType String // stage.transitioned, routing.executed, filtering.completed, etc.
|
eventType String // stage.transitioned, routing.executed, filtering.completed, etc.
|
||||||
@@ -2114,21 +2105,6 @@ model DecisionAuditLog {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model NotificationPolicy {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
eventType String @unique // stage.transitioned, filtering.completed, etc.
|
|
||||||
channel String @default("EMAIL") // EMAIL, IN_APP, BOTH, NONE
|
|
||||||
templateId String? // Optional reference to MessageTemplate
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
configJson Json? @db.JsonB // Additional config (delay, batch, etc.)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@index([eventType])
|
|
||||||
@@index([isActive])
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// COMPETITION / ROUND ENGINE MODELS (NEW — coexists with Pipeline/Track/Stage)
|
// COMPETITION / ROUND ENGINE MODELS (NEW — coexists with Pipeline/Track/Stage)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -2190,6 +2166,11 @@ model Round {
|
|||||||
submissionWindowId String?
|
submissionWindowId String?
|
||||||
specialAwardId String?
|
specialAwardId String?
|
||||||
|
|
||||||
|
// Finalization
|
||||||
|
gracePeriodEndsAt DateTime?
|
||||||
|
finalizedAt DateTime?
|
||||||
|
finalizedBy String?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -2199,7 +2180,6 @@ model Round {
|
|||||||
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[]
|
||||||
advancementRules AdvancementRule[]
|
|
||||||
visibleSubmissionWindows RoundSubmissionVisibility[]
|
visibleSubmissionWindows RoundSubmissionVisibility[]
|
||||||
assignmentIntents AssignmentIntent[]
|
assignmentIntents AssignmentIntent[]
|
||||||
deliberationSessions DeliberationSession[]
|
deliberationSessions DeliberationSession[]
|
||||||
@@ -2222,6 +2202,7 @@ model Round {
|
|||||||
evaluationSummaries EvaluationSummary[]
|
evaluationSummaries EvaluationSummary[]
|
||||||
evaluationDiscussions EvaluationDiscussion[]
|
evaluationDiscussions EvaluationDiscussion[]
|
||||||
messages Message[]
|
messages Message[]
|
||||||
|
notificationLogs NotificationLog[]
|
||||||
cohorts Cohort[]
|
cohorts Cohort[]
|
||||||
liveCursor LiveProgressCursor?
|
liveCursor LiveProgressCursor?
|
||||||
|
|
||||||
@@ -2234,13 +2215,14 @@ model Round {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ProjectRoundState {
|
model ProjectRoundState {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
projectId String
|
projectId String
|
||||||
roundId String
|
roundId String
|
||||||
state ProjectRoundStateValue @default(PENDING)
|
state ProjectRoundStateValue @default(PENDING)
|
||||||
enteredAt DateTime @default(now())
|
proposedOutcome ProjectRoundStateValue?
|
||||||
exitedAt DateTime?
|
enteredAt DateTime @default(now())
|
||||||
metadataJson Json? @db.JsonB
|
exitedAt DateTime?
|
||||||
|
metadataJson Json? @db.JsonB
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -2253,24 +2235,7 @@ model ProjectRoundState {
|
|||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
@@index([roundId])
|
@@index([roundId])
|
||||||
@@index([state])
|
@@index([state])
|
||||||
}
|
@@index([roundId, state])
|
||||||
|
|
||||||
model AdvancementRule {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
roundId String
|
|
||||||
targetRoundId String?
|
|
||||||
ruleType AdvancementRuleType
|
|
||||||
configJson Json @db.JsonB
|
|
||||||
isDefault Boolean @default(true)
|
|
||||||
sortOrder Int @default(0)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
// Relations
|
|
||||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([roundId, sortOrder])
|
|
||||||
@@index([roundId])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -2449,22 +2414,6 @@ model AssignmentIntent {
|
|||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
model AssignmentException {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
assignmentId String
|
|
||||||
reason String @db.Text
|
|
||||||
overCapBy Int
|
|
||||||
approvedById String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
// Relations
|
|
||||||
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
|
||||||
approvedBy User @relation("AssignmentExceptionApprover", fields: [approvedById], references: [id])
|
|
||||||
|
|
||||||
@@index([assignmentId])
|
|
||||||
@@index([approvedById])
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MENTORING WORKSPACE MODELS (NEW)
|
// MENTORING WORKSPACE MODELS (NEW)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
52
prisma/seed-team-leads.ts
Normal file
52
prisma/seed-team-leads.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Idempotent sync: ensure every project with a submittedByUserId has a
|
||||||
|
* corresponding TeamMember(LEAD) record. Safe to run on every deploy.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { submittedByUserId: { not: null } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
submittedByUserId: true,
|
||||||
|
teamMembers: { select: { userId: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const toCreate: Array<{ projectId: string; userId: string; role: 'LEAD' }> = []
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
if (!project.submittedByUserId) continue
|
||||||
|
const alreadyLinked = project.teamMembers.some(
|
||||||
|
(tm) => tm.userId === project.submittedByUserId
|
||||||
|
)
|
||||||
|
if (!alreadyLinked) {
|
||||||
|
toCreate.push({
|
||||||
|
projectId: project.id,
|
||||||
|
userId: project.submittedByUserId,
|
||||||
|
role: 'LEAD',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toCreate.length > 0) {
|
||||||
|
await prisma.teamMember.createMany({
|
||||||
|
data: toCreate,
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
console.log(`✓ Linked ${toCreate.length} project submitters as TeamMember(LEAD)`)
|
||||||
|
} else {
|
||||||
|
console.log('✓ All project submitters already linked — nothing to do')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Team lead sync failed:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
RoundStatus,
|
RoundStatus,
|
||||||
CapMode,
|
CapMode,
|
||||||
JuryGroupMemberRole,
|
JuryGroupMemberRole,
|
||||||
AdvancementRuleType,
|
|
||||||
} from '@prisma/client'
|
} from '@prisma/client'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
// Inline default configs so seed has ZERO dependency on src/ (not available in Docker prod image)
|
// Inline default configs so seed has ZERO dependency on src/ (not available in Docker prod image)
|
||||||
@@ -316,6 +315,7 @@ async function main() {
|
|||||||
|
|
||||||
const staffAccounts = [
|
const staffAccounts = [
|
||||||
{ 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: '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!' },
|
{ email: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' },
|
||||||
]
|
]
|
||||||
@@ -323,10 +323,10 @@ async function main() {
|
|||||||
const staffUsers: Record<string, string> = {}
|
const staffUsers: Record<string, string> = {}
|
||||||
for (const account of staffAccounts) {
|
for (const account of staffAccounts) {
|
||||||
const passwordHash = await bcrypt.hash(account.password, 12)
|
const passwordHash = await bcrypt.hash(account.password, 12)
|
||||||
const isSuperAdmin = account.role === UserRole.SUPER_ADMIN
|
const needsPassword = account.role === UserRole.SUPER_ADMIN || account.role === UserRole.APPLICANT
|
||||||
const user = await prisma.user.upsert({
|
const user = await prisma.user.upsert({
|
||||||
where: { email: account.email },
|
where: { email: account.email },
|
||||||
update: isSuperAdmin
|
update: needsPassword
|
||||||
? {
|
? {
|
||||||
status: UserStatus.ACTIVE,
|
status: UserStatus.ACTIVE,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
@@ -348,11 +348,11 @@ async function main() {
|
|||||||
name: account.name,
|
name: account.name,
|
||||||
role: account.role,
|
role: account.role,
|
||||||
roles: [account.role],
|
roles: [account.role],
|
||||||
status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE,
|
status: needsPassword ? UserStatus.ACTIVE : UserStatus.NONE,
|
||||||
passwordHash: isSuperAdmin ? passwordHash : null,
|
passwordHash: needsPassword ? passwordHash : null,
|
||||||
mustSetPassword: !isSuperAdmin,
|
mustSetPassword: !needsPassword,
|
||||||
passwordSetAt: isSuperAdmin ? new Date() : null,
|
passwordSetAt: needsPassword ? new Date() : null,
|
||||||
onboardingCompletedAt: isSuperAdmin ? new Date() : null,
|
onboardingCompletedAt: needsPassword ? new Date() : null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
staffUsers[account.email] = user.id
|
staffUsers[account.email] = user.id
|
||||||
@@ -559,7 +559,7 @@ async function main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Create project
|
// Create project
|
||||||
await prisma.project.create({
|
const createdProject = await prisma.project.create({
|
||||||
data: {
|
data: {
|
||||||
programId: program.id,
|
programId: program.id,
|
||||||
title: projectName || `Project by ${name}`,
|
title: projectName || `Project by ${name}`,
|
||||||
@@ -584,13 +584,24 @@ async function main() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Link submitter as team lead
|
||||||
|
await prisma.teamMember.upsert({
|
||||||
|
where: { projectId_userId: { projectId: createdProject.id, userId: user.id } },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
projectId: createdProject.id,
|
||||||
|
userId: user.id,
|
||||||
|
role: 'LEAD',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
projectCount++
|
projectCount++
|
||||||
if (projectCount % 50 === 0) {
|
if (projectCount % 50 === 0) {
|
||||||
console.log(` ... ${projectCount} projects created`)
|
console.log(` ... ${projectCount} projects created`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(` ✓ Created ${projectCount} projects`)
|
console.log(` ✓ Created ${projectCount} projects (with team lead links)`)
|
||||||
if (skippedNoEmail > 0) {
|
if (skippedNoEmail > 0) {
|
||||||
console.log(` ⚠ Skipped ${skippedNoEmail} rows with no valid email`)
|
console.log(` ⚠ Skipped ${skippedNoEmail} rows with no valid email`)
|
||||||
}
|
}
|
||||||
@@ -846,23 +857,23 @@ async function main() {
|
|||||||
}
|
}
|
||||||
console.log(` ✓ ${rounds.length} rounds created (R1-R8)`)
|
console.log(` ✓ ${rounds.length} rounds created (R1-R8)`)
|
||||||
|
|
||||||
// --- Advancement Rules (auto-advance between rounds) ---
|
// --- Assign all projects to intake round (COMPLETED, since intake is closed) ---
|
||||||
for (let i = 0; i < rounds.length - 1; i++) {
|
const intakeRound = rounds[0]
|
||||||
await prisma.advancementRule.upsert({
|
const allProjects = await prisma.project.findMany({
|
||||||
where: {
|
where: { programId: program.id },
|
||||||
roundId_sortOrder: { roundId: rounds[i].id, sortOrder: 0 },
|
select: { id: true },
|
||||||
},
|
})
|
||||||
update: {},
|
if (allProjects.length > 0) {
|
||||||
create: {
|
await prisma.projectRoundState.createMany({
|
||||||
roundId: rounds[i].id,
|
data: allProjects.map((p) => ({
|
||||||
ruleType: AdvancementRuleType.AUTO_ADVANCE,
|
projectId: p.id,
|
||||||
sortOrder: 0,
|
roundId: intakeRound.id,
|
||||||
targetRoundId: rounds[i + 1].id,
|
state: 'COMPLETED' as const,
|
||||||
configJson: {},
|
})),
|
||||||
},
|
skipDuplicates: true,
|
||||||
})
|
})
|
||||||
|
console.log(` ✓ ${allProjects.length} projects assigned to intake round (COMPLETED)`)
|
||||||
}
|
}
|
||||||
console.log(` ✓ ${rounds.length - 1} advancement rules created`)
|
|
||||||
|
|
||||||
// --- Round-Submission Visibility (which rounds can see which submission windows) ---
|
// --- Round-Submission Visibility (which rounds can see which submission windows) ---
|
||||||
// R2 and R3 can see R1 docs, R5 can see R4 docs
|
// R2 and R3 can see R1 docs, R5 can see R4 docs
|
||||||
@@ -887,6 +898,28 @@ async function main() {
|
|||||||
}
|
}
|
||||||
console.log(` ✓ ${visibilityLinks.length} submission visibility links created`)
|
console.log(` ✓ ${visibilityLinks.length} submission visibility links created`)
|
||||||
|
|
||||||
|
// --- Applicant/Observer visibility settings ---
|
||||||
|
const visibilitySettings = [
|
||||||
|
{ key: 'observer_show_team_tab', value: 'true', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show Team tab on observer project detail page' },
|
||||||
|
{ key: 'applicant_show_evaluation_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show anonymous jury evaluation feedback to applicants' },
|
||||||
|
{ key: 'applicant_show_evaluation_scores', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show global scores in evaluation feedback' },
|
||||||
|
{ key: 'applicant_show_evaluation_criteria', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show per-criterion scores in evaluation feedback' },
|
||||||
|
{ key: 'applicant_show_evaluation_text', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show written feedback text in evaluation feedback' },
|
||||||
|
{ key: 'applicant_show_livefinal_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show live final scores to applicants' },
|
||||||
|
{ key: 'applicant_show_livefinal_scores', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show individual jury scores from live finals' },
|
||||||
|
{ key: 'applicant_show_deliberation_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show deliberation results to applicants' },
|
||||||
|
{ key: 'applicant_hide_feedback_from_rejected', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Hide feedback from rejected projects' },
|
||||||
|
{ key: 'applicant_allow_description_edit', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Allow applicants to edit their project description' },
|
||||||
|
]
|
||||||
|
for (const s of visibilitySettings) {
|
||||||
|
await prisma.systemSettings.upsert({
|
||||||
|
where: { key: s.key },
|
||||||
|
update: {},
|
||||||
|
create: s,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
console.log(` ✓ Created ${visibilitySettings.length} applicant/observer visibility settings`)
|
||||||
|
|
||||||
// --- Feature flag: enable competition model ---
|
// --- Feature flag: enable competition model ---
|
||||||
await prisma.systemSettings.upsert({
|
await prisma.systemSettings.upsert({
|
||||||
where: { key: 'feature.useCompetitionModel' },
|
where: { key: 'feature.useCompetitionModel' },
|
||||||
|
|||||||
106
scripts/backfill-binary-decision.ts
Normal file
106
scripts/backfill-binary-decision.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* One-off script: backfill binaryDecision from custom boolean criterion
|
||||||
|
* "Move to the Next Stage?" for evaluations where binaryDecision is null.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/backfill-binary-decision.ts
|
||||||
|
*
|
||||||
|
* What it does:
|
||||||
|
* 1. Finds all rounds with a boolean criterion labeled "Move to the Next Stage?"
|
||||||
|
* 2. For evaluations in those rounds where binaryDecision IS NULL,
|
||||||
|
* copies the boolean value from criterionScoresJson into binaryDecision
|
||||||
|
*
|
||||||
|
* Safe to re-run: only updates evaluations where binaryDecision is still null.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient, Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
type CriterionConfig = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Find all rounds that have evaluation config with criteria
|
||||||
|
const rounds = await prisma.round.findMany({
|
||||||
|
where: { roundType: 'EVALUATION' },
|
||||||
|
select: { id: true, name: true, configJson: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
let totalUpdated = 0
|
||||||
|
let totalSkipped = 0
|
||||||
|
|
||||||
|
for (const round of rounds) {
|
||||||
|
const config = round.configJson as Record<string, unknown> | null
|
||||||
|
if (!config) continue
|
||||||
|
|
||||||
|
const criteria = (config.criteria ?? config.evaluationCriteria ?? []) as CriterionConfig[]
|
||||||
|
|
||||||
|
// Find the boolean criterion for "Move to the Next Stage?"
|
||||||
|
const boolCriterion = criteria.find(
|
||||||
|
(c) =>
|
||||||
|
(c.type === 'boolean') &&
|
||||||
|
c.label?.toLowerCase().includes('move to the next stage'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!boolCriterion) continue
|
||||||
|
|
||||||
|
console.log(`Round "${round.name}" (${round.id}): found criterion "${boolCriterion.label}" (${boolCriterion.id})`)
|
||||||
|
|
||||||
|
// Find evaluations in this round where binaryDecision is null
|
||||||
|
// Use Prisma.JsonNull for proper null filtering
|
||||||
|
const evaluations = await prisma.evaluation.findMany({
|
||||||
|
where: {
|
||||||
|
assignment: { roundId: round.id },
|
||||||
|
binaryDecision: null,
|
||||||
|
status: 'SUBMITTED',
|
||||||
|
},
|
||||||
|
select: { id: true, criterionScoresJson: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
let updated = 0
|
||||||
|
let skipped = 0
|
||||||
|
for (const ev of evaluations) {
|
||||||
|
const scores = ev.criterionScoresJson as Record<string, unknown> | null
|
||||||
|
if (!scores) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = scores[boolCriterion.id]
|
||||||
|
let resolved: boolean | null = null
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
resolved = value
|
||||||
|
} else if (value === 'true' || value === 1) {
|
||||||
|
resolved = true
|
||||||
|
} else if (value === 'false' || value === 0) {
|
||||||
|
resolved = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved === null) {
|
||||||
|
console.log(` Skipping eval ${ev.id}: criterion value is ${JSON.stringify(value)}`)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.evaluation.update({
|
||||||
|
where: { id: ev.id },
|
||||||
|
data: { binaryDecision: resolved },
|
||||||
|
})
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Updated ${updated}/${evaluations.length} evaluations (skipped ${skipped})`)
|
||||||
|
totalUpdated += updated
|
||||||
|
totalSkipped += skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDone. Total updated: ${totalUpdated}, Total skipped: ${totalSkipped}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
112
scripts/backfill-intake-round.ts
Normal file
112
scripts/backfill-intake-round.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Backfill all projects into the intake round (and any intermediate rounds
|
||||||
|
* between intake and their earliest assigned round) with COMPLETED state.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/backfill-intake-round.ts
|
||||||
|
* Add --dry-run to preview without making changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
const dryRun = process.argv.includes('--dry-run')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(dryRun ? '🔍 DRY RUN — no changes will be made\n' : '🚀 Backfilling intake round states...\n')
|
||||||
|
|
||||||
|
// Find the intake round
|
||||||
|
const intakeRound = await prisma.round.findFirst({
|
||||||
|
where: { roundType: 'INTAKE' },
|
||||||
|
select: { id: true, name: true, sortOrder: true, competitionId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!intakeRound) {
|
||||||
|
console.log('❌ No INTAKE round found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Intake round: "${intakeRound.name}" (sortOrder: ${intakeRound.sortOrder})`)
|
||||||
|
|
||||||
|
// Get all rounds in the competition ordered by sortOrder
|
||||||
|
const allRounds = await prisma.round.findMany({
|
||||||
|
where: { competitionId: intakeRound.competitionId },
|
||||||
|
select: { id: true, name: true, sortOrder: true },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find all projects NOT in the intake round
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
projectRoundStates: {
|
||||||
|
none: { roundId: intakeRound.id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
projectRoundStates: {
|
||||||
|
select: { roundId: true, round: { select: { sortOrder: true } } },
|
||||||
|
orderBy: { round: { sortOrder: 'asc' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`${projects.length} projects not in intake round\n`)
|
||||||
|
|
||||||
|
if (projects.length === 0) {
|
||||||
|
console.log('✅ All projects already in intake round')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each project, create COMPLETED states for intake + any intermediate rounds
|
||||||
|
const toCreate: Array<{ projectId: string; roundId: string; state: 'COMPLETED' }> = []
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
// Find the earliest round this project is already in
|
||||||
|
const earliestSortOrder = project.projectRoundStates.length > 0
|
||||||
|
? Math.min(...project.projectRoundStates.map(ps => ps.round.sortOrder))
|
||||||
|
: Infinity
|
||||||
|
|
||||||
|
const existingRoundIds = new Set(project.projectRoundStates.map(ps => ps.roundId))
|
||||||
|
|
||||||
|
// Add COMPLETED for intake + all intermediate rounds before the earliest assigned round
|
||||||
|
for (const round of allRounds) {
|
||||||
|
if (round.sortOrder >= earliestSortOrder) break
|
||||||
|
if (existingRoundIds.has(round.id)) continue
|
||||||
|
|
||||||
|
toCreate.push({
|
||||||
|
projectId: project.id,
|
||||||
|
roundId: round.id,
|
||||||
|
state: 'COMPLETED',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Creating ${toCreate.length} ProjectRoundState records...`)
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
await prisma.projectRoundState.createMany({
|
||||||
|
data: toCreate,
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary by round
|
||||||
|
const byRound = new Map<string, number>()
|
||||||
|
for (const r of toCreate) {
|
||||||
|
const name = allRounds.find(ar => ar.id === r.roundId)?.name ?? r.roundId
|
||||||
|
byRound.set(name, (byRound.get(name) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
for (const [name, count] of byRound) {
|
||||||
|
console.log(` ${name}: ${count} projects`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Done! ${toCreate.length} records ${dryRun ? 'would be' : ''} created`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Error:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
78
scripts/backfill-team-leads.ts
Normal file
78
scripts/backfill-team-leads.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Backfill TeamMember records for all projects that have a submittedByUserId
|
||||||
|
* but no corresponding TeamMember link.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/backfill-team-leads.ts
|
||||||
|
* Add --dry-run to preview without making changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
const dryRun = process.argv.includes('--dry-run')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(dryRun ? '🔍 DRY RUN — no changes will be made\n' : '🚀 Backfilling team leads...\n')
|
||||||
|
|
||||||
|
// Find all projects with a submitter but no TeamMember link for that user
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
submittedByUserId: { not: null },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
submittedByUserId: true,
|
||||||
|
teamMembers: {
|
||||||
|
select: { userId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let created = 0
|
||||||
|
let alreadyLinked = 0
|
||||||
|
let noSubmitter = 0
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
if (!project.submittedByUserId) {
|
||||||
|
noSubmitter++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyHasLink = project.teamMembers.some(
|
||||||
|
(tm) => tm.userId === project.submittedByUserId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (alreadyHasLink) {
|
||||||
|
alreadyLinked++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` + Linking "${project.title}" → user ${project.submittedByUserId}`)
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
await prisma.teamMember.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id,
|
||||||
|
userId: project.submittedByUserId,
|
||||||
|
role: 'LEAD',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Done!`)
|
||||||
|
console.log(` ${created} TeamMember records ${dryRun ? 'would be' : ''} created`)
|
||||||
|
console.log(` ${alreadyLinked} projects already had the submitter linked`)
|
||||||
|
console.log(` ${noSubmitter} projects had no submitter`)
|
||||||
|
console.log(` ${projects.length} total projects checked`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Error:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
32
scripts/check-invites.cjs
Normal file
32
scripts/check-invites.cjs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const members = await p.teamMember.findMany({
|
||||||
|
orderBy: { joinedAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true, status: true, inviteToken: true } },
|
||||||
|
project: { select: { title: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const m of members) {
|
||||||
|
console.log(m.role, '|', m.user.name, '|', m.user.email, '|', m.user.status, '|', m.project.title, '|', m.joinedAt.toISOString().slice(0,16), '| token:', m.user.inviteToken ? 'yes' : 'no');
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await p.notificationLog.findMany({
|
||||||
|
where: { type: 'TEAM_INVITATION' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
});
|
||||||
|
if (logs.length) {
|
||||||
|
console.log('\n--- Notification logs:');
|
||||||
|
for (const l of logs) {
|
||||||
|
console.log(l.status, '|', l.channel, '|', l.errorMsg, '|', l.createdAt.toISOString().slice(0,16));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('\n--- No TEAM_INVITATION notification logs found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await p.$disconnect();
|
||||||
|
})();
|
||||||
20
scripts/check-rounds.cjs
Normal file
20
scripts/check-rounds.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const rounds = await p.round.findMany({
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
select: { id: true, name: true, roundType: true, status: true, sortOrder: true, competitionId: true },
|
||||||
|
});
|
||||||
|
for (const r of rounds) console.log(r.sortOrder, '|', r.name, '|', r.roundType, '|', r.status, '|', r.id);
|
||||||
|
|
||||||
|
console.log('\n--- File Requirements:');
|
||||||
|
const reqs = await p.fileRequirement.findMany({ include: { round: { select: { name: true } } } });
|
||||||
|
for (const r of reqs) console.log(r.round.name, '|', r.name, '|', r.isRequired, '|', r.id);
|
||||||
|
|
||||||
|
console.log('\n--- Submission Windows:');
|
||||||
|
const wins = await p.submissionWindow.findMany({ select: { id: true, name: true, roundNumber: true, windowOpenAt: true, windowCloseAt: true, competitionId: true } });
|
||||||
|
for (const w of wins) console.log(w.name, '| round#', w.roundNumber, '| open:', w.windowOpenAt?.toISOString().slice(0,16), '| close:', w.windowCloseAt?.toISOString().slice(0,16));
|
||||||
|
|
||||||
|
await p.$disconnect();
|
||||||
|
})();
|
||||||
71
scripts/create-requirements.cjs
Normal file
71
scripts/create-requirements.cjs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// R2 - AI Screening round ID
|
||||||
|
const roundId = 'cmmafe7et00ldy53kxpdhhvf0';
|
||||||
|
|
||||||
|
// Check existing
|
||||||
|
const existing = await p.fileRequirement.count({ where: { roundId } });
|
||||||
|
if (existing > 0) {
|
||||||
|
console.log(`Round already has ${existing} file requirements, skipping.`);
|
||||||
|
await p.$disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requirements = [
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Executive Summary',
|
||||||
|
description: 'A 2-page executive summary of your project (PDF format, max 10MB)',
|
||||||
|
acceptedMimeTypes: ['application/pdf'],
|
||||||
|
maxSizeMB: 10,
|
||||||
|
isRequired: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Business Plan',
|
||||||
|
description: 'Full business plan or project proposal (PDF format, max 25MB)',
|
||||||
|
acceptedMimeTypes: ['application/pdf'],
|
||||||
|
maxSizeMB: 25,
|
||||||
|
isRequired: true,
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Pitch Presentation',
|
||||||
|
description: 'Slide deck presenting your project (PDF or PowerPoint, max 50MB)',
|
||||||
|
acceptedMimeTypes: ['application/pdf', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
|
||||||
|
maxSizeMB: 50,
|
||||||
|
isRequired: true,
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Video Pitch',
|
||||||
|
description: 'A short video (max 3 minutes) explaining your project (MP4, max 200MB). Optional but recommended.',
|
||||||
|
acceptedMimeTypes: ['video/mp4', 'video/quicktime', 'video/webm'],
|
||||||
|
maxSizeMB: 200,
|
||||||
|
isRequired: false,
|
||||||
|
sortOrder: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Supporting Documents',
|
||||||
|
description: 'Any additional supporting documents such as research papers, letters of support, etc. (PDF, max 20MB)',
|
||||||
|
acceptedMimeTypes: ['application/pdf'],
|
||||||
|
maxSizeMB: 20,
|
||||||
|
isRequired: false,
|
||||||
|
sortOrder: 4,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const req of requirements) {
|
||||||
|
const created = await p.fileRequirement.create({ data: req });
|
||||||
|
console.log('Created:', created.name, '| required:', created.isRequired, '| id:', created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nDone! Created', requirements.length, 'file requirements for R2.');
|
||||||
|
await p.$disconnect();
|
||||||
|
})();
|
||||||
68
scripts/create-test-applicant.ts
Normal file
68
scripts/create-test-applicant.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { sendInvitationEmail } from '../src/lib/email'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Find a program to attach the project to
|
||||||
|
const program = await prisma.program.findFirst()
|
||||||
|
if (!program) throw new Error('No program found - run seed first')
|
||||||
|
|
||||||
|
// Create applicant user
|
||||||
|
const inviteToken = crypto.randomBytes(32).toString('hex')
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: 'test_applicant_matt_ciaccio',
|
||||||
|
name: 'Matt Ciaccio',
|
||||||
|
email: 'matt.ciaccio@gmail.com',
|
||||||
|
role: 'APPLICANT',
|
||||||
|
roles: ['APPLICANT'],
|
||||||
|
status: 'INVITED',
|
||||||
|
mustSetPassword: true,
|
||||||
|
inviteToken,
|
||||||
|
inviteTokenExpiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log('Created user:', user.id)
|
||||||
|
|
||||||
|
// Create test project
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
id: 'test_project_qa',
|
||||||
|
title: 'OceanWatch AI',
|
||||||
|
description: 'AI-powered ocean monitoring platform for marine conservation',
|
||||||
|
programId: program.id,
|
||||||
|
submittedByUserId: user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log('Created project:', project.id)
|
||||||
|
|
||||||
|
// Create team member (LEAD)
|
||||||
|
await prisma.teamMember.create({
|
||||||
|
data: {
|
||||||
|
id: 'test_tm_lead',
|
||||||
|
projectId: project.id,
|
||||||
|
userId: user.id,
|
||||||
|
role: 'LEAD',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log('Created team member (LEAD)')
|
||||||
|
|
||||||
|
// Send styled invitation email
|
||||||
|
const url = `http://localhost:3000/accept-invite?token=${inviteToken}`
|
||||||
|
console.log('Invite URL:', url)
|
||||||
|
|
||||||
|
await sendInvitationEmail(
|
||||||
|
'matt.ciaccio@gmail.com',
|
||||||
|
'Matt Ciaccio',
|
||||||
|
url,
|
||||||
|
'APPLICANT',
|
||||||
|
72
|
||||||
|
)
|
||||||
|
console.log('Styled invitation email sent!')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => prisma.$disconnect().then(() => process.exit(0)))
|
||||||
165
scripts/seed-notification-log.ts
Normal file
165
scripts/seed-notification-log.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Seed NotificationLog with confirmed SMTP delivery data.
|
||||||
|
*
|
||||||
|
* Sources:
|
||||||
|
* 1. 33 emails confirmed delivered in Poste.io SMTP logs (2026-03-04)
|
||||||
|
* 2. Users with status ACTIVE who are LEADs on PASSED projects
|
||||||
|
* (they clearly received and used their invite link)
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/seed-notification-log.ts
|
||||||
|
* Add --dry-run to preview without making changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
const dryRun = process.argv.includes('--dry-run')
|
||||||
|
|
||||||
|
// Emails confirmed delivered via SMTP logs on 2026-03-04
|
||||||
|
const CONFIRMED_SMTP_EMAILS = new Set([
|
||||||
|
'fbayong@balazstudio.com',
|
||||||
|
'gnoel@kilimora.africa',
|
||||||
|
'amal.chebbi@pigmentoco.com',
|
||||||
|
'nairita@yarsi.net',
|
||||||
|
'martin.itamalo@greenbrinetechnologies.com',
|
||||||
|
'petervegan1223@gmail.com',
|
||||||
|
'dmarinov@redget.io',
|
||||||
|
'adrien@seavium.com',
|
||||||
|
'l.buob@whisper-ef.com',
|
||||||
|
'silvia@omnivorus.com',
|
||||||
|
'marzettisebastian@gmail.com',
|
||||||
|
'fiona.mcomish@algae-scope.com',
|
||||||
|
'karimeguillen@rearvora.com',
|
||||||
|
'info@skywatt.tech',
|
||||||
|
'julia@nereia-coatings.com',
|
||||||
|
'info@janmaisenbacher.com',
|
||||||
|
'xbm_0201@qq.com',
|
||||||
|
'irinakharitonova0201@gmail.com',
|
||||||
|
'seablocksrecif@gmail.com',
|
||||||
|
'oscar@seafuser.com',
|
||||||
|
'charles.maher@blueshadow.dk',
|
||||||
|
'sabirabokhari@gmail.com',
|
||||||
|
'munayimbabura@gmail.com',
|
||||||
|
'amritha.ramadevu@edu.escp.eu',
|
||||||
|
'nele.jordan@myhsba.de',
|
||||||
|
'karl.mihhels@aalto.fi',
|
||||||
|
'christine.a.kurz@gmail.com',
|
||||||
|
'aki@corall.eco',
|
||||||
|
'topias.kilpinen@hotmail.fi',
|
||||||
|
'nina.riutta.camilla@gmail.com',
|
||||||
|
'sofie.boggiosella@my.jcu.edu.au',
|
||||||
|
'giambattistafigari@gmail.com',
|
||||||
|
'mussinig0@gmail.com',
|
||||||
|
])
|
||||||
|
|
||||||
|
const SENT_AT = new Date('2026-03-04T01:00:00Z')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(dryRun ? '--- DRY RUN ---\n' : 'Seeding NotificationLog...\n')
|
||||||
|
|
||||||
|
// Find LEAD team members on PASSED projects
|
||||||
|
const passedLeads = await prisma.teamMember.findMany({
|
||||||
|
where: {
|
||||||
|
role: 'LEAD',
|
||||||
|
project: {
|
||||||
|
projectRoundStates: {
|
||||||
|
some: { state: 'PASSED' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
projectId: true,
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
projectRoundStates: {
|
||||||
|
where: { state: 'PASSED' },
|
||||||
|
select: { roundId: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
status: true,
|
||||||
|
inviteToken: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Found ${passedLeads.length} LEAD team members on PASSED projects\n`)
|
||||||
|
|
||||||
|
let created = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
for (const lead of passedLeads) {
|
||||||
|
const email = lead.user.email?.toLowerCase()
|
||||||
|
if (!email) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a NotificationLog already exists for this project+email
|
||||||
|
const existing = await prisma.notificationLog.findFirst({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
projectId: lead.projectId,
|
||||||
|
type: 'ADVANCEMENT_NOTIFICATION',
|
||||||
|
status: 'SENT',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine confidence of delivery
|
||||||
|
const isConfirmedSMTP = CONFIRMED_SMTP_EMAILS.has(email)
|
||||||
|
const isActive = lead.user.status === 'ACTIVE'
|
||||||
|
const isInvited = lead.user.status === 'INVITED' && !!lead.user.inviteToken
|
||||||
|
|
||||||
|
// Only seed for confirmed deliveries or active users
|
||||||
|
if (!isConfirmedSMTP && !isActive && !isInvited) {
|
||||||
|
console.log(` SKIP ${email} (status=${lead.user.status}, not in SMTP logs)`)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundId = lead.project.projectRoundStates[0]?.roundId ?? null
|
||||||
|
const label = isConfirmedSMTP ? 'SMTP-confirmed' : isActive ? 'user-active' : 'invite-sent'
|
||||||
|
|
||||||
|
console.log(` ${dryRun ? 'WOULD CREATE' : 'CREATE'} ${email} [${label}] project=${lead.projectId}`)
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
await prisma.notificationLog.create({
|
||||||
|
data: {
|
||||||
|
userId: lead.user.id,
|
||||||
|
channel: 'EMAIL',
|
||||||
|
type: 'ADVANCEMENT_NOTIFICATION',
|
||||||
|
status: 'SENT',
|
||||||
|
email,
|
||||||
|
projectId: lead.projectId,
|
||||||
|
roundId,
|
||||||
|
batchId: 'seed-2026-03-04',
|
||||||
|
createdAt: SENT_AT,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
created++
|
||||||
|
} else {
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDone. Created: ${created}, Skipped: ${skipped}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Error:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
120
scripts/send-invite-direct.ts
Normal file
120
scripts/send-invite-direct.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
|
// Import just the template helper without hitting DB
|
||||||
|
// We'll construct the email manually since the DB connection fails
|
||||||
|
|
||||||
|
const BRAND = {
|
||||||
|
red: '#de0f1e',
|
||||||
|
darkBlue: '#053d57',
|
||||||
|
white: '#fefefe',
|
||||||
|
teal: '#557f8c',
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = '6f974b1da9fae95f74bbcd2419df589730979ac945aeaa5413021c00311b5165';
|
||||||
|
const url = 'http://localhost:3000/accept-invite?token=' + token;
|
||||||
|
|
||||||
|
// Replicate the styled email template from email.ts
|
||||||
|
function getStyledHtml(name: string, inviteUrl: string) {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>You're invited to join the MOPC Portal</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #f8fafc;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width: 600px; width: 100%;">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background: linear-gradient(135deg, ${BRAND.darkBlue} 0%, ${BRAND.teal} 100%); border-radius: 16px 16px 0 0; padding: 32px 40px; text-align: center;">
|
||||||
|
<h1 style="color: ${BRAND.white}; font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em;">
|
||||||
|
Monaco Ocean Protection Challenge
|
||||||
|
</h1>
|
||||||
|
<p style="color: rgba(255,255,255,0.8); font-size: 13px; font-weight: 300; margin: 8px 0 0 0; letter-spacing: 0.05em; text-transform: uppercase;">
|
||||||
|
Together for a healthier ocean
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Body -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #ffffff; padding: 40px; border-radius: 0 0 16px 16px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);">
|
||||||
|
<h2 style="color: ${BRAND.darkBlue}; font-size: 20px; font-weight: 600; margin: 0 0 24px 0;">
|
||||||
|
Hello ${name},
|
||||||
|
</h2>
|
||||||
|
<p style="color: #475569; font-size: 15px; line-height: 1.7; margin: 0 0 16px 0; font-weight: 400;">
|
||||||
|
You've been invited to join the Monaco Ocean Protection Challenge platform as an <strong>applicant</strong>.
|
||||||
|
</p>
|
||||||
|
<p style="color: #475569; font-size: 15px; line-height: 1.7; margin: 0 0 24px 0; font-weight: 400;">
|
||||||
|
Click the button below to set up your account and get started.
|
||||||
|
</p>
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 28px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="${inviteUrl}" style="display: inline-block; background: linear-gradient(135deg, ${BRAND.red} 0%, #c40d19 100%); color: #ffffff; text-decoration: none; padding: 14px 36px; border-radius: 10px; font-size: 15px; font-weight: 600; letter-spacing: 0.02em; box-shadow: 0 4px 14px rgba(222, 15, 30, 0.3);">
|
||||||
|
Accept Invitation
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!-- Info Box -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #eff6ff; border-left: 4px solid ${BRAND.darkBlue}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||||||
|
<p style="color: #1e40af; margin: 0; font-size: 13px; line-height: 1.6;">
|
||||||
|
This link will expire in 3 days.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 24px 40px; text-align: center;">
|
||||||
|
<p style="color: #94a3b8; font-size: 12px; line-height: 1.6; margin: 0;">
|
||||||
|
Monaco Ocean Protection Challenge<br>
|
||||||
|
<span style="color: #cbd5e1;">Together for a healthier ocean.</span>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Creating transporter...');
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: 'mail.monaco-opc.com',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: 'noreply@monaco-opc.com',
|
||||||
|
pass: '9EythPDcz1Fya4M88iigkB1wojNf8QEVPuRRnD9dJMBpT3pk2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Sending styled invitation email...');
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: 'MOPC Portal <noreply@monaco-opc.com>',
|
||||||
|
to: 'matt.ciaccio@gmail.com',
|
||||||
|
subject: "You're invited to join the MOPC Portal",
|
||||||
|
text: `Hello Matt Ciaccio,\n\nYou've been invited to join the Monaco Ocean Protection Challenge platform as an applicant.\n\nClick the link below to set up your account:\n\n${url}\n\nThis link will expire in 3 days.\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
||||||
|
html: getStyledHtml('Matt Ciaccio', url),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('SUCCESS! Message ID:', info.messageId);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('FAILED:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
26
scripts/send-invite.ts
Normal file
26
scripts/send-invite.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { sendInvitationEmail } from '../src/lib/email';
|
||||||
|
|
||||||
|
const token = '6f974b1da9fae95f74bbcd2419df589730979ac945aeaa5413021c00311b5165';
|
||||||
|
const url = 'http://localhost:3000/accept-invite?token=' + token;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Sending styled invitation email...');
|
||||||
|
console.log('To: matt.ciaccio@gmail.com');
|
||||||
|
console.log('URL:', url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendInvitationEmail(
|
||||||
|
'matt.ciaccio@gmail.com',
|
||||||
|
'Matt Ciaccio',
|
||||||
|
url,
|
||||||
|
'APPLICANT',
|
||||||
|
72
|
||||||
|
);
|
||||||
|
console.log('SUCCESS: Styled invitation email sent!');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('FAILED:', err.message || err);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
20
scripts/test-db.cjs
Normal file
20
scripts/test-db.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('DATABASE_URL:', process.env.DATABASE_URL);
|
||||||
|
const p = new PrismaClient({ log: ['query', 'info', 'warn', 'error'] });
|
||||||
|
try {
|
||||||
|
const result = await p.$queryRawUnsafe('SELECT 1 as ok');
|
||||||
|
console.log('Connected!', result);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error code:', e.code);
|
||||||
|
console.error('Error meta:', JSON.stringify(e.meta, null, 2));
|
||||||
|
console.error('Message:', e.message);
|
||||||
|
} finally {
|
||||||
|
await p.$disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -56,9 +56,11 @@ import { Switch } from '@/components/ui/switch'
|
|||||||
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||||
import { formatDate } from '@/lib/utils'
|
import { formatDate } from '@/lib/utils'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
// Action type options
|
// Action type options (manual audit actions + auto-generated mutation audit actions)
|
||||||
const ACTION_TYPES = [
|
const ACTION_TYPES = [
|
||||||
|
// Manual audit actions
|
||||||
'CREATE',
|
'CREATE',
|
||||||
'UPDATE',
|
'UPDATE',
|
||||||
'DELETE',
|
'DELETE',
|
||||||
@@ -76,6 +78,8 @@ const ACTION_TYPES = [
|
|||||||
'ROUND_ARCHIVED',
|
'ROUND_ARCHIVED',
|
||||||
'UPLOAD_FILE',
|
'UPLOAD_FILE',
|
||||||
'DELETE_FILE',
|
'DELETE_FILE',
|
||||||
|
'FILE_VIEWED',
|
||||||
|
'FILE_OPENED',
|
||||||
'FILE_DOWNLOADED',
|
'FILE_DOWNLOADED',
|
||||||
'BULK_CREATE',
|
'BULK_CREATE',
|
||||||
'BULK_UPDATE_STATUS',
|
'BULK_UPDATE_STATUS',
|
||||||
@@ -88,12 +92,53 @@ const ACTION_TYPES = [
|
|||||||
'APPLY_AI_SUGGESTIONS',
|
'APPLY_AI_SUGGESTIONS',
|
||||||
'APPLY_SUGGESTIONS',
|
'APPLY_SUGGESTIONS',
|
||||||
'NOTIFY_JURORS_OF_ASSIGNMENTS',
|
'NOTIFY_JURORS_OF_ASSIGNMENTS',
|
||||||
|
'IMPERSONATION_START',
|
||||||
|
'IMPERSONATION_END',
|
||||||
|
// Auto-generated mutation audit actions (non-super-admin)
|
||||||
|
'EVALUATION_START',
|
||||||
|
'EVALUATION_SUBMIT',
|
||||||
|
'EVALUATION_AUTOSAVE',
|
||||||
|
'EVALUATION_DECLARE_COI',
|
||||||
|
'EVALUATION_ADD_COMMENT',
|
||||||
|
'APPLICANT_SAVE_SUBMISSION',
|
||||||
|
'APPLICANT_SAVE_FILE_METADATA',
|
||||||
|
'APPLICANT_DELETE_FILE',
|
||||||
|
'APPLICANT_REQUEST_MENTORING',
|
||||||
|
'APPLICANT_WITHDRAW_FROM_COMPETITION',
|
||||||
|
'APPLICANT_INVITE_TEAM_MEMBER',
|
||||||
|
'APPLICANT_REMOVE_TEAM_MEMBER',
|
||||||
|
'APPLICANT_SEND_MENTOR_MESSAGE',
|
||||||
|
'APPLICATION_SUBMIT',
|
||||||
|
'APPLICATION_SAVE_DRAFT',
|
||||||
|
'APPLICATION_SUBMIT_DRAFT',
|
||||||
|
'MENTOR_SEND_MESSAGE',
|
||||||
|
'MENTOR_CREATE_NOTE',
|
||||||
|
'MENTOR_DELETE_NOTE',
|
||||||
|
'MENTOR_COMPLETE_MILESTONE',
|
||||||
|
'LIVE_CAST_VOTE',
|
||||||
|
'LIVE_CAST_STAGE_VOTE',
|
||||||
|
'LIVE_VOTING_VOTE',
|
||||||
|
'LIVE_VOTING_CAST_AUDIENCE_VOTE',
|
||||||
|
'DELIBERATION_SUBMIT_VOTE',
|
||||||
|
'NOTIFICATION_MARK_AS_READ',
|
||||||
|
'NOTIFICATION_MARK_ALL_AS_READ',
|
||||||
|
'USER_UPDATE_PROFILE',
|
||||||
|
'USER_SET_PASSWORD',
|
||||||
|
'USER_CHANGE_PASSWORD',
|
||||||
|
'USER_COMPLETE_ONBOARDING',
|
||||||
|
'SPECIAL_AWARD_SUBMIT_VOTE',
|
||||||
|
// Security events
|
||||||
|
'ACCOUNT_LOCKED',
|
||||||
|
'ACCESS_DENIED_FORBIDDEN',
|
||||||
|
'ACCESS_DENIED_UNAUTHORIZED',
|
||||||
|
'ACCESS_DENIED_NOT_FOUND',
|
||||||
]
|
]
|
||||||
|
|
||||||
// Entity type options
|
// Entity type options
|
||||||
const ENTITY_TYPES = [
|
const ENTITY_TYPES = [
|
||||||
'User',
|
'User',
|
||||||
'Program',
|
'Program',
|
||||||
|
'Competition',
|
||||||
'Round',
|
'Round',
|
||||||
'Project',
|
'Project',
|
||||||
'Assignment',
|
'Assignment',
|
||||||
@@ -101,6 +146,21 @@ const ENTITY_TYPES = [
|
|||||||
'EvaluationForm',
|
'EvaluationForm',
|
||||||
'ProjectFile',
|
'ProjectFile',
|
||||||
'GracePeriod',
|
'GracePeriod',
|
||||||
|
'Applicant',
|
||||||
|
'Application',
|
||||||
|
'Mentor',
|
||||||
|
'Live',
|
||||||
|
'LiveVoting',
|
||||||
|
'Deliberation',
|
||||||
|
'Notification',
|
||||||
|
'SpecialAward',
|
||||||
|
'File',
|
||||||
|
'Tag',
|
||||||
|
'Message',
|
||||||
|
'Settings',
|
||||||
|
'Ranking',
|
||||||
|
'Filtering',
|
||||||
|
'RoundEngine',
|
||||||
]
|
]
|
||||||
|
|
||||||
// Color map for action types
|
// Color map for action types
|
||||||
@@ -119,6 +179,8 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
|
|||||||
ROUND_ACTIVATED: 'default',
|
ROUND_ACTIVATED: 'default',
|
||||||
ROUND_CLOSED: 'secondary',
|
ROUND_CLOSED: 'secondary',
|
||||||
ROUND_ARCHIVED: 'secondary',
|
ROUND_ARCHIVED: 'secondary',
|
||||||
|
FILE_VIEWED: 'outline',
|
||||||
|
FILE_OPENED: 'outline',
|
||||||
FILE_DOWNLOADED: 'outline',
|
FILE_DOWNLOADED: 'outline',
|
||||||
ROLE_CHANGED: 'secondary',
|
ROLE_CHANGED: 'secondary',
|
||||||
PASSWORD_SET: 'outline',
|
PASSWORD_SET: 'outline',
|
||||||
@@ -128,6 +190,58 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
|
|||||||
APPLY_AI_SUGGESTIONS: 'default',
|
APPLY_AI_SUGGESTIONS: 'default',
|
||||||
APPLY_SUGGESTIONS: 'default',
|
APPLY_SUGGESTIONS: 'default',
|
||||||
NOTIFY_JURORS_OF_ASSIGNMENTS: 'outline',
|
NOTIFY_JURORS_OF_ASSIGNMENTS: 'outline',
|
||||||
|
IMPERSONATION_START: 'destructive',
|
||||||
|
IMPERSONATION_END: 'secondary',
|
||||||
|
// Auto-generated mutation audit actions
|
||||||
|
EVALUATION_START: 'default',
|
||||||
|
EVALUATION_SUBMIT: 'default',
|
||||||
|
EVALUATION_AUTOSAVE: 'outline',
|
||||||
|
EVALUATION_DECLARE_COI: 'secondary',
|
||||||
|
EVALUATION_ADD_COMMENT: 'outline',
|
||||||
|
APPLICANT_SAVE_SUBMISSION: 'default',
|
||||||
|
APPLICANT_DELETE_FILE: 'destructive',
|
||||||
|
APPLICANT_WITHDRAW_FROM_COMPETITION: 'destructive',
|
||||||
|
APPLICANT_INVITE_TEAM_MEMBER: 'default',
|
||||||
|
APPLICANT_REMOVE_TEAM_MEMBER: 'destructive',
|
||||||
|
APPLICATION_SUBMIT: 'default',
|
||||||
|
MENTOR_SEND_MESSAGE: 'outline',
|
||||||
|
MENTOR_CREATE_NOTE: 'default',
|
||||||
|
MENTOR_DELETE_NOTE: 'destructive',
|
||||||
|
LIVE_CAST_VOTE: 'default',
|
||||||
|
LIVE_CAST_STAGE_VOTE: 'default',
|
||||||
|
LIVE_VOTING_CAST_AUDIENCE_VOTE: 'default',
|
||||||
|
DELIBERATION_SUBMIT_VOTE: 'default',
|
||||||
|
SPECIAL_AWARD_SUBMIT_VOTE: 'default',
|
||||||
|
USER_UPDATE_PROFILE: 'secondary',
|
||||||
|
USER_SET_PASSWORD: 'outline',
|
||||||
|
USER_CHANGE_PASSWORD: 'outline',
|
||||||
|
USER_COMPLETE_ONBOARDING: 'default',
|
||||||
|
// Security events
|
||||||
|
ACCOUNT_LOCKED: 'destructive',
|
||||||
|
ACCESS_DENIED_FORBIDDEN: 'destructive',
|
||||||
|
ACCESS_DENIED_UNAUTHORIZED: 'destructive',
|
||||||
|
ACCESS_DENIED_NOT_FOUND: 'secondary',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function getEntityLink(entityType: string, entityId: string): string | null {
|
||||||
|
switch (entityType) {
|
||||||
|
case 'User':
|
||||||
|
return `/admin/members/${entityId}`
|
||||||
|
case 'Project':
|
||||||
|
return `/admin/projects/${entityId}`
|
||||||
|
case 'Round':
|
||||||
|
return `/admin/rounds/${entityId}`
|
||||||
|
case 'Competition':
|
||||||
|
return `/admin/competitions`
|
||||||
|
case 'Evaluation':
|
||||||
|
case 'EvaluationForm':
|
||||||
|
return null // no dedicated page
|
||||||
|
case 'SpecialAward':
|
||||||
|
return `/admin/awards/${entityId}`
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuditLogPage() {
|
export default function AuditLogPage() {
|
||||||
@@ -462,14 +576,24 @@ export default function AuditLogPage() {
|
|||||||
{formatDate(log.timestamp)}
|
{formatDate(log.timestamp)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
{log.userId ? (
|
||||||
<p className="font-medium text-sm">
|
<Link
|
||||||
{log.user?.name || 'System'}
|
href={`/admin/members/${log.userId}`}
|
||||||
</p>
|
className="group block"
|
||||||
<p className="text-xs text-muted-foreground">
|
onClick={(e) => e.stopPropagation()}
|
||||||
{log.user?.email}
|
>
|
||||||
</p>
|
<p className="font-medium text-sm group-hover:text-primary group-hover:underline">
|
||||||
</div>
|
{log.user?.name || 'System'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{log.user?.email}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">System</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -481,11 +605,22 @@ export default function AuditLogPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm">{log.entityType}</p>
|
<p className="text-sm">{log.entityType}</p>
|
||||||
{log.entityId && (
|
{log.entityId && (() => {
|
||||||
<p className="text-xs text-muted-foreground font-mono">
|
const link = getEntityLink(log.entityType, log.entityId)
|
||||||
{log.entityId.slice(0, 8)}...
|
return link ? (
|
||||||
</p>
|
<Link
|
||||||
)}
|
href={link}
|
||||||
|
className="text-xs text-primary font-mono hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{log.entityId.slice(0, 8)}...
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground font-mono">
|
||||||
|
{log.entityId.slice(0, 8)}...
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-xs">
|
<TableCell className="font-mono text-xs">
|
||||||
@@ -508,9 +643,18 @@ export default function AuditLogPage() {
|
|||||||
<p className="text-xs font-medium text-muted-foreground">
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
Entity ID
|
Entity ID
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-sm">
|
{log.entityId ? (() => {
|
||||||
{log.entityId || 'N/A'}
|
const link = getEntityLink(log.entityType, log.entityId)
|
||||||
</p>
|
return link ? (
|
||||||
|
<Link href={link} className="font-mono text-sm text-primary hover:underline" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{log.entityId}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<p className="font-mono text-sm">{log.entityId}</p>
|
||||||
|
)
|
||||||
|
})() : (
|
||||||
|
<p className="font-mono text-sm">N/A</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
@@ -607,12 +751,23 @@ export default function AuditLogPage() {
|
|||||||
{formatDate(log.timestamp)}
|
{formatDate(log.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-muted-foreground">
|
{log.userId ? (
|
||||||
<User className="h-3 w-3" />
|
<Link
|
||||||
<span className="text-xs">
|
href={`/admin/members/${log.userId}`}
|
||||||
{log.user?.name || 'System'}
|
className="flex items-center gap-1 text-muted-foreground hover:text-primary"
|
||||||
</span>
|
onClick={(e) => e.stopPropagation()}
|
||||||
</div>
|
>
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
<span className="text-xs hover:underline">
|
||||||
|
{log.user?.name || 'System'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
<span className="text-xs">System</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
|
|||||||
@@ -122,11 +122,9 @@ export default function EditAwardPage({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href={`/admin/awards/${awardId}`}>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Award
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import { Progress } from '@/components/ui/progress'
|
|||||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { Pagination } from '@/components/shared/pagination'
|
import { Pagination } from '@/components/shared/pagination'
|
||||||
|
import { EmailPreviewDialog } from '@/components/admin/round/email-preview-dialog'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -91,7 +92,28 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Layers,
|
Layers,
|
||||||
Info,
|
Info,
|
||||||
|
Mail,
|
||||||
|
GripVertical,
|
||||||
|
ArrowRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { CountryDisplay } from '@/components/shared/country-display'
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
DRAFT: 'secondary',
|
DRAFT: 'secondary',
|
||||||
@@ -114,6 +136,199 @@ function getStepIndex(status: string): number {
|
|||||||
return idx >= 0 ? idx : (status === 'ARCHIVED' ? 3 : 0)
|
return idx >= 0 ? idx : (status === 'ARCHIVED' ? 3 : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROUND_TYPE_COLORS: Record<string, string> = {
|
||||||
|
EVALUATION: 'bg-violet-100 text-violet-700',
|
||||||
|
FILTERING: 'bg-amber-100 text-amber-700',
|
||||||
|
SUBMISSION: 'bg-blue-100 text-blue-700',
|
||||||
|
MENTORING: 'bg-teal-100 text-teal-700',
|
||||||
|
LIVE_FINAL: 'bg-rose-100 text-rose-700',
|
||||||
|
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
||||||
|
}
|
||||||
|
const ROUND_STATUS_COLORS: Record<string, string> = {
|
||||||
|
DRAFT: 'bg-gray-100 text-gray-600',
|
||||||
|
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||||
|
CLOSED: 'bg-blue-100 text-blue-700',
|
||||||
|
ARCHIVED: 'bg-muted text-muted-foreground',
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableRoundCard({
|
||||||
|
round,
|
||||||
|
index,
|
||||||
|
isFirst,
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
}: {
|
||||||
|
round: any
|
||||||
|
index: number
|
||||||
|
isFirst: boolean
|
||||||
|
onDelete: (roundId: string) => void
|
||||||
|
isDeleting: boolean
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: round.id })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectCount = round._count?.projectRoundStates ?? 0
|
||||||
|
const assignmentCount = round._count?.assignments ?? 0
|
||||||
|
const statusLabel = round.status.replace('ROUND_', '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`hover:shadow-md transition-shadow ${isDragging ? 'opacity-50 shadow-lg z-50' : ''}`}
|
||||||
|
>
|
||||||
|
<CardContent className="pt-4 pb-3 space-y-3">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<button
|
||||||
|
className="cursor-grab touch-none text-muted-foreground hover:text-foreground mt-1 shrink-0"
|
||||||
|
aria-label="Drag to reorder"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link href={`/admin/rounds/${round.id}` as any} className="text-sm font-semibold truncate hover:underline">
|
||||||
|
{round.name}
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
|
<Badge variant="secondary" className={`text-[10px] ${ROUND_TYPE_COLORS[round.roundType] ?? 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
{round.roundType.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className={`text-[10px] ${ROUND_STATUS_COLORS[statusLabel]}`}>
|
||||||
|
{statusLabel}
|
||||||
|
</Badge>
|
||||||
|
{isFirst && (
|
||||||
|
<Badge variant="outline" className="text-[10px] border-amber-300 bg-amber-50 text-amber-700">
|
||||||
|
Entry point
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Layers className="h-3.5 w-3.5" />
|
||||||
|
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
{assignmentCount > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<ListChecks className="h-3.5 w-3.5" />
|
||||||
|
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{round.status === 'ROUND_DRAFT' && (
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete "{round.name}". This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => onDelete(round.id)}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoundsDndGrid({
|
||||||
|
rounds,
|
||||||
|
awardId,
|
||||||
|
onReorder,
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
}: {
|
||||||
|
rounds: any[]
|
||||||
|
awardId: string
|
||||||
|
onReorder: (roundIds: string[]) => void
|
||||||
|
onDelete: (roundId: string) => void
|
||||||
|
isDeleting: boolean
|
||||||
|
}) {
|
||||||
|
const [items, setItems] = useState(rounds.map((r: any) => r.id))
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sync if server data changes
|
||||||
|
useEffect(() => {
|
||||||
|
setItems(rounds.map((r: any) => r.id))
|
||||||
|
}, [rounds])
|
||||||
|
|
||||||
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id) return
|
||||||
|
|
||||||
|
const oldIndex = items.indexOf(active.id as string)
|
||||||
|
const newIndex = items.indexOf(over.id as string)
|
||||||
|
const newItems = arrayMove(items, oldIndex, newIndex)
|
||||||
|
setItems(newItems)
|
||||||
|
onReorder(newItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundMap = new Map(rounds.map((r: any) => [r.id, r]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{items.map((id, index) => {
|
||||||
|
const round = roundMap.get(id)
|
||||||
|
if (!round) return null
|
||||||
|
return (
|
||||||
|
<SortableRoundCard
|
||||||
|
key={id}
|
||||||
|
round={round}
|
||||||
|
index={index}
|
||||||
|
isFirst={index === 0}
|
||||||
|
onDelete={onDelete}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ConfidenceBadge({ confidence }: { confidence: number }) {
|
function ConfidenceBadge({ confidence }: { confidence: number }) {
|
||||||
if (confidence > 0.8) {
|
if (confidence > 0.8) {
|
||||||
return (
|
return (
|
||||||
@@ -155,6 +370,8 @@ export default function AwardDetailPage({
|
|||||||
const [activeTab, setActiveTab] = useState('eligibility')
|
const [activeTab, setActiveTab] = useState('eligibility')
|
||||||
const [addRoundOpen, setAddRoundOpen] = useState(false)
|
const [addRoundOpen, setAddRoundOpen] = useState(false)
|
||||||
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
|
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
|
||||||
|
const [notifyDialogOpen, setNotifyDialogOpen] = useState(false)
|
||||||
|
const [notifyCustomMessage, setNotifyCustomMessage] = useState<string | undefined>()
|
||||||
|
|
||||||
// Pagination for eligibility list
|
// Pagination for eligibility list
|
||||||
const [eligibilityPage, setEligibilityPage] = useState(1)
|
const [eligibilityPage, setEligibilityPage] = useState(1)
|
||||||
@@ -282,6 +499,31 @@ export default function AwardDetailPage({
|
|||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
const reorderRounds = trpc.specialAward.reorderAwardRounds.useMutation({
|
||||||
|
onSuccess: () => refetchRounds(),
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
const assignToFirstRound = trpc.specialAward.assignToFirstRound.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast.success(`Assigned ${result.totalAssigned} projects to first round (${result.createdCount} new, ${result.movedCount} moved)`)
|
||||||
|
refetchRounds()
|
||||||
|
refetch()
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const notifyPreview = trpc.specialAward.previewAwardSelectionEmail.useQuery(
|
||||||
|
{ awardId, customMessage: notifyCustomMessage },
|
||||||
|
{ enabled: notifyDialogOpen }
|
||||||
|
)
|
||||||
|
const notifyEligible = trpc.specialAward.notifyEligibleProjects.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast.success(`Notified ${result.notified} projects (${result.emailsSent} emails sent${result.emailsFailed ? `, ${result.emailsFailed} failed` : ''})`)
|
||||||
|
setNotifyDialogOpen(false)
|
||||||
|
setNotifyCustomMessage(undefined)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
const handleStatusChange = async (
|
const handleStatusChange = async (
|
||||||
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
|
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
|
||||||
@@ -422,11 +664,9 @@ export default function AwardDetailPage({
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/awards">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Awards
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -468,13 +708,44 @@ export default function AwardDetailPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{award.status === 'NOMINATIONS_OPEN' && (
|
{award.status === 'NOMINATIONS_OPEN' && (
|
||||||
<Button
|
<>
|
||||||
onClick={() => handleStatusChange('VOTING_OPEN')}
|
<Button variant="outline" disabled={award.eligibleCount === 0} onClick={() => setNotifyDialogOpen(true)}>
|
||||||
disabled={updateStatus.isPending}
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
>
|
Notify Pool ({award.eligibleCount})
|
||||||
<Play className="mr-2 h-4 w-4" />
|
</Button>
|
||||||
Open Voting
|
<EmailPreviewDialog
|
||||||
</Button>
|
open={notifyDialogOpen}
|
||||||
|
onOpenChange={setNotifyDialogOpen}
|
||||||
|
title="Notify Eligible Projects"
|
||||||
|
description={`Send "Under consideration for ${award.name}" emails to all ${award.eligibleCount} eligible projects.`}
|
||||||
|
recipientCount={notifyPreview.data?.recipientCount ?? 0}
|
||||||
|
previewHtml={notifyPreview.data?.html}
|
||||||
|
isPreviewLoading={notifyPreview.isLoading}
|
||||||
|
onSend={(msg) => notifyEligible.mutate({ awardId, customMessage: msg })}
|
||||||
|
isSending={notifyEligible.isPending}
|
||||||
|
onRefreshPreview={(msg) => setNotifyCustomMessage(msg)}
|
||||||
|
/>
|
||||||
|
{award.eligibilityMode === 'SEPARATE_POOL' ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => assignToFirstRound.mutate({ awardId })}
|
||||||
|
disabled={assignToFirstRound.isPending || award.eligibleCount === 0}
|
||||||
|
>
|
||||||
|
{assignToFirstRound.isPending ? (
|
||||||
|
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Assigning...</>
|
||||||
|
) : (
|
||||||
|
<><ArrowRight className="mr-2 h-4 w-4" />Assign to First Round</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleStatusChange('VOTING_OPEN')}
|
||||||
|
disabled={updateStatus.isPending}
|
||||||
|
>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Open Voting
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{award.status === 'VOTING_OPEN' && (
|
{award.status === 'VOTING_OPEN' && (
|
||||||
<Button
|
<Button
|
||||||
@@ -752,7 +1023,7 @@ export default function AwardDetailPage({
|
|||||||
'-'
|
'-'
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">{project.country || '-'}</TableCell>
|
<TableCell className="text-sm">{project.country ? <CountryDisplay country={project.country} /> : '-'}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -918,7 +1189,7 @@ export default function AwardDetailPage({
|
|||||||
'-'
|
'-'
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{e.project.country || '-'}</TableCell>
|
<TableCell>{e.project.country ? <CountryDisplay country={e.project.country} /> : '-'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs gap-1">
|
<Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs gap-1">
|
||||||
{e.method === 'MANUAL' ? 'Manual' : <><Bot className="h-3 w-3" />AI Assessed</>}
|
{e.method === 'MANUAL' ? 'Manual' : <><Bot className="h-3 w-3" />AI Assessed</>}
|
||||||
@@ -1065,7 +1336,7 @@ export default function AwardDetailPage({
|
|||||||
<TableRow key={j.id}>
|
<TableRow key={j.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<UserAvatar user={j.user} size="sm" />
|
<UserAvatar user={j.user} avatarUrl={j.user.avatarUrl} size="sm" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{j.user.name || 'Unnamed'}
|
{j.user.name || 'Unnamed'}
|
||||||
@@ -1208,99 +1479,13 @@ export default function AwardDetailPage({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
<RoundsDndGrid
|
||||||
{awardRounds.map((round: any, index: number) => {
|
rounds={awardRounds}
|
||||||
const projectCount = round._count?.projectRoundStates ?? 0
|
awardId={awardId}
|
||||||
const assignmentCount = round._count?.assignments ?? 0
|
onReorder={(roundIds) => reorderRounds.mutate({ awardId, roundIds })}
|
||||||
const statusLabel = round.status.replace('ROUND_', '')
|
onDelete={(roundId) => deleteRound.mutate({ roundId })}
|
||||||
const statusColors: Record<string, string> = {
|
isDeleting={deleteRound.isPending}
|
||||||
DRAFT: 'bg-gray-100 text-gray-600',
|
/>
|
||||||
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
|
||||||
CLOSED: 'bg-blue-100 text-blue-700',
|
|
||||||
ARCHIVED: 'bg-muted text-muted-foreground',
|
|
||||||
}
|
|
||||||
const roundTypeColors: Record<string, string> = {
|
|
||||||
EVALUATION: 'bg-violet-100 text-violet-700',
|
|
||||||
FILTERING: 'bg-amber-100 text-amber-700',
|
|
||||||
SUBMISSION: 'bg-blue-100 text-blue-700',
|
|
||||||
MENTORING: 'bg-teal-100 text-teal-700',
|
|
||||||
LIVE_FINAL: 'bg-rose-100 text-rose-700',
|
|
||||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Card key={round.id} className="hover:shadow-md transition-shadow h-full">
|
|
||||||
<CardContent className="pt-4 pb-3 space-y-3">
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Link href={`/admin/rounds/${round.id}` as any} className="text-sm font-semibold truncate hover:underline">
|
|
||||||
{round.name}
|
|
||||||
</Link>
|
|
||||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
|
||||||
<Badge variant="secondary" className={`text-[10px] ${roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}`}>
|
|
||||||
{round.roundType.replace('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className={`text-[10px] ${statusColors[statusLabel]}`}>
|
|
||||||
{statusLabel}
|
|
||||||
</Badge>
|
|
||||||
{index === 0 && (
|
|
||||||
<Badge variant="outline" className="text-[10px] border-amber-300 bg-amber-50 text-amber-700">
|
|
||||||
Entry point
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<Layers className="h-3.5 w-3.5" />
|
|
||||||
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
{assignmentCount > 0 && (
|
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<ListChecks className="h-3.5 w-3.5" />
|
|
||||||
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{round.status === 'ROUND_DRAFT' && (
|
|
||||||
<div className="flex justify-end pt-1">
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
|
|
||||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will permanently delete "{round.name}". This cannot be undone.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => deleteRound.mutate({ roundId: round.id })}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -69,11 +69,9 @@ export default function CreateAwardPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/awards">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Awards
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { ActivityFeed } from '@/components/dashboard/activity-feed'
|
|||||||
import { CategoryBreakdown } from '@/components/dashboard/category-breakdown'
|
import { CategoryBreakdown } from '@/components/dashboard/category-breakdown'
|
||||||
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton'
|
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton'
|
||||||
import { RecentEvaluations } from '@/components/dashboard/recent-evaluations'
|
import { RecentEvaluations } from '@/components/dashboard/recent-evaluations'
|
||||||
|
import { RoundUserTracker } from '@/components/dashboard/round-user-tracker'
|
||||||
|
|
||||||
type DashboardContentProps = {
|
type DashboardContentProps = {
|
||||||
editionId: string
|
editionId: string
|
||||||
@@ -115,16 +116,17 @@ function getContextualActions(
|
|||||||
export function DashboardContent({ editionId, sessionName }: DashboardContentProps) {
|
export function DashboardContent({ editionId, sessionName }: DashboardContentProps) {
|
||||||
const { data, isLoading, error } = trpc.dashboard.getStats.useQuery(
|
const { data, isLoading, error } = trpc.dashboard.getStats.useQuery(
|
||||||
{ editionId },
|
{ editionId },
|
||||||
{ enabled: !!editionId, retry: 1, refetchInterval: 30_000 }
|
{ enabled: !!editionId, refetchInterval: 60_000 }
|
||||||
)
|
)
|
||||||
const { data: recentEvals } = trpc.dashboard.getRecentEvaluations.useQuery(
|
const { data: recentEvals } = trpc.dashboard.getRecentEvaluations.useQuery(
|
||||||
{ editionId, limit: 8 },
|
{ editionId, limit: 8 },
|
||||||
{ enabled: !!editionId, refetchInterval: 30_000 }
|
{ enabled: !!editionId, refetchInterval: 60_000 }
|
||||||
)
|
)
|
||||||
const { data: liveActivity } = trpc.dashboard.getRecentActivity.useQuery(
|
const { data: liveActivity } = trpc.dashboard.getRecentActivity.useQuery(
|
||||||
{ limit: 8 },
|
{ limit: 8 },
|
||||||
{ enabled: !!editionId, refetchInterval: 5_000 }
|
{ enabled: !!editionId, refetchInterval: 30_000 }
|
||||||
)
|
)
|
||||||
|
// Round User Tracker is self-contained — it fetches its own data
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <DashboardSkeleton />
|
return <DashboardSkeleton />
|
||||||
@@ -272,6 +274,10 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
<AnimatedCard index={6}>
|
<AnimatedCard index={6}>
|
||||||
|
<RoundUserTracker editionId={editionId} />
|
||||||
|
</AnimatedCard>
|
||||||
|
|
||||||
|
<AnimatedCard index={7}>
|
||||||
<ActivityFeed activity={liveActivity ?? recentActivity} />
|
<ActivityFeed activity={liveActivity ?? recentActivity} />
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,12 +286,12 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||||||
{/* Bottom Full Width */}
|
{/* Bottom Full Width */}
|
||||||
<div className="grid gap-6 lg:grid-cols-12">
|
<div className="grid gap-6 lg:grid-cols-12">
|
||||||
<div className="lg:col-span-8">
|
<div className="lg:col-span-8">
|
||||||
<AnimatedCard index={7}>
|
<AnimatedCard index={8}>
|
||||||
<GeographicSummaryCard programId={editionId} />
|
<GeographicSummaryCard programId={editionId} />
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-4">
|
<div className="lg:col-span-4">
|
||||||
<AnimatedCard index={8}>
|
<AnimatedCard index={9}>
|
||||||
<CategoryBreakdown
|
<CategoryBreakdown
|
||||||
categories={categoryBreakdown}
|
categories={categoryBreakdown}
|
||||||
issues={oceanIssueBreakdown}
|
issues={oceanIssueBreakdown}
|
||||||
|
|||||||
@@ -194,10 +194,8 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
|
|||||||
<Card className="border-dashed">
|
<Card className="border-dashed">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<p className="text-muted-foreground">The requested jury group could not be found.</p>
|
<p className="text-muted-foreground">The requested jury group could not be found.</p>
|
||||||
<Button asChild className="mt-4" variant="outline">
|
<Button className="mt-4" variant="outline" onClick={() => router.back()}>
|
||||||
<Link href={'/admin/juries' as Route}>
|
Back
|
||||||
Back to Juries
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -212,13 +210,11 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
asChild
|
|
||||||
className="mb-2"
|
className="mb-2"
|
||||||
|
onClick={() => router.back()}
|
||||||
>
|
>
|
||||||
<Link href={'/admin/juries' as Route}>
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
Back
|
||||||
Back to Juries
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -289,7 +285,9 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
|
|||||||
{group.members.map((member) => (
|
{group.members.map((member) => (
|
||||||
<TableRow key={member.id}>
|
<TableRow key={member.id}>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
{member.user.name || 'Unnamed'}
|
<Link href={`/admin/members/${member.user.id}` as Route} className="hover:underline text-primary">
|
||||||
|
{member.user.name || 'Unnamed'}
|
||||||
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{member.user.email}
|
{member.user.email}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -257,11 +256,9 @@ export default function EditLearningResourcePage() {
|
|||||||
The resource you're looking for does not exist.
|
The resource you're looking for does not exist.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<Button asChild>
|
<Button onClick={() => router.back()}>
|
||||||
<Link href="/admin/learning">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Learning Hub
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -271,11 +268,9 @@ export default function EditLearningResourcePage() {
|
|||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* Sticky toolbar */}
|
{/* Sticky toolbar */}
|
||||||
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||||
<Link href="/admin/learning">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -165,11 +164,9 @@ export default function NewLearningResourcePage() {
|
|||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* Sticky toolbar */}
|
{/* Sticky toolbar */}
|
||||||
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||||
<Link href="/admin/learning">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { directSessionUpdate } from '@/lib/session-update'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
@@ -33,8 +34,10 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { TagInput } from '@/components/shared/tag-input'
|
import { TagInput } from '@/components/shared/tag-input'
|
||||||
import { UserActivityLog } from '@/components/shared/user-activity-log'
|
import { UserActivityLog } from '@/components/shared/user-activity-log'
|
||||||
|
import { EvaluationEditSheet } from '@/components/admin/evaluation-edit-sheet'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -45,6 +48,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Save,
|
Save,
|
||||||
@@ -53,7 +57,48 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
ClipboardList,
|
||||||
|
Eye,
|
||||||
|
ThumbsUp,
|
||||||
|
ThumbsDown,
|
||||||
|
Globe,
|
||||||
|
Building2,
|
||||||
|
FileText,
|
||||||
|
FolderOpen,
|
||||||
|
LogIn,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||||
|
import { formatRelativeTime } from '@/lib/utils'
|
||||||
|
|
||||||
|
function getRoleHomePath(role: string): string {
|
||||||
|
switch (role) {
|
||||||
|
case 'JURY_MEMBER': return '/jury'
|
||||||
|
case 'APPLICANT': return '/applicant'
|
||||||
|
case 'MENTOR': return '/mentor'
|
||||||
|
case 'OBSERVER': return '/observer'
|
||||||
|
default: return '/admin'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusVariant: Record<string, 'default' | 'success' | 'destructive' | 'secondary'> = {
|
||||||
|
ACTIVE: 'success',
|
||||||
|
SUSPENDED: 'destructive',
|
||||||
|
INVITED: 'secondary',
|
||||||
|
NONE: 'secondary',
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
||||||
|
JURY_MEMBER: 'default',
|
||||||
|
MENTOR: 'secondary',
|
||||||
|
OBSERVER: 'outline',
|
||||||
|
PROGRAM_ADMIN: 'default',
|
||||||
|
SUPER_ADMIN: 'default',
|
||||||
|
APPLICANT: 'secondary',
|
||||||
|
AWARD_MASTER: 'outline',
|
||||||
|
AUDIENCE: 'outline',
|
||||||
|
}
|
||||||
|
|
||||||
export default function MemberDetailPage() {
|
export default function MemberDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -66,6 +111,7 @@ 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 startImpersonation = trpc.user.startImpersonation.useMutation()
|
||||||
|
|
||||||
// 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(
|
||||||
@@ -73,6 +119,16 @@ export default function MemberDetailPage() {
|
|||||||
{ enabled: user?.role === 'MENTOR' }
|
{ enabled: user?.role === 'MENTOR' }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Juror evaluations (only fetched for jury members)
|
||||||
|
const isJuror = user?.role === 'JURY_MEMBER' || user?.roles?.includes('JURY_MEMBER')
|
||||||
|
const { data: jurorEvaluations } = trpc.evaluation.getJurorEvaluations.useQuery(
|
||||||
|
{ userId },
|
||||||
|
{ enabled: !!user && !!isJuror }
|
||||||
|
)
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const [selectedEvaluation, setSelectedEvaluation] = useState<any>(null)
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [role, setRole] = useState<string>('JURY_MEMBER')
|
const [role, setRole] = useState<string>('JURY_MEMBER')
|
||||||
@@ -99,7 +155,7 @@ export default function MemberDetailPage() {
|
|||||||
id: userId,
|
id: userId,
|
||||||
email: email || undefined,
|
email: email || undefined,
|
||||||
name: name || null,
|
name: name || null,
|
||||||
role: role as 'SUPER_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN',
|
role: role as 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE',
|
||||||
status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED',
|
status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED',
|
||||||
expertiseTags,
|
expertiseTags,
|
||||||
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
|
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
|
||||||
@@ -107,7 +163,6 @@ export default function MemberDetailPage() {
|
|||||||
utils.user.get.invalidate({ id: userId })
|
utils.user.get.invalidate({ id: userId })
|
||||||
utils.user.list.invalidate()
|
utils.user.list.invalidate()
|
||||||
toast.success('Member updated successfully')
|
toast.success('Member updated successfully')
|
||||||
router.push('/admin/members')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to update member')
|
toast.error(error instanceof Error ? error.message : 'Failed to update member')
|
||||||
}
|
}
|
||||||
@@ -124,20 +179,37 @@ export default function MemberDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleImpersonate = async () => {
|
||||||
|
try {
|
||||||
|
const result = await startImpersonation.mutateAsync({ targetUserId: userId })
|
||||||
|
const ok = await directSessionUpdate({ impersonate: userId })
|
||||||
|
if (!ok) {
|
||||||
|
toast.error('Failed to update session for impersonation')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.location.href = getRoleHomePath(result.targetRole)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to start impersonation')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Skeleton className="h-9 w-32" />
|
<Skeleton className="h-9 w-32" />
|
||||||
<Card>
|
<div className="flex items-center gap-4">
|
||||||
<CardHeader>
|
<Skeleton className="h-16 w-16 rounded-full" />
|
||||||
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-6 w-48" />
|
<Skeleton className="h-6 w-48" />
|
||||||
<Skeleton className="h-4 w-72" />
|
<Skeleton className="h-4 w-72" />
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="space-y-4">
|
</div>
|
||||||
<Skeleton className="h-10 w-full" />
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
<Skeleton className="h-10 w-full" />
|
<div className="lg:col-span-2 space-y-6">
|
||||||
</CardContent>
|
<Skeleton className="h-48 w-full" />
|
||||||
</Card>
|
</div>
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -150,286 +222,573 @@ export default function MemberDetailPage() {
|
|||||||
<AlertTitle>Error Loading Member</AlertTitle>
|
<AlertTitle>Error Loading Member</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{error?.message || 'The member you\'re looking for does not exist.'}
|
{error?.message || 'The member you\'re looking for does not exist.'}
|
||||||
{process.env.NODE_ENV === 'development' && (
|
|
||||||
<div className="mt-2 text-xs opacity-75">
|
|
||||||
User ID: {userId}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<Button asChild>
|
<Button onClick={() => router.back()}>
|
||||||
<Link href="/admin/members">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Members
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayRoles = user.roles?.length ? user.roles : [user.role]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Back nav */}
|
||||||
<div className="flex items-center gap-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<Link href="/admin/members">
|
Back
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
</Button>
|
||||||
Back to Members
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between">
|
{/* Header Hero */}
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<div className="flex items-center gap-4">
|
||||||
{user.name || 'Unnamed Member'}
|
<UserAvatar user={user} avatarUrl={user.avatarUrl} size="lg" />
|
||||||
</h1>
|
<div>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
{user.name || 'Unnamed Member'}
|
||||||
|
</h1>
|
||||||
<p className="text-muted-foreground">{user.email}</p>
|
<p className="text-muted-foreground">{user.email}</p>
|
||||||
<Badge variant={user.status === 'ACTIVE' ? 'success' : user.status === 'SUSPENDED' ? 'destructive' : 'secondary'}>
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
{user.status === 'NONE' ? 'Not Invited' : user.status}
|
<Badge variant={statusVariant[user.status] || 'secondary'}>
|
||||||
</Badge>
|
{user.status === 'NONE' ? 'Not Invited' : user.status}
|
||||||
|
</Badge>
|
||||||
|
{displayRoles.map((r) => (
|
||||||
|
<Badge key={r} variant={roleColors[r] || 'secondary'} className="text-xs">
|
||||||
|
{r.replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(user.status === 'NONE' || user.status === 'INVITED') && (
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{(user.status === 'NONE' || user.status === 'INVITED') && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSendInvitation}
|
||||||
|
disabled={sendInvitation.isPending}
|
||||||
|
>
|
||||||
|
{sendInvitation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{user.status === 'INVITED' ? 'Resend Invite' : 'Send Invitation'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleSendInvitation}
|
onClick={handleImpersonate}
|
||||||
disabled={sendInvitation.isPending}
|
disabled={startImpersonation.isPending}
|
||||||
>
|
>
|
||||||
{sendInvitation.isPending ? (
|
{startImpersonation.isPending ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Mail className="mr-2 h-4 w-4" />
|
<LogIn className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Send Invitation
|
Impersonate
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<Tabs defaultValue="profile" className="space-y-6">
|
||||||
{/* Basic Info */}
|
<TabsList>
|
||||||
<Card>
|
<TabsTrigger value="profile">
|
||||||
<CardHeader>
|
<User className="h-4 w-4 mr-1" />
|
||||||
<CardTitle className="flex items-center gap-2">
|
Profile
|
||||||
<User className="h-5 w-5" />
|
</TabsTrigger>
|
||||||
Basic Information
|
{isJuror && (
|
||||||
</CardTitle>
|
<TabsTrigger value="evaluations">
|
||||||
</CardHeader>
|
<ClipboardList className="h-4 w-4 mr-1" />
|
||||||
<CardContent className="space-y-4">
|
Evaluations
|
||||||
<div className="space-y-2">
|
{jurorEvaluations && jurorEvaluations.length > 0 && (
|
||||||
<Label htmlFor="email">Email</Label>
|
<Badge variant="secondary" className="ml-1.5 text-xs px-1.5 py-0">
|
||||||
<Input
|
{jurorEvaluations.length}
|
||||||
id="email"
|
</Badge>
|
||||||
type="email"
|
)}
|
||||||
value={email}
|
</TabsTrigger>
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="Enter name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="role">Role</Label>
|
|
||||||
<Select
|
|
||||||
value={role}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
if (v === 'SUPER_ADMIN') {
|
|
||||||
setPendingSuperAdminRole(true)
|
|
||||||
setShowSuperAdminConfirm(true)
|
|
||||||
} else {
|
|
||||||
setRole(v)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!isSuperAdmin && (user.role === 'SUPER_ADMIN' || user.role === 'PROGRAM_ADMIN')}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="role">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{isSuperAdmin && (
|
|
||||||
<SelectItem value="SUPER_ADMIN">Super Admin</SelectItem>
|
|
||||||
)}
|
|
||||||
{isSuperAdmin && (
|
|
||||||
<SelectItem value="PROGRAM_ADMIN">Program Admin</SelectItem>
|
|
||||||
)}
|
|
||||||
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
|
|
||||||
<SelectItem value="MENTOR">Mentor</SelectItem>
|
|
||||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="status">Status</Label>
|
|
||||||
<Select value={status} onValueChange={setStatus}>
|
|
||||||
<SelectTrigger id="status">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="NONE">Not Invited</SelectItem>
|
|
||||||
<SelectItem value="INVITED">Invited</SelectItem>
|
|
||||||
<SelectItem value="ACTIVE">Active</SelectItem>
|
|
||||||
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Expertise & Capacity */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Shield className="h-5 w-5" />
|
|
||||||
Expertise & Capacity
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Expertise Tags</Label>
|
|
||||||
<TagInput
|
|
||||||
value={expertiseTags}
|
|
||||||
onChange={setExpertiseTags}
|
|
||||||
placeholder="Select expertise tags..."
|
|
||||||
maxTags={15}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="maxAssignments">Max Assignments</Label>
|
|
||||||
<Input
|
|
||||||
id="maxAssignments"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="100"
|
|
||||||
value={maxAssignments}
|
|
||||||
onChange={(e) => setMaxAssignments(e.target.value)}
|
|
||||||
placeholder="Unlimited"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{user._count && (
|
|
||||||
<div className="pt-4 border-t">
|
|
||||||
<h4 className="font-medium mb-2">Statistics</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Jury Assignments</p>
|
|
||||||
<p className="text-2xl font-semibold">{user._count.assignments}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Mentor Assignments</p>
|
|
||||||
<p className="text-2xl font-semibold">{user._count.mentorAssignments}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mentor Assignments Section */}
|
|
||||||
{user.role === 'MENTOR' && mentorAssignments && mentorAssignments.assignments.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Mentored Projects</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Projects this mentor is assigned to
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Project</TableHead>
|
|
||||||
<TableHead>Category</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Assigned</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{mentorAssignments.assignments.map((assignment) => (
|
|
||||||
<TableRow key={assignment.id}>
|
|
||||||
<TableCell>
|
|
||||||
<Link
|
|
||||||
href={`/admin/projects/${assignment.project.id}`}
|
|
||||||
className="font-medium hover:underline"
|
|
||||||
>
|
|
||||||
{assignment.project.title}
|
|
||||||
</Link>
|
|
||||||
{assignment.project.teamName && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{assignment.project.teamName}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{assignment.project.competitionCategory ? (
|
|
||||||
<Badge variant="outline">
|
|
||||||
{assignment.project.competitionCategory.replace('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
'-'
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{assignment.project.status ?? 'SUBMITTED'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
|
||||||
{new Date(assignment.assignedAt).toLocaleDateString()}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Activity Log */}
|
|
||||||
<UserActivityLog userId={userId} />
|
|
||||||
|
|
||||||
{/* Status Alert */}
|
|
||||||
{user.status === 'NONE' && (
|
|
||||||
<Alert>
|
|
||||||
<Mail className="h-4 w-4" />
|
|
||||||
<AlertTitle>Not Yet Invited</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
This member was added to the platform via project import but hasn't been
|
|
||||||
invited yet. Send them an invitation using the button above.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{user.status === 'INVITED' && (
|
|
||||||
<Alert>
|
|
||||||
<Mail className="h-4 w-4" />
|
|
||||||
<AlertTitle>Invitation Pending</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
This member hasn't accepted their invitation yet. You can resend the
|
|
||||||
invitation email using the button above.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Save Button */}
|
|
||||||
<div className="flex justify-end gap-4">
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link href="/admin/members">Cancel</Link>
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} disabled={updateUser.isPending}>
|
|
||||||
{updateUser.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
)}
|
||||||
Save Changes
|
</TabsList>
|
||||||
</Button>
|
|
||||||
</div>
|
<TabsContent value="profile" className="space-y-6">
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* Left column: Profile info + Projects */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Profile Details (read-only) */}
|
||||||
|
{(user.nationality || user.country || user.institution || user.bio) && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
||||||
|
<Globe className="h-4 w-4 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
Profile Details
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Information provided during onboarding</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{user.nationality && (
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border p-3">
|
||||||
|
<span className="text-xl mt-0.5 shrink-0" role="img">{getCountryFlag(user.nationality)}</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Nationality</p>
|
||||||
|
<p className="text-sm font-medium">{getCountryName(user.nationality)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{user.country && (
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border p-3">
|
||||||
|
<span className="text-xl mt-0.5 shrink-0" role="img">{getCountryFlag(user.country)}</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Country of Residence</p>
|
||||||
|
<p className="text-sm font-medium">{getCountryName(user.country)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{user.institution && (
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border p-3">
|
||||||
|
<Building2 className="h-5 w-5 mt-0.5 text-muted-foreground shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Institution / Organization</p>
|
||||||
|
<p className="text-sm font-medium">{user.institution}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{user.bio && (
|
||||||
|
<div className="sm:col-span-2 rounded-lg border p-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<FileText className="h-5 w-5 mt-0.5 text-muted-foreground shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Bio</p>
|
||||||
|
<p className="text-sm whitespace-pre-line mt-1">{user.bio}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Projects */}
|
||||||
|
{user.teamMemberships && user.teamMemberships.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
||||||
|
<FolderOpen className="h-4 w-4 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
Projects ({user.teamMemberships.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="divide-y">
|
||||||
|
{user.teamMemberships.map((tm) => (
|
||||||
|
<Link
|
||||||
|
key={tm.id}
|
||||||
|
href={`/admin/projects/${tm.project.id}`}
|
||||||
|
className="flex items-center justify-between px-6 py-3 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-sm truncate">{tm.project.title}</p>
|
||||||
|
{tm.project.teamName && (
|
||||||
|
<p className="text-xs text-muted-foreground">{tm.project.teamName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||||
|
{tm.project.competitionCategory && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{tm.project.competitionCategory.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{tm.role === 'LEAD' ? 'Lead' : tm.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Jury Groups */}
|
||||||
|
{user.juryGroupMemberships && user.juryGroupMemberships.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||||
|
<Shield className="h-4 w-4 text-violet-500" />
|
||||||
|
</div>
|
||||||
|
Jury Groups ({user.juryGroupMemberships.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{user.juryGroupMemberships.map((m: { id: string; role: string; juryGroup: { id: string; name: string } }) => (
|
||||||
|
<Badge key={m.id} variant="outline" className="text-sm py-1.5 px-3">
|
||||||
|
{m.juryGroup.name}
|
||||||
|
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||||
|
({m.role === 'CHAIR' ? 'Chair' : m.role === 'OBSERVER' ? 'Observer' : 'Member'})
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mentor Assignments */}
|
||||||
|
{user.role === 'MENTOR' && mentorAssignments && mentorAssignments.assignments.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<div className="rounded-lg bg-amber-500/10 p-1.5">
|
||||||
|
<ClipboardList className="h-4 w-4 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
Mentored Projects
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{mentorAssignments.assignments.length} project{mentorAssignments.assignments.length !== 1 ? 's' : ''} assigned
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Project</TableHead>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Assigned</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{mentorAssignments.assignments.map((assignment) => (
|
||||||
|
<TableRow key={assignment.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={`/admin/projects/${assignment.project.id}`}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{assignment.project.title}
|
||||||
|
</Link>
|
||||||
|
{assignment.project.teamName && (
|
||||||
|
<p className="text-sm text-muted-foreground">{assignment.project.teamName}</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{assignment.project.competitionCategory ? (
|
||||||
|
<Badge variant="outline">{assignment.project.competitionCategory.replace('_', ' ')}</Badge>
|
||||||
|
) : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{assignment.project.status ?? 'SUBMITTED'}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{new Date(assignment.assignedAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Activity Log */}
|
||||||
|
<UserActivityLog userId={userId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right sidebar: Edit form + Quick info */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Quick Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<div className="rounded-lg bg-slate-500/10 p-1.5">
|
||||||
|
<Clock className="h-4 w-4 text-slate-500" />
|
||||||
|
</div>
|
||||||
|
Quick Info
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Created</span>
|
||||||
|
<span>{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Last Login</span>
|
||||||
|
<span>
|
||||||
|
{user.lastLoginAt ? (
|
||||||
|
<span title={new Date(user.lastLoginAt).toLocaleString()}>
|
||||||
|
{formatRelativeTime(user.lastLoginAt)}
|
||||||
|
</span>
|
||||||
|
) : 'Never'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{user._count && !['APPLICANT', 'AUDIENCE'].includes(user.role) && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Jury Assignments</span>
|
||||||
|
<span className="font-semibold">{user._count.assignments}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Mentor Assignments</span>
|
||||||
|
<span className="font-semibold">{user._count.mentorAssignments}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Status Alerts */}
|
||||||
|
{user.status === 'NONE' && (
|
||||||
|
<Alert>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
<AlertTitle>Not Yet Invited</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
This member was added via import but hasn't been invited yet.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{user.status === 'INVITED' && (
|
||||||
|
<Alert>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
<AlertTitle>Invitation Pending</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
This member hasn't accepted their invitation yet.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Basic Info Edit */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
||||||
|
<User className="h-4 w-4 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
Edit Details
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Enter name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="role">Role</Label>
|
||||||
|
<Select
|
||||||
|
value={role}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v === 'SUPER_ADMIN') {
|
||||||
|
setPendingSuperAdminRole(true)
|
||||||
|
setShowSuperAdminConfirm(true)
|
||||||
|
} else {
|
||||||
|
setRole(v)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!isSuperAdmin && (user.role === 'SUPER_ADMIN' || user.role === 'PROGRAM_ADMIN')}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="role">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{isSuperAdmin && <SelectItem value="SUPER_ADMIN">Super Admin</SelectItem>}
|
||||||
|
{isSuperAdmin && <SelectItem value="PROGRAM_ADMIN">Program Admin</SelectItem>}
|
||||||
|
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
|
||||||
|
<SelectItem value="MENTOR">Mentor</SelectItem>
|
||||||
|
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||||
|
<SelectItem value="APPLICANT">Applicant</SelectItem>
|
||||||
|
<SelectItem value="AWARD_MASTER">Award Master</SelectItem>
|
||||||
|
<SelectItem value="AUDIENCE">Audience</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="status">Status</Label>
|
||||||
|
<Select value={status} onValueChange={setStatus}>
|
||||||
|
<SelectTrigger id="status">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="NONE">Not Invited</SelectItem>
|
||||||
|
<SelectItem value="INVITED">Invited</SelectItem>
|
||||||
|
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||||
|
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expertise & Capacity for non-applicant */}
|
||||||
|
{!['APPLICANT', 'AUDIENCE'].includes(user.role) && (
|
||||||
|
<>
|
||||||
|
<div className="border-t pt-4 space-y-2">
|
||||||
|
<Label>Expertise Tags</Label>
|
||||||
|
<TagInput
|
||||||
|
value={expertiseTags}
|
||||||
|
onChange={setExpertiseTags}
|
||||||
|
placeholder="Select expertise tags..."
|
||||||
|
maxTags={15}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="maxAssignments">Max Assignments</Label>
|
||||||
|
<Input
|
||||||
|
id="maxAssignments"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={maxAssignments}
|
||||||
|
onChange={(e) => setMaxAssignments(e.target.value)}
|
||||||
|
placeholder="Unlimited"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={handleSave} disabled={updateUser.isPending} className="w-full">
|
||||||
|
{updateUser.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Evaluations Tab */}
|
||||||
|
{isJuror && (
|
||||||
|
<TabsContent value="evaluations" className="space-y-4">
|
||||||
|
{!jurorEvaluations || jurorEvaluations.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<ClipboardList className="h-12 w-12 text-muted-foreground/30" />
|
||||||
|
<p className="mt-2 text-muted-foreground">No evaluations submitted yet</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
const byRound = new Map<string, typeof jurorEvaluations>()
|
||||||
|
for (const ev of jurorEvaluations) {
|
||||||
|
const key = ev.roundName
|
||||||
|
if (!byRound.has(key)) byRound.set(key, [])
|
||||||
|
byRound.get(key)!.push(ev)
|
||||||
|
}
|
||||||
|
return Array.from(byRound.entries()).map(([roundName, evals]) => (
|
||||||
|
<Card key={roundName}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{roundName}</CardTitle>
|
||||||
|
<CardDescription>{evals.length} evaluation{evals.length !== 1 ? 's' : ''}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Project</TableHead>
|
||||||
|
<TableHead>Score</TableHead>
|
||||||
|
<TableHead>Decision</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Submitted</TableHead>
|
||||||
|
<TableHead className="w-10"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{evals.map((ev) => (
|
||||||
|
<TableRow key={ev.assignmentId}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<Link
|
||||||
|
href={`/admin/projects/${ev.projectId}`}
|
||||||
|
className="hover:underline text-primary"
|
||||||
|
>
|
||||||
|
{ev.projectTitle}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{ev.evaluation.globalScore !== null && ev.evaluation.globalScore !== undefined
|
||||||
|
? <span className="font-medium">{ev.evaluation.globalScore}/10</span>
|
||||||
|
: <span className="text-muted-foreground">-</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{ev.evaluation.binaryDecision !== null && ev.evaluation.binaryDecision !== undefined ? (
|
||||||
|
ev.evaluation.binaryDecision ? (
|
||||||
|
<div className="flex items-center gap-1 text-green-600">
|
||||||
|
<ThumbsUp className="h-4 w-4" />
|
||||||
|
<span className="text-sm">Yes</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 text-red-600">
|
||||||
|
<ThumbsDown className="h-4 w-4" />
|
||||||
|
<span className="text-sm">No</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={ev.evaluation.status === 'SUBMITTED' ? 'default' : 'secondary'}>
|
||||||
|
{ev.evaluation.status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{ev.evaluation.submittedAt
|
||||||
|
? new Date(ev.evaluation.submittedAt).toLocaleDateString()
|
||||||
|
: '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedEvaluation({
|
||||||
|
...ev,
|
||||||
|
user: user,
|
||||||
|
evaluation: ev.evaluation,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EvaluationEditSheet
|
||||||
|
assignment={selectedEvaluation}
|
||||||
|
open={!!selectedEvaluation}
|
||||||
|
onOpenChange={(open) => { if (!open) setSelectedEvaluation(null) }}
|
||||||
|
onSaved={() => utils.evaluation.getJurorEvaluations.invalidate({ userId })}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
{/* Super Admin Confirmation Dialog */}
|
{/* Super Admin Confirmation Dialog */}
|
||||||
<AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}>
|
<AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}>
|
||||||
@@ -443,11 +802,7 @@ export default function MemberDetailPage() {
|
|||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel
|
<AlertDialogCancel onClick={() => setPendingSuperAdminRole(false)}>
|
||||||
onClick={() => {
|
|
||||||
setPendingSuperAdminRole(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useCallback, useMemo } from 'react'
|
import { useState, useCallback, useMemo } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import Papa from 'papaparse'
|
import Papa from 'papaparse'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -257,6 +258,7 @@ function TagPicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MemberInvitePage() {
|
export default function MemberInvitePage() {
|
||||||
|
const router = useRouter()
|
||||||
const [step, setStep] = useState<Step>('input')
|
const [step, setStep] = useState<Step>('input')
|
||||||
const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual')
|
const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual')
|
||||||
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
|
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
|
||||||
@@ -1044,11 +1046,9 @@ export default function MemberInvitePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/members">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Members
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Link from 'next/link'
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -83,6 +83,7 @@ const defaultForm: TemplateFormData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageTemplatesPage() {
|
export default function MessageTemplatesPage() {
|
||||||
|
const router = useRouter()
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
@@ -183,11 +184,9 @@ export default function MessageTemplatesPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/messages">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Messages
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -135,11 +135,9 @@ export default function EditPartnerPage() {
|
|||||||
<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 className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href="/admin/partners">
|
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||||
<Button variant="ghost" size="icon">
|
<ArrowLeft className="h-4 w-4" />
|
||||||
<ArrowLeft className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Edit Partner</h1>
|
<h1 className="text-2xl font-bold">Edit Partner</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
|
|||||||
@@ -66,11 +66,9 @@ export default function NewPartnerPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href="/admin/partners">
|
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||||
<Button variant="ghost" size="icon">
|
<ArrowLeft className="h-4 w-4" />
|
||||||
<ArrowLeft className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Add Partner</h1>
|
<h1 className="text-2xl font-bold">Add Partner</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
|
|||||||
@@ -134,11 +134,9 @@ export default function EditProgramPage() {
|
|||||||
<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 className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href={`/admin/programs/${id}`}>
|
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||||
<Button variant="ghost" size="icon">
|
<ArrowLeft className="h-4 w-4" />
|
||||||
<ArrowLeft className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Edit Program</h1>
|
<h1 className="text-2xl font-bold">Edit Program</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -68,6 +67,7 @@ const defaultMilestoneForm: MilestoneFormData = {
|
|||||||
|
|
||||||
export default function MentorshipMilestonesPage() {
|
export default function MentorshipMilestonesPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
const programId = params.id as string
|
const programId = params.id as string
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
@@ -184,11 +184,9 @@ export default function MentorshipMilestonesPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/programs">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Programs
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -56,11 +56,9 @@ export default function NewProgramPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href="/admin/programs">
|
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||||
<Button variant="ghost" size="icon">
|
<ArrowLeft className="h-4 w-4" />
|
||||||
<ArrowLeft className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Create Program</h1>
|
<h1 className="text-2xl font-bold">Create Program</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Suspense, use, useState, useEffect, useCallback } from 'react'
|
import { Suspense, use, useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
@@ -19,6 +19,8 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { CountrySelect } from '@/components/ui/country-select'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -55,6 +57,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { FileUpload } from '@/components/shared/file-upload'
|
import { FileUpload } from '@/components/shared/file-upload'
|
||||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||||
import { LogoUpload } from '@/components/shared/logo-upload'
|
import { LogoUpload } from '@/components/shared/logo-upload'
|
||||||
@@ -87,8 +90,15 @@ const updateProjectSchema = z.object({
|
|||||||
'SEMIFINALIST',
|
'SEMIFINALIST',
|
||||||
'FINALIST',
|
'FINALIST',
|
||||||
'REJECTED',
|
'REJECTED',
|
||||||
]),
|
]).optional(),
|
||||||
tags: z.array(z.string()),
|
tags: z.array(z.string()),
|
||||||
|
competitionCategory: z.string().optional(),
|
||||||
|
oceanIssue: z.string().optional(),
|
||||||
|
institution: z.string().optional(),
|
||||||
|
country: z.string().optional(),
|
||||||
|
geographicZone: z.string().optional(),
|
||||||
|
wantsMentorship: z.boolean().optional(),
|
||||||
|
foundedAt: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type UpdateProjectForm = z.infer<typeof updateProjectSchema>
|
type UpdateProjectForm = z.infer<typeof updateProjectSchema>
|
||||||
@@ -124,6 +134,27 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
|||||||
// Fetch existing tags for suggestions
|
// Fetch existing tags for suggestions
|
||||||
const { data: existingTags } = trpc.project.getTags.useQuery({})
|
const { data: existingTags } = trpc.project.getTags.useQuery({})
|
||||||
|
|
||||||
|
// Fetch submission round config to show required documents
|
||||||
|
const programId = project?.programId
|
||||||
|
const { data: submissionRound } = trpc.round.getSubmissionRoundForProgram.useQuery(
|
||||||
|
{ programId: programId! },
|
||||||
|
{ enabled: !!programId }
|
||||||
|
)
|
||||||
|
|
||||||
|
const submissionRoundConfig = useMemo(() => {
|
||||||
|
if (!submissionRound?.configJson) return null
|
||||||
|
const config = submissionRound.configJson as Record<string, unknown>
|
||||||
|
const docs = config.requiredDocuments as
|
||||||
|
| Array<{ name: string; required?: boolean; description?: string }>
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
if (!docs || docs.length === 0) return null
|
||||||
|
return {
|
||||||
|
roundName: submissionRound.name,
|
||||||
|
requiredDocuments: docs,
|
||||||
|
}
|
||||||
|
}, [submissionRound])
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const updateProject = trpc.project.update.useMutation({
|
const updateProject = trpc.project.update.useMutation({
|
||||||
@@ -155,8 +186,15 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
|||||||
title: '',
|
title: '',
|
||||||
teamName: '',
|
teamName: '',
|
||||||
description: '',
|
description: '',
|
||||||
status: 'SUBMITTED',
|
status: 'SUBMITTED' as const,
|
||||||
tags: [],
|
tags: [],
|
||||||
|
competitionCategory: '',
|
||||||
|
oceanIssue: '',
|
||||||
|
institution: '',
|
||||||
|
country: '',
|
||||||
|
geographicZone: '',
|
||||||
|
wantsMentorship: false,
|
||||||
|
foundedAt: '',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -167,8 +205,15 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
|||||||
title: project.title,
|
title: project.title,
|
||||||
teamName: project.teamName || '',
|
teamName: project.teamName || '',
|
||||||
description: project.description || '',
|
description: project.description || '',
|
||||||
status: (project.status ?? 'SUBMITTED') as UpdateProjectForm['status'],
|
status: (project.status || 'SUBMITTED') as UpdateProjectForm['status'],
|
||||||
tags: project.tags || [],
|
tags: project.tags || [],
|
||||||
|
competitionCategory: project.competitionCategory || '',
|
||||||
|
oceanIssue: project.oceanIssue || '',
|
||||||
|
institution: project.institution || '',
|
||||||
|
country: project.country || '',
|
||||||
|
geographicZone: project.geographicZone || '',
|
||||||
|
wantsMentorship: project.wantsMentorship ?? false,
|
||||||
|
foundedAt: project.foundedAt ? new Date(project.foundedAt).toISOString().split('T')[0] : '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [project, form])
|
}, [project, form])
|
||||||
@@ -176,7 +221,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
|||||||
const tags = form.watch('tags')
|
const tags = form.watch('tags')
|
||||||
const selectedStatus = form.watch('status')
|
const selectedStatus = form.watch('status')
|
||||||
const previousStatus = (project?.status ?? 'SUBMITTED') as UpdateProjectForm['status']
|
const previousStatus = (project?.status ?? 'SUBMITTED') as UpdateProjectForm['status']
|
||||||
const statusTriggersNotifications = ['SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(selectedStatus)
|
const statusTriggersNotifications = !!selectedStatus && ['SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(selectedStatus)
|
||||||
const requiresStatusNotificationConfirmation = Boolean(
|
const requiresStatusNotificationConfirmation = Boolean(
|
||||||
project && selectedStatus !== previousStatus && statusTriggersNotifications
|
project && selectedStatus !== previousStatus && statusTriggersNotifications
|
||||||
)
|
)
|
||||||
@@ -222,13 +267,21 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statusChanged = data.status !== previousStatus
|
||||||
await updateProject.mutateAsync({
|
await updateProject.mutateAsync({
|
||||||
id: projectId,
|
id: projectId,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
teamName: data.teamName || null,
|
teamName: data.teamName || null,
|
||||||
description: data.description || null,
|
description: data.description || null,
|
||||||
status: data.status,
|
...(statusChanged && { status: data.status }),
|
||||||
tags: data.tags,
|
tags: data.tags,
|
||||||
|
competitionCategory: (data.competitionCategory || null) as 'STARTUP' | 'BUSINESS_CONCEPT' | null,
|
||||||
|
oceanIssue: (data.oceanIssue || null) as 'POLLUTION_REDUCTION' | 'CLIMATE_MITIGATION' | 'TECHNOLOGY_INNOVATION' | 'SUSTAINABLE_SHIPPING' | 'BLUE_CARBON' | 'HABITAT_RESTORATION' | 'COMMUNITY_CAPACITY' | 'SUSTAINABLE_FISHING' | 'CONSUMER_AWARENESS' | 'OCEAN_ACIDIFICATION' | 'OTHER' | null,
|
||||||
|
institution: data.institution || null,
|
||||||
|
country: data.country || null,
|
||||||
|
geographicZone: data.geographicZone || null,
|
||||||
|
wantsMentorship: data.wantsMentorship,
|
||||||
|
foundedAt: data.foundedAt ? new Date(data.foundedAt).toISOString() : null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,19 +300,17 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
|||||||
if (!project) {
|
if (!project) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/projects">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Projects
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||||
<p className="mt-2 font-medium">Project Not Found</p>
|
<p className="mt-2 font-medium">Project Not Found</p>
|
||||||
<Button asChild className="mt-4">
|
<Button className="mt-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/projects">Back to Projects</Link>
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -277,11 +328,9 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href={`/admin/projects/${projectId}`}>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Project
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -386,7 +435,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
|||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue placeholder="Select status..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -438,6 +487,159 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Project Details */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Project Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Additional categorization and metadata for this project
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="competitionCategory"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Competition Category</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value || ''}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select category..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="STARTUP">Startup</SelectItem>
|
||||||
|
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="oceanIssue"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Ocean Issue</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value || ''}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select ocean issue..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="POLLUTION_REDUCTION">Pollution Reduction</SelectItem>
|
||||||
|
<SelectItem value="CLIMATE_MITIGATION">Climate Mitigation</SelectItem>
|
||||||
|
<SelectItem value="TECHNOLOGY_INNOVATION">Technology Innovation</SelectItem>
|
||||||
|
<SelectItem value="SUSTAINABLE_SHIPPING">Sustainable Shipping</SelectItem>
|
||||||
|
<SelectItem value="BLUE_CARBON">Blue Carbon</SelectItem>
|
||||||
|
<SelectItem value="HABITAT_RESTORATION">Habitat Restoration</SelectItem>
|
||||||
|
<SelectItem value="COMMUNITY_CAPACITY">Community Capacity</SelectItem>
|
||||||
|
<SelectItem value="SUSTAINABLE_FISHING">Sustainable Fishing</SelectItem>
|
||||||
|
<SelectItem value="CONSUMER_AWARENESS">Consumer Awareness</SelectItem>
|
||||||
|
<SelectItem value="OCEAN_ACIDIFICATION">Ocean Acidification</SelectItem>
|
||||||
|
<SelectItem value="OTHER">Other</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="institution"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Institution</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Institution or organization" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="country"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Country</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<CountrySelect
|
||||||
|
value={field.value || ''}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder="Select country..."
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="geographicZone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Geographic Zone</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g. Europe, France" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="foundedAt"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Founded Date</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="date" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="wantsMentorship"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Wants Mentorship</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Whether this project team is interested in mentorship
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value ?? false}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -519,7 +721,34 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{files && files.length > 0 ? (
|
{submissionRoundConfig && (
|
||||||
|
<div className="mb-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Required Documents</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
From {submissionRoundConfig.roundName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{submissionRoundConfig.requiredDocuments.map((doc, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between rounded-md border border-dashed p-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{doc.name}</p>
|
||||||
|
{doc.description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{doc.description}</p>
|
||||||
|
)}
|
||||||
|
{doc.required && (
|
||||||
|
<Badge variant="outline" className="mt-1 text-xs">Required</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{files && files.length > 0 ? (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Suspense, use, useState } from 'react'
|
import { Suspense, use, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
@@ -46,6 +46,7 @@ interface MentorSuggestion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||||
|
const router = useRouter()
|
||||||
const [selectedMentorId, setSelectedMentorId] = useState<string | null>(null)
|
const [selectedMentorId, setSelectedMentorId] = useState<string | null>(null)
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
@@ -128,11 +129,9 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href={`/admin/projects/${projectId}`}>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Project
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Suspense, use, useState } from 'react'
|
import { Suspense, use, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -23,18 +24,35 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
import { FileViewer } from '@/components/shared/file-viewer'
|
import { FileViewer } from '@/components/shared/file-viewer'
|
||||||
import { FileUpload } from '@/components/shared/file-upload'
|
import { FileUpload } from '@/components/shared/file-upload'
|
||||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
|
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
|
||||||
import {
|
import { EvaluationEditSheet } from '@/components/admin/evaluation-edit-sheet'
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from '@/components/ui/sheet'
|
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -43,7 +61,6 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
FileText,
|
FileText,
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
|
||||||
BarChart3,
|
BarChart3,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
ThumbsDown,
|
ThumbsDown,
|
||||||
@@ -56,10 +73,13 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
ScanSearch,
|
ScanSearch,
|
||||||
Eye,
|
Eye,
|
||||||
MessageSquare,
|
Plus,
|
||||||
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { formatDate, formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
|
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||||
|
import { CountryDisplay } from '@/components/shared/country-display'
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
@@ -84,6 +104,7 @@ const evalStatusColors: Record<string, 'default' | 'secondary' | 'destructive' |
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||||
|
const router = useRouter()
|
||||||
// Fetch project + assignments + stats in a single combined query
|
// Fetch project + assignments + stats in a single combined query
|
||||||
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery(
|
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery(
|
||||||
{ id: projectId },
|
{ id: projectId },
|
||||||
@@ -128,6 +149,52 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null)
|
const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null)
|
||||||
|
|
||||||
|
// State for add member dialog
|
||||||
|
const [addMemberOpen, setAddMemberOpen] = useState(false)
|
||||||
|
const [addMemberForm, setAddMemberForm] = useState({
|
||||||
|
email: '',
|
||||||
|
name: '',
|
||||||
|
role: 'MEMBER' as 'LEAD' | 'MEMBER' | 'ADVISOR',
|
||||||
|
title: '',
|
||||||
|
sendInvite: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// State for remove member confirmation
|
||||||
|
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const addTeamMember = trpc.project.addTeamMember.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Team member added')
|
||||||
|
setAddMemberOpen(false)
|
||||||
|
setAddMemberForm({ email: '', name: '', role: 'MEMBER', title: '', sendInvite: true })
|
||||||
|
utils.project.getFullDetail.invalidate({ id: projectId })
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err.message || 'Failed to add team member')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateTeamMemberRole = trpc.project.updateTeamMemberRole.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Role updated')
|
||||||
|
utils.project.getFullDetail.invalidate({ id: projectId })
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err.message || 'Failed to update role')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeTeamMember = trpc.project.removeTeamMember.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Team member removed')
|
||||||
|
setRemovingMemberId(null)
|
||||||
|
utils.project.getFullDetail.invalidate({ id: projectId })
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err.message || 'Failed to remove team member')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <ProjectDetailSkeleton />
|
return <ProjectDetailSkeleton />
|
||||||
}
|
}
|
||||||
@@ -135,19 +202,17 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
if (!project) {
|
if (!project) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/projects">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Projects
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||||
<p className="mt-2 font-medium">Project Not Found</p>
|
<p className="mt-2 font-medium">Project Not Found</p>
|
||||||
<Button asChild className="mt-4">
|
<Button className="mt-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/projects">Back to Projects</Link>
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -159,11 +224,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/projects">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Projects
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -173,6 +236,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
project={project}
|
project={project}
|
||||||
size="lg"
|
size="lg"
|
||||||
fallback="initials"
|
fallback="initials"
|
||||||
|
clickToEnlarge
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
|
||||||
@@ -191,9 +255,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
{project.title}
|
{project.title}
|
||||||
</h1>
|
</h1>
|
||||||
<Badge variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}>
|
{(() => {
|
||||||
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
|
const prs = (project as any).projectRoundStates ?? []
|
||||||
</Badge>
|
if (!prs.length) return <Badge variant="secondary">Submitted</Badge>
|
||||||
|
if (prs.some((p: any) => p.state === 'REJECTED')) return <Badge variant="destructive">Rejected</Badge>
|
||||||
|
const latest = prs[0]
|
||||||
|
return <Badge variant={latest.state === 'PASSED' ? 'default' : 'secondary'}>{latest.round.name}</Badge>
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{project.teamName && (
|
{project.teamName && (
|
||||||
<p className="text-muted-foreground">{project.teamName}</p>
|
<p className="text-muted-foreground">{project.teamName}</p>
|
||||||
@@ -306,7 +374,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
|
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">Location</p>
|
<p className="text-sm font-medium text-muted-foreground">Location</p>
|
||||||
<p className="text-sm">{project.geographicZone || project.country}</p>
|
<p className="text-sm">{project.geographicZone}{project.geographicZone && project.country ? ', ' : ''}{project.country ? <CountryDisplay country={project.country} /> : null}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -437,53 +505,229 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
{/* Team Members Section */}
|
{/* Team Members Section */}
|
||||||
{project.teamMembers && project.teamMembers.length > 0 && (
|
<AnimatedCard index={2}>
|
||||||
<AnimatedCard index={2}>
|
<Card>
|
||||||
<Card>
|
<CardHeader>
|
||||||
<CardHeader>
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
<Users className="h-4 w-4 text-violet-500" />
|
||||||
<Users className="h-4 w-4 text-violet-500" />
|
</div>
|
||||||
</div>
|
Team Members ({project.teamMembers?.length ?? 0})
|
||||||
Team Members ({project.teamMembers.length})
|
</CardTitle>
|
||||||
</CardTitle>
|
<Button variant="outline" size="sm" onClick={() => setAddMemberOpen(true)}>
|
||||||
</div>
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
</CardHeader>
|
Add Member
|
||||||
<CardContent>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{project.teamMembers && project.teamMembers.length > 0 ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null } }) => (
|
{project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null; nationality?: string | null; country?: string | null; institution?: string | null } }) => {
|
||||||
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
|
const isLastLead =
|
||||||
{member.role === 'LEAD' ? (
|
member.role === 'LEAD' &&
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
project.teamMembers.filter((m: { role: string }) => m.role === 'LEAD').length <= 1
|
||||||
<Crown className="h-5 w-5 text-yellow-500" />
|
const details = [
|
||||||
</div>
|
member.user.nationality ? `${getCountryFlag(member.user.nationality)} ${getCountryName(member.user.nationality)}` : null,
|
||||||
) : (
|
member.user.institution,
|
||||||
<UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" />
|
member.user.country && member.user.country !== member.user.nationality ? `${getCountryFlag(member.user.country)} ${getCountryName(member.user.country)}` : null,
|
||||||
)}
|
].filter(Boolean)
|
||||||
<div className="flex-1 min-w-0">
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
|
||||||
<p className="font-medium text-sm truncate">
|
{member.role === 'LEAD' ? (
|
||||||
{member.user.name || 'Unnamed'}
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||||
</p>
|
<Crown className="h-5 w-5 text-yellow-500" />
|
||||||
<Badge variant="outline" className="text-xs">
|
</div>
|
||||||
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
) : (
|
||||||
</Badge>
|
<UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" />
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
{member.user.email}
|
|
||||||
</p>
|
|
||||||
{member.title && (
|
|
||||||
<p className="text-xs text-muted-foreground">{member.title}</p>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href={`/admin/members/${member.user.id}`} className="font-medium text-sm truncate hover:underline text-primary">
|
||||||
|
{member.user.name || 'Unnamed'}
|
||||||
|
</Link>
|
||||||
|
<Select
|
||||||
|
value={member.role}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateTeamMemberRole.mutate({
|
||||||
|
projectId: project.id,
|
||||||
|
userId: member.user.id,
|
||||||
|
role: value as 'LEAD' | 'MEMBER' | 'ADVISOR',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-auto text-xs px-2 py-0 border-dashed gap-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="LEAD">Lead</SelectItem>
|
||||||
|
<SelectItem value="MEMBER">Member</SelectItem>
|
||||||
|
<SelectItem value="ADVISOR">Advisor</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{member.user.email}
|
||||||
|
</p>
|
||||||
|
{member.title && (
|
||||||
|
<p className="text-xs text-muted-foreground">{member.title}</p>
|
||||||
|
)}
|
||||||
|
{details.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{details.join(' · ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
disabled={isLastLead}
|
||||||
|
onClick={() => setRemovingMemberId(member.user.id)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{isLastLead && (
|
||||||
|
<TooltipContent>
|
||||||
|
Cannot remove the last team lead
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
) : (
|
||||||
</Card>
|
<p className="text-sm text-muted-foreground">No team members yet.</p>
|
||||||
</AnimatedCard>
|
)}
|
||||||
)}
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
|
||||||
|
{/* Add Member Dialog */}
|
||||||
|
<Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Team Member</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="member-email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="member-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="member@example.com"
|
||||||
|
value={addMemberForm.email}
|
||||||
|
onChange={(e) => setAddMemberForm((f) => ({ ...f, email: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="member-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="member-name"
|
||||||
|
placeholder="Full name"
|
||||||
|
value={addMemberForm.name}
|
||||||
|
onChange={(e) => setAddMemberForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="member-role">Role</Label>
|
||||||
|
<Select
|
||||||
|
value={addMemberForm.role}
|
||||||
|
onValueChange={(v) => setAddMemberForm((f) => ({ ...f, role: v as 'LEAD' | 'MEMBER' | 'ADVISOR' }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="member-role">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="LEAD">Lead</SelectItem>
|
||||||
|
<SelectItem value="MEMBER">Member</SelectItem>
|
||||||
|
<SelectItem value="ADVISOR">Advisor</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="member-title">Title (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="member-title"
|
||||||
|
placeholder="e.g. CEO, Co-founder"
|
||||||
|
value={addMemberForm.title}
|
||||||
|
onChange={(e) => setAddMemberForm((f) => ({ ...f, title: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="member-invite"
|
||||||
|
checked={addMemberForm.sendInvite}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setAddMemberForm((f) => ({ ...f, sendInvite: checked === true }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="member-invite" className="font-normal cursor-pointer">
|
||||||
|
Send invite email
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setAddMemberOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
addTeamMember.mutate({
|
||||||
|
projectId,
|
||||||
|
email: addMemberForm.email,
|
||||||
|
name: addMemberForm.name,
|
||||||
|
role: addMemberForm.role,
|
||||||
|
title: addMemberForm.title || undefined,
|
||||||
|
sendInvite: addMemberForm.sendInvite,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={addTeamMember.isPending || !addMemberForm.email || !addMemberForm.name}
|
||||||
|
>
|
||||||
|
{addTeamMember.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Add Member
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Remove Member Confirmation Dialog */}
|
||||||
|
<Dialog open={!!removingMemberId} onOpenChange={(open) => { if (!open) setRemovingMemberId(null) }}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Remove Team Member</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Are you sure you want to remove this team member? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRemovingMemberId(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (removingMemberId) {
|
||||||
|
removeTeamMember.mutate({ projectId, userId: removingMemberId })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={removeTeamMember.isPending}
|
||||||
|
>
|
||||||
|
{removeTeamMember.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Mentor Assignment Section */}
|
{/* Mentor Assignment Section */}
|
||||||
{project.wantsMentorship && (
|
{project.wantsMentorship && (
|
||||||
@@ -571,33 +815,48 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* All Files list */}
|
{/* All Files list — grouped by round */}
|
||||||
{files && files.length > 0 && (
|
{files && files.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<FileViewer
|
<FileViewer
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
files={files.map((f) => ({
|
groupedFiles={(() => {
|
||||||
id: f.id,
|
const groups = new Map<string, { roundId: string | null; roundName: string; sortOrder: number; files: typeof mappedFiles }>()
|
||||||
fileName: f.fileName,
|
const mappedFiles = files.map((f) => ({
|
||||||
fileType: f.fileType,
|
id: f.id,
|
||||||
mimeType: f.mimeType,
|
fileName: f.fileName,
|
||||||
size: f.size,
|
fileType: f.fileType as 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' | 'BUSINESS_PLAN' | 'VIDEO_PITCH' | 'SUPPORTING_DOC',
|
||||||
bucket: f.bucket,
|
mimeType: f.mimeType,
|
||||||
objectKey: f.objectKey,
|
size: f.size,
|
||||||
pageCount: f.pageCount,
|
bucket: f.bucket,
|
||||||
textPreview: f.textPreview,
|
objectKey: f.objectKey,
|
||||||
detectedLang: f.detectedLang,
|
pageCount: f.pageCount,
|
||||||
langConfidence: f.langConfidence,
|
textPreview: f.textPreview,
|
||||||
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
|
detectedLang: f.detectedLang,
|
||||||
requirementId: f.requirementId,
|
langConfidence: f.langConfidence,
|
||||||
requirement: f.requirement ? {
|
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
|
||||||
id: f.requirement.id,
|
requirementId: f.requirementId,
|
||||||
name: f.requirement.name,
|
requirement: f.requirement ? {
|
||||||
description: f.requirement.description,
|
id: f.requirement.id,
|
||||||
isRequired: f.requirement.isRequired,
|
name: f.requirement.name,
|
||||||
} : null,
|
description: f.requirement.description,
|
||||||
}))}
|
isRequired: f.requirement.isRequired,
|
||||||
|
} : null,
|
||||||
|
}))
|
||||||
|
for (const f of files) {
|
||||||
|
const roundId = f.requirement?.roundId ?? null
|
||||||
|
const roundName = f.requirement?.round?.name ?? 'General'
|
||||||
|
const sortOrder = f.requirement?.round?.sortOrder ?? -1
|
||||||
|
const key = roundId ?? '_general'
|
||||||
|
if (!groups.has(key)) {
|
||||||
|
groups.set(key, { roundId, roundName, sortOrder, files: [] })
|
||||||
|
}
|
||||||
|
const mapped = mappedFiles.find((m) => m.id === f.id)!
|
||||||
|
groups.get(key)!.files.push(mapped)
|
||||||
|
}
|
||||||
|
return Array.from(groups.values())
|
||||||
|
})()}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -742,10 +1001,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Evaluation Detail Sheet */}
|
{/* Evaluation Detail Sheet */}
|
||||||
<EvaluationDetailSheet
|
<EvaluationEditSheet
|
||||||
assignment={selectedEvalAssignment}
|
assignment={selectedEvalAssignment}
|
||||||
open={!!selectedEvalAssignment}
|
open={!!selectedEvalAssignment}
|
||||||
onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }}
|
onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }}
|
||||||
|
onSaved={() => utils.project.getFullDetail.invalidate({ id: projectId })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* AI Evaluation Summary */}
|
{/* AI Evaluation Summary */}
|
||||||
@@ -830,173 +1090,6 @@ function AnalyzeDocumentsButton({ projectId, onComplete }: { projectId: string;
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EvaluationDetailSheet({
|
|
||||||
assignment,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
}: {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
assignment: any
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
}) {
|
|
||||||
if (!assignment?.evaluation) return null
|
|
||||||
|
|
||||||
const ev = assignment.evaluation
|
|
||||||
const criterionScores = (ev.criterionScoresJson || {}) as Record<string, number | boolean | string>
|
|
||||||
const hasScores = Object.keys(criterionScores).length > 0
|
|
||||||
|
|
||||||
// Try to get the evaluation form for labels
|
|
||||||
const roundId = assignment.roundId as string | undefined
|
|
||||||
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
|
||||||
{ roundId: roundId ?? '' },
|
|
||||||
{ enabled: !!roundId }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Build label lookup from form criteria
|
|
||||||
const criteriaMap = new Map<string, { label: string; type: string; trueLabel?: string; falseLabel?: string }>()
|
|
||||||
if (activeForm?.criteriaJson) {
|
|
||||||
for (const c of activeForm.criteriaJson as Array<{ id: string; label: string; type?: string; trueLabel?: string; falseLabel?: string }>) {
|
|
||||||
criteriaMap.set(c.id, {
|
|
||||||
label: c.label,
|
|
||||||
type: c.type || 'numeric',
|
|
||||||
trueLabel: c.trueLabel,
|
|
||||||
falseLabel: c.falseLabel,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
||||||
<SheetContent className="sm:max-w-lg overflow-y-auto">
|
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle className="flex items-center gap-2">
|
|
||||||
<UserAvatar user={assignment.user} avatarUrl={assignment.user.avatarUrl} size="sm" />
|
|
||||||
{assignment.user.name || assignment.user.email}
|
|
||||||
</SheetTitle>
|
|
||||||
<SheetDescription>
|
|
||||||
{ev.submittedAt
|
|
||||||
? `Submitted ${formatDate(ev.submittedAt)}`
|
|
||||||
: 'Evaluation details'}
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<div className="space-y-6 mt-6">
|
|
||||||
{/* Global stats */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="p-3 rounded-lg bg-muted">
|
|
||||||
<p className="text-xs text-muted-foreground">Score</p>
|
|
||||||
<p className="text-2xl font-bold">
|
|
||||||
{ev.globalScore !== null ? `${ev.globalScore}/10` : '-'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 rounded-lg bg-muted">
|
|
||||||
<p className="text-xs text-muted-foreground">Decision</p>
|
|
||||||
<div className="mt-1">
|
|
||||||
{ev.binaryDecision !== null ? (
|
|
||||||
ev.binaryDecision ? (
|
|
||||||
<div className="flex items-center gap-1.5 text-emerald-600">
|
|
||||||
<ThumbsUp className="h-5 w-5" />
|
|
||||||
<span className="font-semibold">Yes</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1.5 text-red-600">
|
|
||||||
<ThumbsDown className="h-5 w-5" />
|
|
||||||
<span className="font-semibold">No</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span className="text-2xl font-bold">-</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Criterion Scores */}
|
|
||||||
{hasScores && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
|
||||||
<BarChart3 className="h-4 w-4" />
|
|
||||||
Criterion Scores
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2.5">
|
|
||||||
{Object.entries(criterionScores).map(([key, value]) => {
|
|
||||||
const meta = criteriaMap.get(key)
|
|
||||||
const label = meta?.label || key
|
|
||||||
const type = meta?.type || (typeof value === 'boolean' ? 'boolean' : typeof value === 'string' ? 'text' : 'numeric')
|
|
||||||
|
|
||||||
if (type === 'section_header') return null
|
|
||||||
|
|
||||||
if (type === 'boolean' || type === 'advance') {
|
|
||||||
return (
|
|
||||||
<div key={key} className="flex items-center justify-between p-2.5 rounded-lg border">
|
|
||||||
<span className="text-sm">{label}</span>
|
|
||||||
{value === true ? (
|
|
||||||
<Badge className="bg-emerald-100 text-emerald-700 border-emerald-200" variant="outline">
|
|
||||||
<ThumbsUp className="mr-1 h-3 w-3" />
|
|
||||||
{meta?.trueLabel || 'Yes'}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge className="bg-red-100 text-red-700 border-red-200" variant="outline">
|
|
||||||
<ThumbsDown className="mr-1 h-3 w-3" />
|
|
||||||
{meta?.falseLabel || 'No'}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'text') {
|
|
||||||
return (
|
|
||||||
<div key={key} className="space-y-1">
|
|
||||||
<span className="text-sm font-medium">{label}</span>
|
|
||||||
<div className="text-sm text-muted-foreground p-2.5 rounded-lg border bg-muted/50 whitespace-pre-wrap">
|
|
||||||
{typeof value === 'string' ? value : String(value)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Numeric
|
|
||||||
return (
|
|
||||||
<div key={key} className="flex items-center gap-3 p-2.5 rounded-lg border">
|
|
||||||
<span className="text-sm flex-1 truncate">{label}</span>
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
<div className="w-20 h-2 rounded-full bg-muted overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-primary"
|
|
||||||
style={{ width: `${(Number(value) / 10) * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-bold tabular-nums w-8 text-right">
|
|
||||||
{typeof value === 'number' ? value : '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Feedback Text */}
|
|
||||||
{ev.feedbackText && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
|
|
||||||
<MessageSquare className="h-4 w-4" />
|
|
||||||
Feedback
|
|
||||||
</h4>
|
|
||||||
<div className="text-sm text-muted-foreground p-3 rounded-lg border bg-muted/30 whitespace-pre-wrap leading-relaxed">
|
|
||||||
{ev.feedbackText}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectDetailPage({ params }: PageProps) {
|
export default function ProjectDetailPage({ params }: PageProps) {
|
||||||
const { id } = use(params)
|
const { id } = use(params)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
@@ -62,6 +63,7 @@ type UploadState = {
|
|||||||
type UploadMap = Record<string, UploadState>
|
type UploadMap = Record<string, UploadState>
|
||||||
|
|
||||||
export default function BulkUploadPage() {
|
export default function BulkUploadPage() {
|
||||||
|
const router = useRouter()
|
||||||
const [roundId, setRoundId] = useState('')
|
const [roundId, setRoundId] = useState('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||||
@@ -146,7 +148,7 @@ export default function BulkUploadPage() {
|
|||||||
const handleViewFile = useCallback(
|
const handleViewFile = useCallback(
|
||||||
async (bucket: string, objectKey: string) => {
|
async (bucket: string, objectKey: string) => {
|
||||||
try {
|
try {
|
||||||
const { url } = await utils.file.getDownloadUrl.fetch({ bucket, objectKey })
|
const { url } = await utils.file.getDownloadUrl.fetch({ bucket, objectKey, purpose: 'open' as const })
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to open file. It may have been deleted from storage.')
|
toast.error('Failed to open file. It may have been deleted from storage.')
|
||||||
@@ -296,10 +298,8 @@ export default function BulkUploadPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="icon" asChild>
|
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||||
<Link href="/admin/projects">
|
<ArrowLeft className="h-4 w-4" />
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Bulk Document Upload</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Bulk Document Upload</h1>
|
||||||
|
|||||||
@@ -59,11 +59,9 @@ function ImportPageContent() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/projects">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Projects
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ function NewProjectPageContent() {
|
|||||||
|
|
||||||
createProject.mutate({
|
createProject.mutate({
|
||||||
programId: selectedProgramId,
|
programId: selectedProgramId,
|
||||||
|
roundId: selectedRoundId || undefined,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
teamName: teamName.trim() || undefined,
|
teamName: teamName.trim() || undefined,
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
@@ -245,11 +246,9 @@ function NewProjectPageContent() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/projects">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Projects
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from 'next/link'
|
|||||||
import { useSearchParams, usePathname } from 'next/navigation'
|
import { useSearchParams, usePathname } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -72,6 +73,7 @@ import {
|
|||||||
ArrowRightCircle,
|
ArrowRightCircle,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
LayoutList,
|
LayoutList,
|
||||||
|
Bell,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -90,8 +92,10 @@ import {
|
|||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { truncate } from '@/lib/utils'
|
import { truncate } from '@/lib/utils'
|
||||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||||
import { StatusBadge } from '@/components/shared/status-badge'
|
import { BulkNotificationDialog } from '@/components/admin/projects/bulk-notification-dialog'
|
||||||
|
|
||||||
import { Pagination } from '@/components/shared/pagination'
|
import { Pagination } from '@/components/shared/pagination'
|
||||||
|
import { SortableHeader } from '@/components/shared/sortable-header'
|
||||||
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
|
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
|
||||||
import { CountryFlagImg } from '@/components/ui/country-select'
|
import { CountryFlagImg } from '@/components/ui/country-select'
|
||||||
import {
|
import {
|
||||||
@@ -113,6 +117,25 @@ const statusColors: Record<
|
|||||||
WINNER: 'success',
|
WINNER: 'success',
|
||||||
REJECTED: 'destructive',
|
REJECTED: 'destructive',
|
||||||
WITHDRAWN: 'secondary',
|
WITHDRAWN: 'secondary',
|
||||||
|
// Round-state-based statuses
|
||||||
|
PENDING: 'secondary',
|
||||||
|
IN_PROGRESS: 'default',
|
||||||
|
COMPLETED: 'default',
|
||||||
|
PASSED: 'success',
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectRoundStateInfo = {
|
||||||
|
state: string
|
||||||
|
round: { name: string; sortOrder: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveProjectStatus(prs: ProjectRoundStateInfo[]): { label: string; variant: 'default' | 'success' | 'secondary' | 'destructive' | 'warning' } {
|
||||||
|
if (!prs.length) return { label: 'Submitted', variant: 'secondary' }
|
||||||
|
if (prs.some((p) => p.state === 'REJECTED')) return { label: 'Rejected', variant: 'destructive' }
|
||||||
|
// prs is already sorted by sortOrder desc — first item is the latest round
|
||||||
|
const latest = prs[0]
|
||||||
|
if (latest.state === 'PASSED') return { label: latest.round.name, variant: 'success' }
|
||||||
|
return { label: latest.round.name, variant: 'default' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFiltersFromParams(
|
function parseFiltersFromParams(
|
||||||
@@ -123,6 +146,9 @@ function parseFiltersFromParams(
|
|||||||
statuses: searchParams.get('status')
|
statuses: searchParams.get('status')
|
||||||
? searchParams.get('status')!.split(',')
|
? searchParams.get('status')!.split(',')
|
||||||
: [],
|
: [],
|
||||||
|
roundStates: searchParams.get('roundState')
|
||||||
|
? searchParams.get('roundState')!.split(',')
|
||||||
|
: [],
|
||||||
roundId: searchParams.get('round') || '',
|
roundId: searchParams.get('round') || '',
|
||||||
competitionCategory: searchParams.get('category') || '',
|
competitionCategory: searchParams.get('category') || '',
|
||||||
oceanIssue: searchParams.get('issue') || '',
|
oceanIssue: searchParams.get('issue') || '',
|
||||||
@@ -157,6 +183,8 @@ function filtersToParams(
|
|||||||
if (filters.search) params.set('q', filters.search)
|
if (filters.search) params.set('q', filters.search)
|
||||||
if (filters.statuses.length > 0)
|
if (filters.statuses.length > 0)
|
||||||
params.set('status', filters.statuses.join(','))
|
params.set('status', filters.statuses.join(','))
|
||||||
|
if (filters.roundStates.length > 0)
|
||||||
|
params.set('roundState', filters.roundStates.join(','))
|
||||||
if (filters.roundId) params.set('round', filters.roundId)
|
if (filters.roundId) params.set('round', filters.roundId)
|
||||||
if (filters.competitionCategory)
|
if (filters.competitionCategory)
|
||||||
params.set('category', filters.competitionCategory)
|
params.set('category', filters.competitionCategory)
|
||||||
@@ -182,6 +210,7 @@ export default function ProjectsPage() {
|
|||||||
const [filters, setFilters] = useState<ProjectFilters>({
|
const [filters, setFilters] = useState<ProjectFilters>({
|
||||||
search: parsed.search,
|
search: parsed.search,
|
||||||
statuses: parsed.statuses,
|
statuses: parsed.statuses,
|
||||||
|
roundStates: parsed.roundStates,
|
||||||
roundId: parsed.roundId,
|
roundId: parsed.roundId,
|
||||||
competitionCategory: parsed.competitionCategory,
|
competitionCategory: parsed.competitionCategory,
|
||||||
oceanIssue: parsed.oceanIssue,
|
oceanIssue: parsed.oceanIssue,
|
||||||
@@ -194,6 +223,8 @@ export default function ProjectsPage() {
|
|||||||
const [perPage, setPerPage] = useState(parsed.perPage || 20)
|
const [perPage, setPerPage] = useState(parsed.perPage || 20)
|
||||||
const [searchInput, setSearchInput] = useState(parsed.search)
|
const [searchInput, setSearchInput] = useState(parsed.search)
|
||||||
const [viewMode, setViewMode] = useState<'table' | 'card'>('table')
|
const [viewMode, setViewMode] = useState<'table' | 'card'>('table')
|
||||||
|
const [sortBy, setSortBy] = useState<string | undefined>(undefined)
|
||||||
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
|
||||||
|
|
||||||
// Fetch display settings
|
// Fetch display settings
|
||||||
const { data: displaySettings } = trpc.settings.getMultiple.useQuery({
|
const { data: displaySettings } = trpc.settings.getMultiple.useQuery({
|
||||||
@@ -239,6 +270,16 @@ export default function ProjectsPage() {
|
|||||||
setPage(1)
|
setPage(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSort = (column: string) => {
|
||||||
|
if (sortBy === column) {
|
||||||
|
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||||
|
} else {
|
||||||
|
setSortBy(column)
|
||||||
|
setSortDir('asc')
|
||||||
|
}
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
// Build tRPC query input
|
// Build tRPC query input
|
||||||
const queryInput = {
|
const queryInput = {
|
||||||
search: filters.search || undefined,
|
search: filters.search || undefined,
|
||||||
@@ -275,8 +316,16 @@ export default function ProjectsPage() {
|
|||||||
wantsMentorship: filters.wantsMentorship,
|
wantsMentorship: filters.wantsMentorship,
|
||||||
hasFiles: filters.hasFiles,
|
hasFiles: filters.hasFiles,
|
||||||
hasAssignments: filters.hasAssignments,
|
hasAssignments: filters.hasAssignments,
|
||||||
|
roundStates:
|
||||||
|
filters.roundStates.length > 0
|
||||||
|
? (filters.roundStates as Array<
|
||||||
|
'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'PASSED' | 'REJECTED' | 'WITHDRAWN'
|
||||||
|
>)
|
||||||
|
: undefined,
|
||||||
page,
|
page,
|
||||||
perPage,
|
perPage,
|
||||||
|
sortBy: sortBy as 'title' | 'category' | 'program' | 'assignments' | 'status' | 'createdAt' | undefined,
|
||||||
|
sortDir: sortBy ? sortDir : undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
@@ -290,14 +339,15 @@ export default function ProjectsPage() {
|
|||||||
const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null)
|
const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null)
|
||||||
const [assignRoundId, setAssignRoundId] = useState('')
|
const [assignRoundId, setAssignRoundId] = useState('')
|
||||||
|
|
||||||
|
const [bulkNotifyOpen, setBulkNotifyOpen] = useState(false)
|
||||||
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
|
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
|
||||||
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
|
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
|
||||||
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
|
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
|
||||||
const [selectedProgramForTagging, setSelectedProgramForTagging] = useState<string>('')
|
const [selectedProgramForTagging, setSelectedProgramForTagging] = useState<string>('')
|
||||||
const [activeTaggingJobId, setActiveTaggingJobId] = useState<string | null>(null)
|
const [activeTaggingJobId, setActiveTaggingJobId] = useState<string | null>(null)
|
||||||
|
|
||||||
// Fetch programs and rounds for the AI tagging dialog
|
// Fetch programs and rounds for the AI tagging dialog + assign-to-round
|
||||||
const { data: programs } = trpc.program.list.useQuery()
|
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
|
|
||||||
// Start tagging job mutation
|
// Start tagging job mutation
|
||||||
const startTaggingJob = trpc.tag.startTaggingJob.useMutation({
|
const startTaggingJob = trpc.tag.startTaggingJob.useMutation({
|
||||||
@@ -619,6 +669,13 @@ export default function ProjectsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setBulkNotifyOpen(true)}
|
||||||
|
>
|
||||||
|
<Bell className="mr-2 h-4 w-4" />
|
||||||
|
Send Notifications
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setAiTagDialogOpen(true)}
|
onClick={() => setAiTagDialogOpen(true)}
|
||||||
@@ -708,23 +765,51 @@ export default function ProjectsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Stats Summary + View Toggle */}
|
{/* Stats Summary + View Toggle */}
|
||||||
{data && data.projects.length > 0 && (
|
{data && (Object.keys(data.statusCounts ?? {}).length > 0 || data.projects.length > 0) && (
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
{Object.entries(data.statusCounts ?? {})
|
{Object.entries(data.statusCounts ?? {})
|
||||||
.sort(([a], [b]) => {
|
.sort(([a], [b]) => {
|
||||||
const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN']
|
const order = ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN']
|
||||||
return order.indexOf(a) - order.indexOf(b)
|
return order.indexOf(a) - order.indexOf(b)
|
||||||
})
|
})
|
||||||
.map(([status, count]) => (
|
.map(([status, count]) => {
|
||||||
<Badge
|
const isActive = filters.roundStates.includes(status)
|
||||||
key={status}
|
return (
|
||||||
variant={statusColors[status] || 'secondary'}
|
<button
|
||||||
className="text-xs font-normal"
|
key={status}
|
||||||
>
|
type="button"
|
||||||
{count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')}
|
onClick={() => {
|
||||||
</Badge>
|
const next = isActive
|
||||||
))}
|
? filters.roundStates.filter((s) => s !== status)
|
||||||
|
: [...filters.roundStates, status]
|
||||||
|
handleFiltersChange({ ...filters, roundStates: next })
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant={statusColors[status] || 'secondary'}
|
||||||
|
className={cn(
|
||||||
|
'text-xs font-normal cursor-pointer transition-all',
|
||||||
|
isActive && 'ring-2 ring-offset-1 ring-primary',
|
||||||
|
!isActive && filters.roundStates.length > 0 && 'opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{filters.roundStates.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleFiltersChange({ ...filters, roundStates: [] })}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors ml-1"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{data.total > data.projects.length && (
|
{data.total > data.projects.length && (
|
||||||
<span className="text-xs text-muted-foreground ml-1">
|
<span className="text-xs text-muted-foreground ml-1">
|
||||||
(page {data.page} of {data.totalPages})
|
(page {data.page} of {data.totalPages})
|
||||||
@@ -847,6 +932,17 @@ export default function ProjectsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : data ? (
|
) : data ? (
|
||||||
<>
|
<>
|
||||||
|
{/* Top Pagination */}
|
||||||
|
{data.totalPages > 1 && (
|
||||||
|
<Pagination
|
||||||
|
page={data.page}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
total={data.total}
|
||||||
|
perPage={perPage}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Table View */}
|
{/* Table View */}
|
||||||
{viewMode === 'table' ? (
|
{viewMode === 'table' ? (
|
||||||
<>
|
<>
|
||||||
@@ -862,18 +958,18 @@ export default function ProjectsPage() {
|
|||||||
aria-label="Select all projects"
|
aria-label="Select all projects"
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="min-w-[280px]">Project</TableHead>
|
<SortableHeader label="Project" column="title" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} className="min-w-[280px]" />
|
||||||
<TableHead>Category</TableHead>
|
<SortableHeader label="Category" column="category" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
|
||||||
<TableHead>Program</TableHead>
|
<SortableHeader label="Program" column="program" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
|
||||||
<TableHead>Tags</TableHead>
|
<TableHead>Tags</TableHead>
|
||||||
<TableHead>Assignments</TableHead>
|
<SortableHeader label="Assignments" column="assignments" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
|
||||||
<TableHead>Status</TableHead>
|
<SortableHeader label="Status" column="status" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.projects.map((project) => {
|
{data.projects.map((project) => {
|
||||||
const isEliminated = project.status === 'REJECTED'
|
const isEliminated = (project.projectRoundStates ?? []).some((p: ProjectRoundStateInfo) => p.state === 'REJECTED')
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={project.id}
|
key={project.id}
|
||||||
@@ -894,6 +990,7 @@ export default function ProjectsPage() {
|
|||||||
>
|
>
|
||||||
<ProjectLogo
|
<ProjectLogo
|
||||||
project={project}
|
project={project}
|
||||||
|
logoUrl={project.logoUrl}
|
||||||
size="sm"
|
size="sm"
|
||||||
fallback="initials"
|
fallback="initials"
|
||||||
/>
|
/>
|
||||||
@@ -972,7 +1069,10 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StatusBadge status={project.status ?? 'SUBMITTED'} />
|
{(() => {
|
||||||
|
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
|
||||||
|
return <Badge variant={derived.variant}>{derived.label}</Badge>
|
||||||
|
})()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="relative z-10 text-right">
|
<TableCell className="relative z-10 text-right">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -1042,13 +1142,16 @@ export default function ProjectsPage() {
|
|||||||
<Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
|
<Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start gap-3 pl-8">
|
<div className="flex items-start gap-3 pl-8">
|
||||||
<ProjectLogo project={project} size="md" fallback="initials" />
|
<ProjectLogo project={project} logoUrl={project.logoUrl} size="md" fallback="initials" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
|
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
|
||||||
{project.title}
|
{project.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<StatusBadge status={project.status ?? 'SUBMITTED'} className="shrink-0" />
|
{(() => {
|
||||||
|
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
|
||||||
|
return <Badge variant={derived.variant} className="shrink-0">{derived.label}</Badge>
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>{project.teamName}</CardDescription>
|
<CardDescription>{project.teamName}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
@@ -1096,7 +1199,7 @@ export default function ProjectsPage() {
|
|||||||
/* Card View */
|
/* Card View */
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{data.projects.map((project) => {
|
{data.projects.map((project) => {
|
||||||
const isEliminated = project.status === 'REJECTED'
|
const isEliminated = (project.projectRoundStates ?? []).some((p: ProjectRoundStateInfo) => p.state === 'REJECTED')
|
||||||
return (
|
return (
|
||||||
<div key={project.id} className="relative">
|
<div key={project.id} className="relative">
|
||||||
<div className="absolute left-3 top-3 z-10">
|
<div className="absolute left-3 top-3 z-10">
|
||||||
@@ -1110,7 +1213,7 @@ export default function ProjectsPage() {
|
|||||||
<Card className={`transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md h-full ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}>
|
<Card className={`transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md h-full ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start gap-3 pl-7">
|
<div className="flex items-start gap-3 pl-7">
|
||||||
<ProjectLogo project={project} size="lg" fallback="initials" />
|
<ProjectLogo project={project} logoUrl={project.logoUrl} size="lg" fallback="initials" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
|
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
|
||||||
@@ -1177,7 +1280,10 @@ export default function ProjectsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 pt-0">
|
<CardContent className="space-y-3 pt-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<StatusBadge status={project.status ?? 'SUBMITTED'} />
|
{(() => {
|
||||||
|
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
|
||||||
|
return <Badge variant={derived.variant}>{derived.label}</Badge>
|
||||||
|
})()}
|
||||||
{project.competitionCategory && (
|
{project.competitionCategory && (
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||||
@@ -1846,6 +1952,8 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<BulkNotificationDialog open={bulkNotifyOpen} onOpenChange={setBulkNotifyOpen} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const ISSUE_LABELS: Record<string, string> = {
|
|||||||
export interface ProjectFilters {
|
export interface ProjectFilters {
|
||||||
search: string
|
search: string
|
||||||
statuses: string[]
|
statuses: string[]
|
||||||
|
roundStates: string[]
|
||||||
roundId: string
|
roundId: string
|
||||||
competitionCategory: string
|
competitionCategory: string
|
||||||
oceanIssue: string
|
oceanIssue: string
|
||||||
@@ -94,6 +95,7 @@ export function ProjectFiltersBar({
|
|||||||
|
|
||||||
const activeFilterCount = [
|
const activeFilterCount = [
|
||||||
filters.statuses.length > 0,
|
filters.statuses.length > 0,
|
||||||
|
filters.roundStates.length > 0,
|
||||||
filters.roundId !== '',
|
filters.roundId !== '',
|
||||||
filters.competitionCategory !== '',
|
filters.competitionCategory !== '',
|
||||||
filters.oceanIssue !== '',
|
filters.oceanIssue !== '',
|
||||||
@@ -114,6 +116,7 @@ export function ProjectFiltersBar({
|
|||||||
onChange({
|
onChange({
|
||||||
search: filters.search,
|
search: filters.search,
|
||||||
statuses: [],
|
statuses: [],
|
||||||
|
roundStates: [],
|
||||||
roundId: '',
|
roundId: '',
|
||||||
competitionCategory: '',
|
competitionCategory: '',
|
||||||
oceanIssue: '',
|
oceanIssue: '',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
||||||
import { useParams, useSearchParams } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
@@ -13,7 +13,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Switch } from '@/components/ui/switch'
|
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -77,6 +76,9 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
ListChecks,
|
||||||
|
FileText,
|
||||||
|
Languages,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -116,7 +118,11 @@ import { AIRecommendationsDisplay } from '@/components/admin/round/ai-recommenda
|
|||||||
import { EvaluationCriteriaEditor } from '@/components/admin/round/evaluation-criteria-editor'
|
import { EvaluationCriteriaEditor } from '@/components/admin/round/evaluation-criteria-editor'
|
||||||
import { COIReviewSection } from '@/components/admin/assignment/coi-review-section'
|
import { COIReviewSection } from '@/components/admin/assignment/coi-review-section'
|
||||||
import { ConfigSectionHeader } from '@/components/admin/rounds/config/config-section-header'
|
import { ConfigSectionHeader } from '@/components/admin/rounds/config/config-section-header'
|
||||||
|
import { NotifyAdvancedButton } from '@/components/admin/round/notify-advanced-button'
|
||||||
|
import { NotifyRejectedButton } from '@/components/admin/round/notify-rejected-button'
|
||||||
|
import { BulkInviteButton } from '@/components/admin/round/bulk-invite-button'
|
||||||
import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card'
|
import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card'
|
||||||
|
import { FinalizationTab } from '@/components/admin/round/finalization-tab'
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -146,8 +152,7 @@ const stateColors: Record<string, string> = Object.fromEntries(
|
|||||||
export default function RoundDetailPage() {
|
export default function RoundDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const roundId = params.roundId as string
|
const roundId = params.roundId as string
|
||||||
const searchParams = useSearchParams()
|
const router = useRouter()
|
||||||
const backUrl = searchParams.get('from')
|
|
||||||
|
|
||||||
const [config, setConfig] = useState<Record<string, unknown>>({})
|
const [config, setConfig] = useState<Record<string, unknown>>({})
|
||||||
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||||
@@ -185,6 +190,7 @@ export default function RoundDetailPage() {
|
|||||||
const [nameValue, setNameValue] = useState('')
|
const [nameValue, setNameValue] = useState('')
|
||||||
const nameInputRef = useRef<HTMLInputElement>(null)
|
const nameInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [statusConfirmAction, setStatusConfirmAction] = useState<'activate' | 'close' | 'reopen' | 'archive' | null>(null)
|
const [statusConfirmAction, setStatusConfirmAction] = useState<'activate' | 'close' | 'reopen' | 'archive' | null>(null)
|
||||||
|
const [statusDropdownOpen, setStatusDropdownOpen] = useState(false)
|
||||||
const [coverageOpen, setCoverageOpen] = useState(false)
|
const [coverageOpen, setCoverageOpen] = useState(false)
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
@@ -264,6 +270,20 @@ export default function RoundDetailPage() {
|
|||||||
}
|
}
|
||||||
}, [juryWorkload])
|
}, [juryWorkload])
|
||||||
|
|
||||||
|
// Auto-select finalization tab when round is closed and not yet finalized
|
||||||
|
const finalizationAutoSelected = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
round &&
|
||||||
|
!finalizationAutoSelected.current &&
|
||||||
|
round.status === 'ROUND_CLOSED' &&
|
||||||
|
!round.finalizedAt
|
||||||
|
) {
|
||||||
|
finalizationAutoSelected.current = true
|
||||||
|
setActiveTab('finalization')
|
||||||
|
}
|
||||||
|
}, [round])
|
||||||
|
|
||||||
// ── Mutations ──────────────────────────────────────────────────────────
|
// ── Mutations ──────────────────────────────────────────────────────────
|
||||||
const updateMutation = trpc.round.update.useMutation({
|
const updateMutation = trpc.round.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -290,12 +310,12 @@ export default function RoundDetailPage() {
|
|||||||
const closeMutation = trpc.roundEngine.close.useMutation({
|
const closeMutation = trpc.roundEngine.close.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.round.getById.invalidate({ id: roundId })
|
utils.round.getById.invalidate({ id: roundId })
|
||||||
toast.success('Round closed')
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
if (closeAndAdvance) {
|
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
|
||||||
setCloseAndAdvance(false)
|
toast.success('Round closed — use the Finalization tab to review and advance projects')
|
||||||
// Small delay to let cache invalidation complete before opening dialog
|
setCloseAndAdvance(false)
|
||||||
setTimeout(() => setAdvanceDialogOpen(true), 300)
|
// Auto-switch to finalization tab
|
||||||
}
|
setActiveTab('finalization')
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setCloseAndAdvance(false)
|
setCloseAndAdvance(false)
|
||||||
@@ -307,6 +327,7 @@ export default function RoundDetailPage() {
|
|||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
utils.round.getById.invalidate({ id: roundId })
|
utils.round.getById.invalidate({ id: roundId })
|
||||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
|
||||||
const msg = data.pausedRounds?.length
|
const msg = data.pausedRounds?.length
|
||||||
? `Round reopened. Paused: ${data.pausedRounds.join(', ')}`
|
? `Round reopened. Paused: ${data.pausedRounds.join(', ')}`
|
||||||
: 'Round reopened'
|
: 'Round reopened'
|
||||||
@@ -318,6 +339,8 @@ export default function RoundDetailPage() {
|
|||||||
const archiveMutation = trpc.roundEngine.archive.useMutation({
|
const archiveMutation = trpc.roundEngine.archive.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.round.getById.invalidate({ id: roundId })
|
utils.round.getById.invalidate({ id: roundId })
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
|
||||||
toast.success('Round archived')
|
toast.success('Round archived')
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
@@ -492,8 +515,9 @@ 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 hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
|
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
|
||||||
const hasAwards = hasJury
|
const hasAwards = roundAwards.length > 0
|
||||||
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
|
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
|
||||||
|
const showFinalization = ['ROUND_CLOSED', 'ROUND_ARCHIVED'].includes(round?.status ?? '')
|
||||||
|
|
||||||
const poolLink = `/admin/projects?hasAssign=false&round=${roundId}` as Route
|
const poolLink = `/admin/projects?hasAssign=false&round=${roundId}` as Route
|
||||||
|
|
||||||
@@ -521,11 +545,9 @@ export default function RoundDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href={'/admin/rounds' as Route}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back" onClick={() => router.back()}>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back">
|
<ArrowLeft className="h-4 w-4" />
|
||||||
<ArrowLeft className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold">Round Not Found</h1>
|
<h1 className="text-xl font-bold">Round Not Found</h1>
|
||||||
<p className="text-sm text-muted-foreground">This round does not exist.</p>
|
<p className="text-sm text-muted-foreground">This round does not exist.</p>
|
||||||
@@ -597,12 +619,10 @@ export default function RoundDetailPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex items-start gap-3 min-w-0">
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
<Link href={(backUrl ?? (round.specialAwardId ? `/admin/awards/${round.specialAwardId}` : '/admin/rounds')) as Route} className="mt-0.5 shrink-0">
|
<Button variant="ghost" size="sm" className="mt-0.5 shrink-0 h-8 text-white/80 hover:text-white hover:bg-white/10 gap-1.5" aria-label="Back" onClick={() => router.back()}>
|
||||||
<Button variant="ghost" size="sm" className="h-8 text-white/80 hover:text-white hover:bg-white/10 gap-1.5" aria-label={round.specialAwardId ? 'Back to Award' : 'Back to rounds'}>
|
<ArrowLeft className="h-4 w-4" />
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<span className="text-xs hidden sm:inline">Back</span>
|
||||||
<span className="text-xs hidden sm:inline">{round.specialAwardId ? 'Back to Award' : 'Back to Rounds'}</span>
|
</Button>
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2.5">
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
{/* 4.6 Inline-editable round name */}
|
{/* 4.6 Inline-editable round name */}
|
||||||
@@ -650,10 +670,10 @@ export default function RoundDetailPage() {
|
|||||||
|
|
||||||
{/* Status dropdown with confirmation dialogs (4.1) */}
|
{/* Status dropdown with confirmation dialogs (4.1) */}
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip open={statusDropdownOpen ? false : undefined}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span>
|
<span>
|
||||||
<DropdownMenu>
|
<DropdownMenu onOpenChange={setStatusDropdownOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -845,6 +865,7 @@ export default function RoundDetailPage() {
|
|||||||
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []),
|
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []),
|
||||||
...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []),
|
...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []),
|
||||||
...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
|
...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
|
||||||
|
...(showFinalization ? [{ value: 'finalization', label: 'Finalization', icon: ListChecks }] : []),
|
||||||
{ value: 'config', label: 'Config', icon: Settings },
|
{ value: 'config', label: 'Config', icon: Settings },
|
||||||
...(hasAwards ? [{ value: 'awards', label: 'Awards', icon: Trophy }] : []),
|
...(hasAwards ? [{ value: 'awards', label: 'Awards', icon: Trophy }] : []),
|
||||||
].map((tab) => (
|
].map((tab) => (
|
||||||
@@ -1165,49 +1186,54 @@ export default function RoundDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Advance projects (always visible when projects exist) */}
|
{/* Advance projects — closed rounds go to Finalization tab, active rounds use old dialog */}
|
||||||
{projectCount > 0 && (
|
{projectCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => (isSimpleAdvance || passedCount > 0)
|
onClick={() => {
|
||||||
? setAdvanceDialogOpen(true)
|
if (showFinalization) {
|
||||||
: toast.info('Mark projects as "Passed" first in the Projects tab')}
|
setActiveTab('finalization')
|
||||||
|
} else if (isSimpleAdvance || passedCount > 0) {
|
||||||
|
setAdvanceDialogOpen(true)
|
||||||
|
} else {
|
||||||
|
toast.info('Mark projects as "Passed" first in the Projects tab')
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left',
|
'flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left',
|
||||||
(isSimpleAdvance || passedCount > 0)
|
(showFinalization || isSimpleAdvance || passedCount > 0)
|
||||||
? 'border-l-4 border-l-emerald-500 bg-emerald-50/30'
|
? 'border-l-4 border-l-emerald-500 bg-emerald-50/30'
|
||||||
: 'border-dashed opacity-60',
|
: 'border-dashed opacity-60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', (isSimpleAdvance || passedCount > 0) ? 'text-emerald-600' : 'text-muted-foreground')} />
|
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', (showFinalization || isSimpleAdvance || passedCount > 0) ? 'text-emerald-600' : 'text-muted-foreground')} />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Advance Projects</p>
|
<p className="text-sm font-medium">Advance Projects</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{isSimpleAdvance
|
{showFinalization
|
||||||
? `Advance all ${projectCount} project(s) to the next round`
|
? 'Review and confirm advancement in the Finalization tab'
|
||||||
: passedCount > 0
|
: isSimpleAdvance
|
||||||
? `Move ${passedCount} passed project(s) to the next round`
|
? `Advance all ${projectCount} project(s) to the next round`
|
||||||
: 'Mark projects as "Passed" first, then advance'}
|
: passedCount > 0
|
||||||
|
? `Move ${passedCount} passed project(s) to the next round`
|
||||||
|
: 'Mark projects as "Passed" first, then advance'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{isSimpleAdvance ? projectCount : passedCount}</Badge>
|
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{isSimpleAdvance ? projectCount : passedCount}</Badge>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Close & Advance (active rounds with passed projects) */}
|
{/* Close & Finalize (active rounds — closes round and opens finalization tab) */}
|
||||||
{status === 'ROUND_ACTIVE' && passedCount > 0 && (
|
{status === 'ROUND_ACTIVE' && projectCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => closeMutation.mutate({ roundId })}
|
||||||
setCloseAndAdvance(true)
|
|
||||||
closeMutation.mutate({ roundId })
|
|
||||||
}}
|
|
||||||
disabled={isTransitioning}
|
disabled={isTransitioning}
|
||||||
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-purple-500 bg-purple-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
|
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-purple-500 bg-purple-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
|
||||||
>
|
>
|
||||||
<Square className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
|
<Square className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Close & Advance</p>
|
<p className="text-sm font-medium">Close & Finalize</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
Close this round and advance {passedCount} passed project(s) to the next round
|
Close this round and review outcomes in the Finalization tab
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -1288,12 +1314,24 @@ export default function RoundDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Notifications Group */}
|
||||||
|
{projectCount > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Notifications</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<NotifyAdvancedButton roundId={roundId} />
|
||||||
|
<NotifyRejectedButton roundId={roundId} />
|
||||||
|
<BulkInviteButton roundId={roundId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
{/* Advance Projects Dialog */}
|
{/* Advance Projects Dialog — only for active rounds; closed rounds use Finalization tab */}
|
||||||
<AdvanceProjectsDialog
|
{!showFinalization && <AdvanceProjectsDialog
|
||||||
open={advanceDialogOpen}
|
open={advanceDialogOpen}
|
||||||
onOpenChange={setAdvanceDialogOpen}
|
onOpenChange={setAdvanceDialogOpen}
|
||||||
roundId={roundId}
|
roundId={roundId}
|
||||||
@@ -1308,7 +1346,7 @@ export default function RoundDetailPage() {
|
|||||||
roundType: r.roundType,
|
roundType: r.roundType,
|
||||||
}))}
|
}))}
|
||||||
currentSortOrder={round?.sortOrder}
|
currentSortOrder={round?.sortOrder}
|
||||||
/>
|
/>}
|
||||||
|
|
||||||
{/* AI Shortlist Confirmation Dialog */}
|
{/* AI Shortlist Confirmation Dialog */}
|
||||||
<AlertDialog open={shortlistDialogOpen} onOpenChange={setShortlistDialogOpen}>
|
<AlertDialog open={shortlistDialogOpen} onOpenChange={setShortlistDialogOpen}>
|
||||||
@@ -1430,11 +1468,24 @@ export default function RoundDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Document Language Summary */}
|
||||||
|
<DocumentLanguageSummary roundId={roundId as string} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ═══════════ PROJECTS TAB ═══════════ */}
|
{/* ═══════════ PROJECTS TAB ═══════════ */}
|
||||||
<TabsContent value="projects" className="space-y-4">
|
<TabsContent value="projects" className="space-y-4">
|
||||||
<ProjectStatesTable competitionId={competitionId} roundId={roundId} />
|
<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 ═══════════ */}
|
||||||
@@ -1797,7 +1848,7 @@ export default function RoundDetailPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<COIReviewSection roundId={roundId} />
|
<COIReviewSection roundId={roundId} />
|
||||||
<RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
|
<RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} onAssignUnassigned={() => setPreviewSheetOpen(true)} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -2055,6 +2106,13 @@ export default function RoundDetailPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ═══════════ FINALIZATION TAB ═══════════ */}
|
||||||
|
{showFinalization && (
|
||||||
|
<TabsContent value="finalization" className="space-y-4">
|
||||||
|
<FinalizationTab roundId={roundId} roundStatus={round.status} />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ═══════════ CONFIG TAB ═══════════ */}
|
{/* ═══════════ CONFIG TAB ═══════════ */}
|
||||||
<TabsContent value="config" className="space-y-6">
|
<TabsContent value="config" className="space-y-6">
|
||||||
{/* Round Dates */}
|
{/* Round Dates */}
|
||||||
@@ -2119,89 +2177,108 @@ export default function RoundDetailPage() {
|
|||||||
/>
|
/>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-0 pt-0">
|
<CardContent className="space-y-0 pt-0">
|
||||||
<div className="flex items-center justify-between p-4 rounded-md">
|
{(isEvaluation || isFiltering) && (
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor="notify-on-entry" className="text-sm font-medium">
|
|
||||||
Notify on round entry
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Send an automated email to project applicants when their project enters this round
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="notify-on-entry"
|
|
||||||
checked={!!config.notifyOnEntry}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
handleConfigChange({ ...config, notifyOnEntry: checked })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-4 rounded-md bg-muted/30">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor="notify-on-advance" className="text-sm font-medium">
|
|
||||||
Notify on advance
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Send an email to project applicants when their project advances from this round to the next
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="notify-on-advance"
|
|
||||||
checked={!!config.notifyOnAdvance}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
handleConfigChange({ ...config, notifyOnAdvance: checked })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t mt-2 pt-4 px-4 pb-2 bg-[#053d57]/[0.03] rounded-b-lg -mx-6 -mb-6 p-6">
|
<div className="border-t mt-2 pt-4 px-4 pb-2 bg-[#053d57]/[0.03] rounded-b-lg -mx-6 -mb-6 p-6">
|
||||||
<Label className="text-sm font-medium">Advancement Targets</Label>
|
<Label className="text-sm font-medium">Advancement Targets</Label>
|
||||||
{isEvaluation && !(config.startupAdvanceCount as number) && !(config.conceptAdvanceCount as number) && (
|
|
||||||
<div className="mt-2 mb-1 rounded-md border border-amber-200 bg-amber-50 px-3 py-2">
|
|
||||||
<p className="text-xs text-amber-700">Advancement targets not configured — all passed projects will be eligible to advance.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-muted-foreground mb-3">
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
Target number of projects per category to advance from this round
|
How to determine which projects advance from this round
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1.5">
|
{/* Mode toggle */}
|
||||||
<Label htmlFor="startup-advance-count" className="text-xs text-muted-foreground">
|
<div className="flex gap-2 mb-4">
|
||||||
Startup Projects
|
<Button
|
||||||
</Label>
|
type="button"
|
||||||
<Input
|
size="sm"
|
||||||
id="startup-advance-count"
|
variant={(config.advanceMode as string) === 'threshold' ? 'outline' : 'default'}
|
||||||
type="number"
|
className="h-8 text-xs"
|
||||||
min={0}
|
onClick={() => handleConfigChange({ ...config, advanceMode: 'count' })}
|
||||||
className="h-9"
|
>
|
||||||
placeholder="No limit"
|
Fixed Count
|
||||||
value={(config.startupAdvanceCount as number) ?? ''}
|
</Button>
|
||||||
onChange={(e) => {
|
<Button
|
||||||
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
|
type="button"
|
||||||
handleConfigChange({ ...config, startupAdvanceCount: val })
|
size="sm"
|
||||||
}}
|
variant={(config.advanceMode as string) === 'threshold' ? 'default' : 'outline'}
|
||||||
/>
|
className="h-8 text-xs"
|
||||||
</div>
|
onClick={() => handleConfigChange({ ...config, advanceMode: 'threshold' })}
|
||||||
<div className="space-y-1.5">
|
>
|
||||||
<Label htmlFor="concept-advance-count" className="text-xs text-muted-foreground">
|
Score Threshold
|
||||||
Concept Projects
|
</Button>
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="concept-advance-count"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
className="h-9"
|
|
||||||
placeholder="No limit"
|
|
||||||
value={(config.conceptAdvanceCount as number) ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
|
|
||||||
handleConfigChange({ ...config, conceptAdvanceCount: val })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(config.advanceMode as string) === 'threshold' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="advance-threshold" className="text-xs text-muted-foreground">
|
||||||
|
Minimum Average Score to Advance
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
All projects scoring at or above this threshold will advance (both categories)
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
id="advance-threshold"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
step={0.1}
|
||||||
|
className="h-9 w-32"
|
||||||
|
placeholder="e.g. 6.5"
|
||||||
|
value={(config.advanceScoreThreshold as number) ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value ? parseFloat(e.target.value) : undefined
|
||||||
|
handleConfigChange({ ...config, advanceScoreThreshold: val })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isEvaluation && !(config.startupAdvanceCount as number) && !(config.conceptAdvanceCount as number) && (
|
||||||
|
<div className="mb-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2">
|
||||||
|
<p className="text-xs text-amber-700">Advancement targets not configured — all passed projects will be eligible to advance.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
Target number of projects per category to advance
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="startup-advance-count" className="text-xs text-muted-foreground">
|
||||||
|
Startup Projects
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="startup-advance-count"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="No limit"
|
||||||
|
value={(config.startupAdvanceCount as number) ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||||
|
handleConfigChange({ ...config, startupAdvanceCount: val })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="concept-advance-count" className="text-xs text-muted-foreground">
|
||||||
|
Concept Projects
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="concept-advance-count"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="No limit"
|
||||||
|
value={(config.conceptAdvanceCount as number) ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||||
|
handleConfigChange({ ...config, conceptAdvanceCount: val })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -2405,3 +2482,75 @@ export default function RoundDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Document Language Summary — flags non-English docs
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const LANG_NAMES: Record<string, string> = {
|
||||||
|
eng: 'English', fra: 'French', deu: 'German', spa: 'Spanish', ita: 'Italian',
|
||||||
|
por: 'Portuguese', nld: 'Dutch', rus: 'Russian', ara: 'Arabic', zho: 'Chinese',
|
||||||
|
jpn: 'Japanese', kor: 'Korean', tur: 'Turkish', pol: 'Polish', ron: 'Romanian',
|
||||||
|
ces: 'Czech', ell: 'Greek', hun: 'Hungarian', swe: 'Swedish', dan: 'Danish',
|
||||||
|
fin: 'Finnish', nor: 'Norwegian', und: 'Unknown',
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentLanguageSummary({ roundId }: { roundId: string }) {
|
||||||
|
const { data, isLoading } = trpc.file.roundLanguageSummary.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 60_000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading || !data) return null
|
||||||
|
if (data.totalFiles === 0) return null
|
||||||
|
|
||||||
|
const allGood = data.nonEnglishCount === 0 && data.unanalyzedCount === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={allGood ? 'border-green-200 bg-green-50/30' : data.nonEnglishCount > 0 ? 'border-amber-300 bg-amber-50/30' : ''}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<Languages className={cn('h-4 w-4', allGood ? 'text-green-600' : data.nonEnglishCount > 0 ? 'text-amber-600' : 'text-muted-foreground')} />
|
||||||
|
Document Languages
|
||||||
|
{data.nonEnglishCount > 0 && (
|
||||||
|
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
|
||||||
|
{data.nonEnglishCount} flagged
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
{data.analyzedCount} of {data.totalFiles} documents analyzed
|
||||||
|
{data.unanalyzedCount > 0 && ` — ${data.unanalyzedCount} pending`}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{data.nonEnglishCount > 0 && (
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{data.flaggedProjects.map((project) => (
|
||||||
|
<div key={project.projectId} className="rounded-md border bg-white p-3">
|
||||||
|
<Link
|
||||||
|
href={`/admin/projects/${project.projectId}` as Route}
|
||||||
|
className="text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{project.projectTitle}
|
||||||
|
</Link>
|
||||||
|
<div className="mt-1.5 space-y-1">
|
||||||
|
{project.files.map((file) => (
|
||||||
|
<div key={file.id} className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<FileText className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||||
|
<span className="truncate">{file.fileName}</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 shrink-0 ml-2 border-amber-300 text-amber-700">
|
||||||
|
{LANG_NAMES[file.detectedLang ?? ''] || file.detectedLang}
|
||||||
|
{file.langConfidence != null && ` (${Math.round(file.langConfidence * 100)}%)`}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ import {
|
|||||||
FileBox,
|
FileBox,
|
||||||
Save,
|
Save,
|
||||||
Loader2,
|
Loader2,
|
||||||
Award,
|
|
||||||
Trophy,
|
Trophy,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -151,27 +150,42 @@ export default function RoundsPage() {
|
|||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const rounds = useMemo(() => {
|
// Split rounds into main pipeline (no specialAwardId) and award tracks
|
||||||
|
const mainRounds = useMemo(() => {
|
||||||
const all = (compDetail?.rounds ?? []) as RoundWithStats[]
|
const all = (compDetail?.rounds ?? []) as RoundWithStats[]
|
||||||
return filterType === 'all' ? all : all.filter((r) => r.roundType === filterType)
|
const main = all.filter((r) => !r.specialAwardId)
|
||||||
|
return filterType === 'all' ? main : main.filter((r) => r.roundType === filterType)
|
||||||
}, [compDetail?.rounds, filterType])
|
}, [compDetail?.rounds, filterType])
|
||||||
|
|
||||||
// Group awards by their evaluationRoundId
|
// Group award-track rounds by their specialAwardId, paired with the award metadata
|
||||||
const awardsByRound = useMemo(() => {
|
const awardTrackGroups = useMemo(() => {
|
||||||
const map = new Map<string, SpecialAwardItem[]>()
|
const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[]
|
||||||
for (const award of (awards ?? []) as SpecialAwardItem[]) {
|
const awardRounds = allRounds.filter((r) => r.specialAwardId)
|
||||||
if (award.evaluationRoundId) {
|
const groups = new Map<string, { award: SpecialAwardItem; rounds: RoundWithStats[] }>()
|
||||||
const existing = map.get(award.evaluationRoundId) ?? []
|
|
||||||
existing.push(award)
|
for (const round of awardRounds) {
|
||||||
map.set(award.evaluationRoundId, existing)
|
const awardId = round.specialAwardId!
|
||||||
|
if (!groups.has(awardId)) {
|
||||||
|
const award = ((awards ?? []) as SpecialAwardItem[]).find((a) => a.id === awardId)
|
||||||
|
if (!award) continue
|
||||||
|
groups.set(awardId, { award, rounds: [] })
|
||||||
}
|
}
|
||||||
|
groups.get(awardId)!.rounds.push(round)
|
||||||
}
|
}
|
||||||
return map
|
return Array.from(groups.values())
|
||||||
}, [awards])
|
}, [compDetail?.rounds, awards])
|
||||||
|
|
||||||
const floatingAwards = useMemo(() => {
|
const floatingAwards = useMemo(() => {
|
||||||
return ((awards ?? []) as SpecialAwardItem[]).filter((a) => !a.evaluationRoundId)
|
// Awards that have no evaluationRoundId AND no rounds linked via specialAwardId
|
||||||
}, [awards])
|
const awardIdsWithRounds = new Set(
|
||||||
|
((compDetail?.rounds ?? []) as RoundWithStats[])
|
||||||
|
.filter((r) => r.specialAwardId)
|
||||||
|
.map((r) => r.specialAwardId!)
|
||||||
|
)
|
||||||
|
return ((awards ?? []) as SpecialAwardItem[]).filter(
|
||||||
|
(a) => !a.evaluationRoundId && !awardIdsWithRounds.has(a.id)
|
||||||
|
)
|
||||||
|
}, [awards, compDetail?.rounds])
|
||||||
|
|
||||||
const handleCreateRound = () => {
|
const handleCreateRound = () => {
|
||||||
if (!roundForm.name.trim() || !roundForm.roundType || !comp) {
|
if (!roundForm.name.trim() || !roundForm.roundType || !comp) {
|
||||||
@@ -271,8 +285,10 @@ export default function RoundsPage() {
|
|||||||
const activeFilter = filterType !== 'all'
|
const activeFilter = filterType !== 'all'
|
||||||
const totalProjects = (compDetail as any)?.distinctProjectCount ?? 0
|
const totalProjects = (compDetail as any)?.distinctProjectCount ?? 0
|
||||||
const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[]
|
const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[]
|
||||||
|
const allMainRounds = allRounds.filter((r) => !r.specialAwardId)
|
||||||
|
const awardRoundCount = allRounds.length - allMainRounds.length
|
||||||
const totalAssignments = allRounds.reduce((s, r) => s + r._count.assignments, 0)
|
const totalAssignments = allRounds.reduce((s, r) => s + r._count.assignments, 0)
|
||||||
const activeRound = allRounds.find((r) => r.status === 'ROUND_ACTIVE')
|
const activeRound = allMainRounds.find((r) => r.status === 'ROUND_ACTIVE')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
@@ -313,7 +329,7 @@ export default function RoundsPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
|
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
|
||||||
<span>{allRounds.filter((r) => !r.specialAwardId).length} rounds</span>
|
<span>{allMainRounds.length} rounds{awardRoundCount > 0 ? ` + ${awardRoundCount} award` : ''}</span>
|
||||||
<span className="text-muted-foreground/30">|</span>
|
<span className="text-muted-foreground/30">|</span>
|
||||||
<span>{totalProjects} projects</span>
|
<span>{totalProjects} projects</span>
|
||||||
<span className="text-muted-foreground/30">|</span>
|
<span className="text-muted-foreground/30">|</span>
|
||||||
@@ -330,12 +346,12 @@ export default function RoundsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{awards && awards.length > 0 && (
|
{awardTrackGroups.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground/30">|</span>
|
<span className="text-muted-foreground/30">|</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Award className="h-3.5 w-3.5" />
|
<Trophy className="h-3.5 w-3.5" />
|
||||||
{awards.length} awards
|
{awardTrackGroups.length} award {awardTrackGroups.length === 1 ? 'track' : 'tracks'}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -389,7 +405,7 @@ export default function RoundsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Pipeline View ───────────────────────────────────────────── */}
|
{/* ── Pipeline View ───────────────────────────────────────────── */}
|
||||||
{rounds.length === 0 ? (
|
{mainRounds.length === 0 && awardTrackGroups.length === 0 ? (
|
||||||
<div className="py-16 text-center border-2 border-dashed rounded-lg">
|
<div className="py-16 text-center border-2 border-dashed rounded-lg">
|
||||||
<FileBox className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
|
<FileBox className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -397,142 +413,79 @@ export default function RoundsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative">
|
<div className="space-y-6">
|
||||||
{/* Main pipeline track */}
|
{/* ── Main Competition Pipeline ───────────────────────── */}
|
||||||
{rounds.map((round, index) => {
|
{mainRounds.length > 0 && (
|
||||||
const isLast = index === rounds.length - 1
|
<div className="relative">
|
||||||
const typeColors = ROUND_TYPE_COLORS[round.roundType] ?? ROUND_TYPE_COLORS.INTAKE
|
{mainRounds.map((round, index) => (
|
||||||
const statusStyle = ROUND_STATUS_STYLES[round.status] ?? ROUND_STATUS_STYLES.ROUND_DRAFT
|
<RoundRow
|
||||||
const projectCount = round._count.projectRoundStates
|
key={round.id}
|
||||||
const assignmentCount = round._count.assignments
|
round={round}
|
||||||
const roundAwards = awardsByRound.get(round.id) ?? []
|
isLast={index === mainRounds.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Award Track Sections ────────────────────────────── */}
|
||||||
|
{awardTrackGroups.map(({ award, rounds: awardRounds }) => {
|
||||||
|
const isExclusive = award.eligibilityMode === 'SEPARATE_POOL'
|
||||||
|
const eligible = award._count.eligibilities
|
||||||
|
const statusColor = AWARD_STATUS_COLORS[award.status] ?? 'text-gray-500'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={round.id} className="relative">
|
<div
|
||||||
{/* Round row with pipeline connector */}
|
key={award.id}
|
||||||
<div className="flex">
|
className="rounded-lg border border-amber-200/80 bg-amber-50/30 overflow-hidden"
|
||||||
{/* Left: pipeline track */}
|
>
|
||||||
<div className="flex flex-col items-center shrink-0 w-10">
|
{/* Award track header */}
|
||||||
{/* Status dot */}
|
<Link href={`/admin/awards/${award.id}` as Route}>
|
||||||
<Tooltip>
|
<div className="group flex items-center gap-3 px-4 py-3 border-b border-amber-200/60 hover:bg-amber-50/60 transition-colors cursor-pointer">
|
||||||
<TooltipTrigger asChild>
|
<div className="flex items-center justify-center h-8 w-8 rounded-full bg-amber-100 shrink-0">
|
||||||
<div className="relative z-10 flex items-center justify-center">
|
<Trophy className="h-4 w-4 text-amber-600" />
|
||||||
<div
|
|
||||||
className="h-3.5 w-3.5 rounded-full border-2 border-white shadow-sm"
|
|
||||||
style={{ backgroundColor: statusStyle.color }}
|
|
||||||
/>
|
|
||||||
{statusStyle.pulse && (
|
|
||||||
<div
|
|
||||||
className="absolute h-3.5 w-3.5 rounded-full animate-ping opacity-40"
|
|
||||||
style={{ backgroundColor: statusStyle.color }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left" className="text-xs">
|
|
||||||
{statusStyle.label}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
{/* Connector line */}
|
|
||||||
{!isLast && (
|
|
||||||
<div className="w-px flex-1 min-h-[8px] bg-border" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: round content + awards */}
|
|
||||||
<div className="flex-1 min-w-0 pb-2">
|
|
||||||
<div className="flex items-stretch gap-3">
|
|
||||||
{/* Round row */}
|
|
||||||
<Link
|
|
||||||
href={`/admin/rounds/${round.id}` as Route}
|
|
||||||
className="flex-1 min-w-0"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'group flex items-center gap-3 px-3 py-2.5 rounded-md border-l-[3px] cursor-pointer transition-all',
|
|
||||||
'bg-white hover:bg-gray-50/80 hover:shadow-sm',
|
|
||||||
)}
|
|
||||||
style={{ borderLeftColor: typeColors.dot }}
|
|
||||||
>
|
|
||||||
{/* Round type indicator */}
|
|
||||||
<span className={cn(
|
|
||||||
'text-[10px] font-semibold uppercase tracking-wider shrink-0 w-[70px]',
|
|
||||||
typeColors.text
|
|
||||||
)}>
|
|
||||||
{round.roundType.replace('_', ' ')}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Round name */}
|
|
||||||
<span className="text-sm font-semibold text-[#053d57] truncate group-hover:text-[#de0f1e] transition-colors min-w-0 flex-1">
|
|
||||||
{round.name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Stats cluster */}
|
|
||||||
<div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground shrink-0">
|
|
||||||
{round.juryGroup && (
|
|
||||||
<span className="flex items-center gap-1 max-w-[120px]">
|
|
||||||
<Users className="h-3 w-3 shrink-0" />
|
|
||||||
<span className="truncate">{round.juryGroup.name}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<FileBox className="h-3 w-3 shrink-0" />
|
|
||||||
{projectCount}
|
|
||||||
</span>
|
|
||||||
{assignmentCount > 0 && (
|
|
||||||
<span className="tabular-nums">{assignmentCount} asgn</span>
|
|
||||||
)}
|
|
||||||
{(round.windowOpenAt || round.windowCloseAt) && (
|
|
||||||
<span className="flex items-center gap-1 tabular-nums">
|
|
||||||
<Calendar className="h-3 w-3 shrink-0" />
|
|
||||||
{round.windowOpenAt
|
|
||||||
? new Date(round.windowOpenAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })
|
|
||||||
: ''}
|
|
||||||
{round.windowOpenAt && round.windowCloseAt ? ' \u2013 ' : ''}
|
|
||||||
{round.windowCloseAt
|
|
||||||
? new Date(round.windowCloseAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })
|
|
||||||
: ''}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status badge (compact) */}
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] px-1.5 py-0 h-5 font-medium shrink-0 hidden md:inline-flex"
|
|
||||||
style={{ color: statusStyle.color, borderColor: statusStyle.color + '40' }}
|
|
||||||
>
|
|
||||||
{statusStyle.label}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{/* Arrow */}
|
|
||||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/30 group-hover:text-[#de0f1e]/60 transition-colors shrink-0" />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Awards branching off this round */}
|
|
||||||
{roundAwards.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
{/* Connector dash */}
|
|
||||||
<div className="w-4 h-px bg-amber-300" />
|
|
||||||
{/* Award nodes */}
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{roundAwards.map((award) => (
|
|
||||||
<AwardNode key={award.id} award={award} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="text-sm font-semibold text-[#053d57] group-hover:text-[#de0f1e] transition-colors truncate">
|
||||||
|
{award.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||||
|
<span>{eligible} projects</span>
|
||||||
|
<span className="text-muted-foreground/30">·</span>
|
||||||
|
<span className={cn(
|
||||||
|
'text-[10px] font-semibold uppercase tracking-wide px-1.5 py-px rounded',
|
||||||
|
isExclusive
|
||||||
|
? 'bg-red-100 text-red-600'
|
||||||
|
: 'bg-blue-100 text-blue-600'
|
||||||
|
)}>
|
||||||
|
{isExclusive ? 'Exclusive pool' : 'Parallel'}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground/30">·</span>
|
||||||
|
<span className={statusColor}>
|
||||||
|
{award.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4 text-muted-foreground/30 group-hover:text-[#de0f1e]/60 transition-colors shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Award track rounds */}
|
||||||
|
<div className="px-4 py-2">
|
||||||
|
{awardRounds.map((round, index) => (
|
||||||
|
<RoundRow
|
||||||
|
key={round.id}
|
||||||
|
round={round}
|
||||||
|
isLast={index === awardRounds.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Floating awards (no evaluationRoundId) */}
|
{/* Floating awards (no linked rounds) */}
|
||||||
{floatingAwards.length > 0 && (
|
{floatingAwards.length > 0 && (
|
||||||
<div className="mt-4 pt-4 border-t border-dashed">
|
<div className="pt-2 border-t border-dashed">
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-2 pl-10">
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-2 pl-10">
|
||||||
Unlinked Awards
|
Unlinked Awards
|
||||||
</p>
|
</p>
|
||||||
@@ -685,6 +638,112 @@ export default function RoundsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Round Row ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RoundRow({ round, isLast }: { round: RoundWithStats; isLast: boolean }) {
|
||||||
|
const typeColors = ROUND_TYPE_COLORS[round.roundType] ?? ROUND_TYPE_COLORS.INTAKE
|
||||||
|
const statusStyle = ROUND_STATUS_STYLES[round.status] ?? ROUND_STATUS_STYLES.ROUND_DRAFT
|
||||||
|
const projectCount = round._count.projectRoundStates
|
||||||
|
const assignmentCount = round._count.assignments
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex">
|
||||||
|
{/* Left: pipeline track */}
|
||||||
|
<div className="flex flex-col items-center shrink-0 w-10">
|
||||||
|
{/* Spacer to vertically center dot with the round card (py-2.5 + half line height) */}
|
||||||
|
<div className="h-[18px] shrink-0" />
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="relative z-10 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="h-3.5 w-3.5 rounded-full border-2 border-white shadow-sm"
|
||||||
|
style={{ backgroundColor: statusStyle.color }}
|
||||||
|
/>
|
||||||
|
{statusStyle.pulse && (
|
||||||
|
<div
|
||||||
|
className="absolute h-3.5 w-3.5 rounded-full animate-ping opacity-40"
|
||||||
|
style={{ backgroundColor: statusStyle.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left" className="text-xs">
|
||||||
|
{statusStyle.label}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
{!isLast && (
|
||||||
|
<div className="w-px flex-1 min-h-[8px] bg-border" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: round content */}
|
||||||
|
<div className="flex-1 min-w-0 pb-2">
|
||||||
|
<Link
|
||||||
|
href={`/admin/rounds/${round.id}` as Route}
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-3 px-3 py-2.5 rounded-md border-l-[3px] cursor-pointer transition-all',
|
||||||
|
'bg-white hover:bg-gray-50/80 hover:shadow-sm',
|
||||||
|
)}
|
||||||
|
style={{ borderLeftColor: typeColors.dot }}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
'text-[10px] font-semibold uppercase tracking-wider shrink-0 w-[88px]',
|
||||||
|
typeColors.text
|
||||||
|
)}>
|
||||||
|
{round.roundType.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-sm font-semibold text-[#053d57] truncate group-hover:text-[#de0f1e] transition-colors min-w-0 flex-1">
|
||||||
|
{round.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground shrink-0">
|
||||||
|
{round.juryGroup && (
|
||||||
|
<span className="flex items-center gap-1 max-w-[120px]">
|
||||||
|
<Users className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="truncate">{round.juryGroup.name}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<FileBox className="h-3 w-3 shrink-0" />
|
||||||
|
{projectCount}
|
||||||
|
</span>
|
||||||
|
{assignmentCount > 0 && (
|
||||||
|
<span className="tabular-nums">{assignmentCount} asgn</span>
|
||||||
|
)}
|
||||||
|
{(round.windowOpenAt || round.windowCloseAt) && (
|
||||||
|
<span className="flex items-center gap-1 tabular-nums">
|
||||||
|
<Calendar className="h-3 w-3 shrink-0" />
|
||||||
|
{round.windowOpenAt
|
||||||
|
? new Date(round.windowOpenAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })
|
||||||
|
: ''}
|
||||||
|
{round.windowOpenAt && round.windowCloseAt ? ' \u2013 ' : ''}
|
||||||
|
{round.windowCloseAt
|
||||||
|
? new Date(round.windowCloseAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] px-1.5 py-0 h-5 font-medium shrink-0 hidden md:inline-flex"
|
||||||
|
style={{ color: statusStyle.color, borderColor: statusStyle.color + '40' }}
|
||||||
|
>
|
||||||
|
{statusStyle.label}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/30 group-hover:text-[#de0f1e]/60 transition-colors shrink-0" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Award Node ──────────────────────────────────────────────────────────────
|
// ─── Award Node ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function AwardNode({ award }: { award: SpecialAwardItem }) {
|
function AwardNode({ award }: { award: SpecialAwardItem }) {
|
||||||
|
|||||||
42
src/app/(admin)/admin/semi-finalists/page.tsx
Normal file
42
src/app/(admin)/admin/semi-finalists/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { SemiFinalistsContent } from '@/components/admin/semi-finalists-content'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Semi-Finalists' }
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
searchParams: Promise<{ editionId?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SemiFinalistsPage({ searchParams }: PageProps) {
|
||||||
|
const params = await searchParams
|
||||||
|
let editionId = params.editionId || null
|
||||||
|
|
||||||
|
if (!editionId) {
|
||||||
|
const defaultEdition = await prisma.program.findFirst({
|
||||||
|
where: { status: 'ACTIVE' },
|
||||||
|
orderBy: { year: 'desc' },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
editionId = defaultEdition?.id || null
|
||||||
|
|
||||||
|
if (!editionId) {
|
||||||
|
const anyEdition = await prisma.program.findFirst({
|
||||||
|
orderBy: { year: 'desc' },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
editionId = anyEdition?.id || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editionId) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
No edition found.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SemiFinalistsContent editionId={editionId} />
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Link from 'next/link'
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -212,6 +212,7 @@ function SortableTagRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TagsSettingsPage() {
|
export default function TagsSettingsPage() {
|
||||||
|
const router = useRouter()
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||||
const [editingTag, setEditingTag] = useState<Tag | null>(null)
|
const [editingTag, setEditingTag] = useState<Tag | null>(null)
|
||||||
@@ -384,11 +385,9 @@ export default function TagsSettingsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/settings">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Link from 'next/link'
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -86,6 +86,7 @@ const defaultForm: WebhookFormData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function WebhooksPage() {
|
export default function WebhooksPage() {
|
||||||
|
const router = useRouter()
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
@@ -209,11 +210,16 @@ export default function WebhooksPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredHeaders = formData.headers.filter((h) => h.key)
|
||||||
|
const headersRecord = filteredHeaders.length > 0
|
||||||
|
? Object.fromEntries(filteredHeaders.map((h) => [h.key, h.value]))
|
||||||
|
: undefined
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
url: formData.url,
|
url: formData.url,
|
||||||
events: formData.events,
|
events: formData.events,
|
||||||
headers: formData.headers.filter((h) => h.key) as Record<string, string>[] | undefined,
|
headers: headersRecord,
|
||||||
maxRetries: formData.maxRetries,
|
maxRetries: formData.maxRetries,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,11 +260,9 @@ export default function WebhooksPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/settings">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import Link from 'next/link'
|
import { useRouter } from 'next/navigation'
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline'
|
import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline'
|
||||||
import { ArrowLeft, FileText, Calendar } from 'lucide-react'
|
import { ArrowLeft, FileText } from 'lucide-react'
|
||||||
|
|
||||||
export default function ApplicantCompetitionPage() {
|
export default function ApplicantCompetitionPage() {
|
||||||
|
const router = useRouter()
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
||||||
enabled: !!session,
|
enabled: !!session,
|
||||||
@@ -36,11 +36,9 @@ export default function ApplicantCompetitionPage() {
|
|||||||
Track your progress through competition rounds
|
Track your progress through competition rounds
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||||
<Link href={'/applicant' as Route} aria-label="Back to applicant dashboard">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Dashboard
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,29 +59,6 @@ export default function ApplicantCompetitionPage() {
|
|||||||
<ApplicantCompetitionTimeline />
|
<ApplicantCompetitionTimeline />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Calendar className="h-5 w-5" />
|
|
||||||
Quick Actions
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<Button variant="outline" className="w-full justify-start" asChild>
|
|
||||||
<Link href={'/applicant/documents' as Route}>
|
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
|
||||||
View Documents
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
{myProject?.openRounds && myProject.openRounds.length > 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground px-3 py-2 bg-muted/50 rounded-md">
|
|
||||||
{myProject.openRounds.length} submission window
|
|
||||||
{myProject.openRounds.length !== 1 ? 's' : ''} currently open
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Timeline Info</CardTitle>
|
<CardTitle>Timeline Info</CardTitle>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -12,6 +14,7 @@ import {
|
|||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
|
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
|
||||||
|
import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer'
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -20,7 +23,11 @@ import {
|
|||||||
Video,
|
Video,
|
||||||
File,
|
File,
|
||||||
Download,
|
Download,
|
||||||
|
Eye,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
const fileTypeIcons: Record<string, typeof FileText> = {
|
const fileTypeIcons: Record<string, typeof FileText> = {
|
||||||
EXEC_SUMMARY: FileText,
|
EXEC_SUMMARY: FileText,
|
||||||
@@ -42,6 +49,114 @@ const fileTypeLabels: Record<string, string> = {
|
|||||||
SUPPORTING_DOC: 'Supporting Document',
|
SUPPORTING_DOC: 'Supporting Document',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FileRow({ file }: { file: { id: string; fileName: string; fileType: string; createdAt: string | Date; isLate?: boolean; bucket?: string; objectKey?: string; mimeType?: string } }) {
|
||||||
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
|
const Icon = fileTypeIcons[file.fileType] || File
|
||||||
|
const mimeType = file.mimeType || ''
|
||||||
|
|
||||||
|
const canPreview =
|
||||||
|
mimeType.startsWith('video/') ||
|
||||||
|
mimeType === 'application/pdf' ||
|
||||||
|
mimeType.startsWith('image/') ||
|
||||||
|
isOfficeFile(mimeType, file.fileName)
|
||||||
|
|
||||||
|
const { data: previewData, isLoading: isLoadingPreview } = trpc.file.getDownloadUrl.useQuery(
|
||||||
|
{ bucket: file.bucket!, objectKey: file.objectKey!, purpose: 'preview' as const },
|
||||||
|
{ enabled: showPreview && !!file.bucket && !!file.objectKey, staleTime: 10 * 60 * 1000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between p-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Icon className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium text-sm truncate">{file.fileName}</p>
|
||||||
|
{file.isLate && (
|
||||||
|
<Badge variant="warning" className="text-xs gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
Late
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{fileTypeLabels[file.fileType] || file.fileType}
|
||||||
|
{' - '}
|
||||||
|
{new Date(file.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{file.bucket && file.objectKey && (
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{canPreview && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs gap-1"
|
||||||
|
onClick={() => setShowPreview(!showPreview)}
|
||||||
|
>
|
||||||
|
{showPreview ? (
|
||||||
|
<><X className="h-3 w-3" /> Close</>
|
||||||
|
) : (
|
||||||
|
<><Eye className="h-3 w-3" /> View</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<DownloadButton bucket={file.bucket} objectKey={file.objectKey} fileName={file.fileName} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPreview && (
|
||||||
|
<div className="border-t bg-muted/50">
|
||||||
|
{isLoadingPreview ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : previewData?.url ? (
|
||||||
|
<FilePreview file={{ mimeType, fileName: file.fileName }} url={previewData.url} />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
|
||||||
|
Failed to load preview
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadButton({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) {
|
||||||
|
const [downloading, setDownloading] = useState(false)
|
||||||
|
|
||||||
|
const { refetch } = trpc.file.getDownloadUrl.useQuery(
|
||||||
|
{ bucket, objectKey, forDownload: true, fileName, purpose: 'download' as const },
|
||||||
|
{ enabled: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
setDownloading(true)
|
||||||
|
try {
|
||||||
|
const result = await refetch()
|
||||||
|
if (result.data?.url) {
|
||||||
|
window.location.href = result.data.url
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to download file')
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => setDownloading(false), 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleDownload} disabled={downloading}>
|
||||||
|
{downloading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ApplicantDocumentsPage() {
|
export default function ApplicantDocumentsPage() {
|
||||||
const { status: sessionStatus } = useSession()
|
const { status: sessionStatus } = useSession()
|
||||||
const isAuthenticated = sessionStatus === 'authenticated'
|
const isAuthenticated = sessionStatus === 'authenticated'
|
||||||
@@ -82,8 +197,7 @@ export default function ApplicantDocumentsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { project, openRounds } = data
|
const { project, openRounds, isRejected } = data
|
||||||
const isDraft = !project.submittedAt
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -98,8 +212,20 @@ export default function ApplicantDocumentsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Rejected banner */}
|
||||||
|
{isRejected && (
|
||||||
|
<Card className="border-destructive/50 bg-destructive/5">
|
||||||
|
<CardContent className="flex items-center gap-3 py-4">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive shrink-0" />
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
Your project was not selected to advance. Documents are view-only.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Per-round upload sections */}
|
{/* Per-round upload sections */}
|
||||||
{openRounds.length > 0 && (
|
{!isRejected && openRounds.length > 0 && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{openRounds.map((round: { id: string; name: string; windowCloseAt?: string | Date | null }) => {
|
{openRounds.map((round: { id: string; name: string; windowCloseAt?: string | Date | null }) => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -162,34 +288,9 @@ export default function ApplicantDocumentsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{project.files.map((file) => {
|
{project.files.map((file) => {
|
||||||
const Icon = fileTypeIcons[file.fileType] || File
|
const fileRecord = file as typeof file & { isLate?: boolean; bucket?: string; objectKey?: string; mimeType?: string }
|
||||||
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<FileRow key={file.id} file={fileRecord} />
|
||||||
key={file.id}
|
|
||||||
className="flex items-center justify-between p-3 rounded-lg border"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="font-medium text-sm">{file.fileName}</p>
|
|
||||||
{fileRecord.isLate && (
|
|
||||||
<Badge variant="warning" className="text-xs gap-1">
|
|
||||||
<AlertTriangle className="h-3 w-3" />
|
|
||||||
Late
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{fileTypeLabels[file.fileType] || file.fileType}
|
|
||||||
{' - '}
|
|
||||||
{new Date(file.createdAt).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,8 +8,82 @@ 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 { Star, MessageSquare } from 'lucide-react'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
import {
|
||||||
|
Star,
|
||||||
|
MessageSquare,
|
||||||
|
Trophy,
|
||||||
|
Vote,
|
||||||
|
TrendingUp,
|
||||||
|
BarChart3,
|
||||||
|
Award,
|
||||||
|
ShieldCheck,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type EvaluationRound = {
|
||||||
|
roundId: string
|
||||||
|
roundName: string
|
||||||
|
roundType: string
|
||||||
|
evaluationCount: number
|
||||||
|
evaluations: Array<{
|
||||||
|
id: string
|
||||||
|
submittedAt: Date | null
|
||||||
|
globalScore: number | null
|
||||||
|
criterionScores: unknown
|
||||||
|
feedbackText: string | null
|
||||||
|
criteria: unknown
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRoundStats(round: EvaluationRound) {
|
||||||
|
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100
|
||||||
|
const scores = round.evaluations
|
||||||
|
.map((ev) => ev.globalScore)
|
||||||
|
.filter((s): s is number => s !== null)
|
||||||
|
const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null
|
||||||
|
const highest = scores.length > 0 ? Math.max(...scores) : null
|
||||||
|
const lowest = scores.length > 0 ? Math.min(...scores) : null
|
||||||
|
return { maxScore, avg, highest, lowest, scores }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreBar({ score, maxScore, color }: { score: number; maxScore: number; color: string }) {
|
||||||
|
const pct = (score / maxScore) * 100
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<div className="flex-1 overflow-hidden rounded-full bg-muted" style={{ height: 10 }}>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold tabular-nums w-8 text-right">{score}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScoreColor(score: number, maxScore: number): string {
|
||||||
|
const pct = score / maxScore
|
||||||
|
if (pct >= 0.8) return '#053d57'
|
||||||
|
if (pct >= 0.6) return '#1e7a8a'
|
||||||
|
if (pct >= 0.4) return '#557f8c'
|
||||||
|
if (pct >= 0.2) return '#c4453a'
|
||||||
|
return '#de0f1e'
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoundIcon({ roundType, className }: { roundType: string; className?: string }) {
|
||||||
|
if (roundType === 'LIVE_FINAL') return <Trophy className={cn('h-4 w-4 text-amber-500', className)} />
|
||||||
|
if (roundType === 'DELIBERATION') return <Vote className={cn('h-4 w-4 text-violet-500', className)} />
|
||||||
|
return <Star className={cn('h-4 w-4 text-yellow-500', className)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundIconBg(roundType: string) {
|
||||||
|
if (roundType === 'LIVE_FINAL') return 'bg-amber-500/10'
|
||||||
|
if (roundType === 'DELIBERATION') return 'bg-violet-500/10'
|
||||||
|
return 'bg-yellow-500/10'
|
||||||
|
}
|
||||||
|
|
||||||
export default function ApplicantEvaluationsPage() {
|
export default function ApplicantEvaluationsPage() {
|
||||||
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
|
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
|
||||||
@@ -21,6 +95,14 @@ 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}>
|
||||||
@@ -37,6 +119,28 @@ 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>
|
||||||
@@ -49,7 +153,9 @@ export default function ApplicantEvaluationsPage() {
|
|||||||
{!hasEvaluations ? (
|
{!hasEvaluations ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<Star className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
<div className="rounded-2xl bg-muted/60 p-4 mb-4">
|
||||||
|
<Star className="h-8 w-8 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
<h3 className="text-lg font-medium mb-2">No Evaluations Available</h3>
|
<h3 className="text-lg font-medium mb-2">No Evaluations Available</h3>
|
||||||
<p className="text-muted-foreground text-center max-w-md">
|
<p className="text-muted-foreground text-center max-w-md">
|
||||||
Evaluations will appear here once jury review is complete and results are published.
|
Evaluations will appear here once jury review is complete and results are published.
|
||||||
@@ -58,88 +164,188 @@ export default function ApplicantEvaluationsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{rounds.map((round) => (
|
{/* Stats Summary Strip */}
|
||||||
<Card key={round.roundId}>
|
<AnimatedCard index={0}>
|
||||||
<CardHeader>
|
<Card className="p-0 overflow-hidden">
|
||||||
<div className="flex items-center justify-between">
|
<div className="grid grid-cols-3 divide-x divide-border">
|
||||||
<CardTitle>{round.roundName}</CardTitle>
|
<div className="p-4 text-center">
|
||||||
<Badge variant="secondary">
|
<div className="flex items-center justify-center gap-1.5 mb-1">
|
||||||
{round.evaluationCount} evaluation{round.evaluationCount !== 1 ? 's' : ''}
|
<BarChart3 className="h-3.5 w-3.5 text-blue-500" />
|
||||||
</Badge>
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Reviews</span>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{round.evaluations.map((ev, idx) => (
|
|
||||||
<div
|
|
||||||
key={ev.id}
|
|
||||||
className="rounded-lg border p-4 space-y-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-medium text-sm">
|
|
||||||
Evaluator #{idx + 1}
|
|
||||||
</span>
|
|
||||||
{ev.submittedAt && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{new Date(ev.submittedAt).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{ev.globalScore !== null && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Star className="h-4 w-4 text-yellow-500" />
|
|
||||||
<span className="text-lg font-semibold">{ev.globalScore}</span>
|
|
||||||
<span className="text-sm text-muted-foreground">/ 100</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ev.criterionScores && ev.criteria && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Criterion Scores</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]
|
|
||||||
return (
|
|
||||||
<div key={ci} className="flex items-center justify-between text-sm">
|
|
||||||
<span>{c.label || c.name || `Criterion ${ci + 1}`}</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{score !== undefined ? score : '—'}
|
|
||||||
{c.maxScore ? ` / ${c.maxScore}` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ev.feedbackText && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
|
|
||||||
<MessageSquare className="h-3.5 w-3.5" />
|
|
||||||
Written Feedback
|
|
||||||
</div>
|
|
||||||
<blockquote className="border-l-2 border-muted pl-4 text-sm italic text-muted-foreground">
|
|
||||||
{ev.feedbackText}
|
|
||||||
</blockquote>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<p className="text-2xl font-bold tabular-nums">{totalEvaluations}</p>
|
||||||
</CardContent>
|
</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>
|
</Card>
|
||||||
))}
|
</AnimatedCard>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
{/* Per-Round Cards */}
|
||||||
Evaluator identities are kept confidential.
|
{rounds.map((round, roundIdx) => {
|
||||||
</p>
|
const { maxScore, avg, highest, lowest } = computeRoundStats(round)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedCard key={round.roundId} index={roundIdx + 1}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2.5">
|
||||||
|
<div className={cn('rounded-lg p-1.5', roundIconBg(round.roundType))}>
|
||||||
|
<RoundIcon roundType={round.roundType} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>{round.roundName}</span>
|
||||||
|
{avg !== null && round.roundType !== 'DELIBERATION' && (
|
||||||
|
<p className="text-sm font-normal text-muted-foreground mt-0.5">
|
||||||
|
Average: <span className="font-semibold text-foreground">{avg.toFixed(1)}</span> / {maxScore}
|
||||||
|
{highest !== null && lowest !== null && highest !== lowest && (
|
||||||
|
<span className="ml-2">
|
||||||
|
Range: {lowest}–{highest}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'evaluation'}{round.evaluationCount !== 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{/* Score Overview Bar — visual comparison across evaluators */}
|
||||||
|
{round.roundType !== 'DELIBERATION' && round.evaluations.some((ev) => ev.globalScore !== null) && (
|
||||||
|
<div className="px-6 pb-3">
|
||||||
|
<div className="rounded-lg bg-muted/40 p-3 space-y-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Score Comparison</p>
|
||||||
|
{round.evaluations.map((ev, idx) => {
|
||||||
|
if (ev.globalScore === null) return null
|
||||||
|
return (
|
||||||
|
<div key={ev.id} className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-muted-foreground w-6 text-right shrink-0 tabular-nums">
|
||||||
|
#{idx + 1}
|
||||||
|
</span>
|
||||||
|
<ScoreBar
|
||||||
|
score={ev.globalScore}
|
||||||
|
maxScore={maxScore}
|
||||||
|
color={getScoreColor(ev.globalScore, maxScore)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="divide-y">
|
||||||
|
{round.evaluations.map((ev, idx) => (
|
||||||
|
<div
|
||||||
|
key={ev.id}
|
||||||
|
className="px-6 py-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{ev.globalScore !== null && round.roundType !== 'DELIBERATION' && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Star className="h-3.5 w-3.5 text-yellow-500" />
|
||||||
|
<span className="text-sm font-bold tabular-nums">{ev.globalScore}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">/ {maxScore}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ev.submittedAt && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(ev.submittedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ev.criterionScores && ev.criteria && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Criteria Breakdown</p>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{(() => {
|
||||||
|
const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
|
||||||
|
const scores = ev.criterionScores as Record<string, number>
|
||||||
|
return criteria
|
||||||
|
.filter((c) => c.id || c.label || c.name)
|
||||||
|
.map((c, ci) => {
|
||||||
|
const key = c.id || String(ci)
|
||||||
|
const score = scores[key]
|
||||||
|
const cMax = c.maxScore || 10
|
||||||
|
const pct = score !== undefined ? (score / cMax) * 100 : 0
|
||||||
|
return (
|
||||||
|
<div key={ci} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">{c.label || c.name || `Criterion ${ci + 1}`}</span>
|
||||||
|
<span className="font-semibold tabular-nums">
|
||||||
|
{score !== undefined ? score : '—'}
|
||||||
|
<span className="text-muted-foreground font-normal text-xs"> / {cMax}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{score !== undefined && (
|
||||||
|
<Progress value={pct} className="h-1.5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ev.feedbackText && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
<MessageSquare className="h-3.5 w-3.5" />
|
||||||
|
{round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/40 px-4 py-3 border-l-3 border-brand-teal">
|
||||||
|
<p className="text-sm italic text-muted-foreground leading-relaxed">
|
||||||
|
{ev.feedbackText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Confidentiality Footer */}
|
||||||
|
<div className="flex items-center justify-center gap-2 py-2">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-muted-foreground/60" />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Evaluator identities are kept confidential.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
@@ -13,14 +14,15 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { StatusTracker } from '@/components/shared/status-tracker'
|
|
||||||
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
|
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
|
||||||
|
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Users,
|
Users,
|
||||||
Crown,
|
Crown,
|
||||||
@@ -29,7 +31,28 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
Star,
|
Star,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Pencil,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
UserCircle,
|
||||||
|
Trophy,
|
||||||
|
Vote,
|
||||||
|
Clock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
function formatCountdown(ms: number): string {
|
||||||
|
if (ms <= 0) return 'Closed'
|
||||||
|
const days = Math.floor(ms / (1000 * 60 * 60 * 24))
|
||||||
|
const hours = Math.floor((ms % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
||||||
|
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60))
|
||||||
|
const parts: string[] = []
|
||||||
|
if (days > 0) parts.push(`${days}d`)
|
||||||
|
if (hours > 0) parts.push(`${hours}h`)
|
||||||
|
parts.push(`${minutes}m`)
|
||||||
|
return parts.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
||||||
DRAFT: 'secondary',
|
DRAFT: 'secondary',
|
||||||
@@ -42,9 +65,13 @@ const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destru
|
|||||||
REJECTED: 'destructive',
|
REJECTED: 'destructive',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keys to hide from the metadata display (shown elsewhere or internal)
|
||||||
|
const HIDDEN_METADATA_KEYS = new Set(['TeamMembers', 'teammembers', 'team_members'])
|
||||||
|
|
||||||
export default function ApplicantDashboardPage() {
|
export default function ApplicantDashboardPage() {
|
||||||
const { status: sessionStatus } = useSession()
|
const { data: session, status: sessionStatus } = useSession()
|
||||||
const isAuthenticated = sessionStatus === 'authenticated'
|
const isAuthenticated = sessionStatus === 'authenticated'
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
@@ -62,7 +89,18 @@ export default function ApplicantDashboardPage() {
|
|||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (sessionStatus === 'loading' || isLoading) {
|
const { data: flags } = trpc.settings.getFeatureFlags.useQuery(undefined, {
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Live countdown timer for open rounds
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => setNow(Date.now()), 60_000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (sessionStatus === 'loading' || (isAuthenticated && isLoading)) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -111,41 +149,99 @@ export default function ApplicantDashboardPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { project, timeline, currentStatus, openRounds, hasPassedIntake } = data
|
const { project, timeline, currentStatus, openRounds, hasPassedIntake, isRejected } = data
|
||||||
const isDraft = !project.submittedAt
|
|
||||||
const programYear = project.program?.year
|
const programYear = project.program?.year
|
||||||
const programName = project.program?.name
|
const programName = project.program?.name
|
||||||
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
|
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
|
||||||
|
const canEditDescription = flags?.applicantAllowDescriptionEdit && !isRejected
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header — no withdraw button here */}
|
||||||
<div className="flex items-start justify-between flex-wrap gap-4">
|
<div className="flex items-start justify-between flex-wrap gap-4">
|
||||||
<div>
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-3">
|
{/* Project logo — clickable for any team member to change */}
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
|
<ProjectLogoUpload
|
||||||
{currentStatus && (
|
projectId={project.id}
|
||||||
<Badge variant={statusColors[currentStatus] || 'secondary'}>
|
currentLogoUrl={data.logoUrl}
|
||||||
{currentStatus.replace('_', ' ')}
|
onUploadComplete={() => utils.applicant.getMyDashboard.invalidate()}
|
||||||
</Badge>
|
>
|
||||||
)}
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group relative shrink-0 flex flex-col items-center gap-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="relative h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden hover:ring-2 hover:ring-primary/30 transition-all">
|
||||||
|
{data.logoUrl ? (
|
||||||
|
<img src={data.logoUrl} alt={project.title} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-7 w-7 text-muted-foreground/60" />
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
||||||
|
<Pencil className="h-4 w-4 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-primary/70 group-hover:text-primary transition-colors">
|
||||||
|
{data.logoUrl ? 'Change' : 'Add logo'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</ProjectLogoUpload>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
|
||||||
|
{currentStatus && (
|
||||||
|
<Badge variant={statusColors[currentStatus] || 'secondary'}>
|
||||||
|
{currentStatus.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Draft warning */}
|
{/* Active round deadline banner */}
|
||||||
{isDraft && (
|
{!isRejected && openRounds.length > 0 && (() => {
|
||||||
<Alert>
|
const submissionTypes = new Set(['INTAKE', 'SUBMISSION', 'MENTORING'])
|
||||||
<Clock className="h-4 w-4" />
|
const roundsWithDeadline = openRounds.filter((r) => r.windowCloseAt && submissionTypes.has(r.roundType))
|
||||||
<AlertTitle>Draft Submission</AlertTitle>
|
if (roundsWithDeadline.length === 0) return null
|
||||||
<AlertDescription>
|
return roundsWithDeadline.map((round) => {
|
||||||
This submission has not been submitted yet. You can continue editing and submit when ready.
|
const closeAt = new Date(round.windowCloseAt!).getTime()
|
||||||
</AlertDescription>
|
const remaining = closeAt - now
|
||||||
</Alert>
|
const isUrgent = remaining > 0 && remaining < 1000 * 60 * 60 * 24 * 3 // < 3 days
|
||||||
)}
|
return (
|
||||||
|
<div
|
||||||
|
key={round.id}
|
||||||
|
className={`flex flex-col sm:flex-row items-start sm:items-center gap-3 rounded-lg border px-4 py-3 ${
|
||||||
|
isUrgent
|
||||||
|
? 'border-amber-500/50 bg-amber-50 dark:bg-amber-950/20'
|
||||||
|
: 'border-primary/20 bg-primary/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Clock className={`h-4 w-4 shrink-0 ${isUrgent ? 'text-amber-600 dark:text-amber-400' : 'text-primary'}`} />
|
||||||
|
<span className="font-medium text-sm truncate">{round.name}</span>
|
||||||
|
<Badge variant={isUrgent ? 'warning' : 'default'} className="shrink-0">
|
||||||
|
{remaining > 0 ? formatCountdown(remaining) + ' left' : 'Closed'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground sm:ml-auto shrink-0">
|
||||||
|
Closes {new Date(round.windowCloseAt!).toLocaleDateString(undefined, {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})}{' '}
|
||||||
|
at {new Date(round.windowCloseAt!).toLocaleTimeString(undefined, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})()}
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
@@ -163,12 +259,19 @@ export default function ApplicantDashboardPage() {
|
|||||||
<p>{project.teamName}</p>
|
<p>{project.teamName}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{project.description && (
|
{/* Description — editable if admin allows */}
|
||||||
|
{project.description && !canEditDescription && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">Description</p>
|
<p className="text-sm font-medium text-muted-foreground">Description</p>
|
||||||
<p className="whitespace-pre-wrap">{project.description}</p>
|
<p className="whitespace-pre-wrap">{project.description}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{canEditDescription && (
|
||||||
|
<EditableDescription
|
||||||
|
projectId={project.id}
|
||||||
|
initialDescription={project.description || ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{project.tags && project.tags.length > 0 && (
|
{project.tags && project.tags.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
|
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
|
||||||
@@ -182,22 +285,27 @@ export default function ApplicantDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Metadata */}
|
{/* Metadata — filter out team members (shown in sidebar) */}
|
||||||
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
|
{project.metadataJson && (() => {
|
||||||
<div className="border-t pt-4 mt-4">
|
const entries = Object.entries(project.metadataJson as Record<string, unknown>)
|
||||||
<p className="text-sm font-medium text-muted-foreground mb-3">Additional Information</p>
|
.filter(([key]) => !HIDDEN_METADATA_KEYS.has(key))
|
||||||
<dl className="space-y-2">
|
if (entries.length === 0) return null
|
||||||
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => (
|
return (
|
||||||
<div key={key} className="flex justify-between">
|
<div className="border-t pt-4 mt-4">
|
||||||
<dt className="text-sm text-muted-foreground capitalize">
|
<p className="text-sm font-medium text-muted-foreground mb-3">Additional Information</p>
|
||||||
{key.replace(/_/g, ' ')}
|
<dl className="space-y-2">
|
||||||
</dt>
|
{entries.map(([key, value]) => (
|
||||||
<dd className="text-sm font-medium">{String(value)}</dd>
|
<div key={key} className="flex justify-between gap-4">
|
||||||
</div>
|
<dt className="text-sm text-muted-foreground capitalize shrink-0">
|
||||||
))}
|
{key.replace(/_/g, ' ')}
|
||||||
</dl>
|
</dt>
|
||||||
</div>
|
<dd className="text-sm font-medium text-right">{String(value)}</dd>
|
||||||
)}
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Meta info row */}
|
{/* Meta info row */}
|
||||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground border-t pt-4 mt-4">
|
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground border-t pt-4 mt-4">
|
||||||
@@ -205,16 +313,11 @@ export default function ApplicantDashboardPage() {
|
|||||||
<Calendar className="h-4 w-4" />
|
<Calendar className="h-4 w-4" />
|
||||||
Created {new Date(project.createdAt).toLocaleDateString()}
|
Created {new Date(project.createdAt).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
{project.submittedAt ? (
|
{project.submittedAt && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
Submitted {new Date(project.submittedAt).toLocaleDateString()}
|
Submitted {new Date(project.submittedAt).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Clock className="h-4 w-4 text-orange-500" />
|
|
||||||
Draft
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
@@ -225,51 +328,20 @@ export default function ApplicantDashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
{/* Quick actions */}
|
{/* Rejected banner */}
|
||||||
<AnimatedCard index={1}>
|
{isRejected && (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<AnimatedCard index={1}>
|
||||||
<Link href={"/applicant/documents" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
|
<Card className="border-destructive/50 bg-destructive/5">
|
||||||
<div className="rounded-xl bg-blue-500/10 p-2.5 transition-colors group-hover:bg-blue-500/20">
|
<CardContent className="flex items-center gap-3 py-4">
|
||||||
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||||
</div>
|
<p className="text-sm text-destructive">
|
||||||
<div className="flex-1 min-w-0">
|
Your project was not selected to advance. Your project space is now read-only.
|
||||||
<p className="text-sm font-medium">Documents</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{openRounds.length > 0 ? `${openRounds.length} round(s) open` : 'View uploads'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</CardContent>
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
</Card>
|
||||||
</Link>
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
|
|
||||||
<Link href={"/applicant/team" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-purple-500/30 hover:bg-purple-500/5">
|
|
||||||
<div className="rounded-xl bg-purple-500/10 p-2.5 transition-colors group-hover:bg-purple-500/20">
|
|
||||||
<Users className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium">Team</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{project.teamMembers.length} member(s)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{project.mentorAssignment && (
|
|
||||||
<Link href={"/applicant/mentor" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-green-500/30 hover:bg-green-500/5">
|
|
||||||
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
|
|
||||||
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium">Mentor</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{project.mentorAssignment.mentor?.name || 'Assigned'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AnimatedCard>
|
|
||||||
|
|
||||||
{/* Document Completeness */}
|
{/* Document Completeness */}
|
||||||
{docCompleteness && docCompleteness.length > 0 && (
|
{docCompleteness && docCompleteness.length > 0 && (
|
||||||
@@ -306,27 +378,29 @@ export default function ApplicantDashboardPage() {
|
|||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Competition timeline or status tracker */}
|
{/* Competition timeline */}
|
||||||
<AnimatedCard index={3}>
|
<AnimatedCard index={3}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle>Status Timeline</CardTitle>
|
||||||
{hasPassedIntake ? 'Competition Progress' : 'Status Timeline'}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{hasPassedIntake ? (
|
<CompetitionTimelineSidebar />
|
||||||
<CompetitionTimelineSidebar />
|
|
||||||
) : (
|
|
||||||
<StatusTracker
|
|
||||||
timeline={timeline}
|
|
||||||
currentStatus={currentStatus || 'SUBMITTED'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
|
{/* Mentoring Request Card */}
|
||||||
|
{project.isTeamLead && openRounds.filter((r) => r.roundType === 'MENTORING').map((mentoringRound) => (
|
||||||
|
<AnimatedCard key={mentoringRound.id} index={4}>
|
||||||
|
<MentoringRequestCard
|
||||||
|
projectId={project.id}
|
||||||
|
roundId={mentoringRound.id}
|
||||||
|
roundName={mentoringRound.name}
|
||||||
|
/>
|
||||||
|
</AnimatedCard>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Jury Feedback Card */}
|
{/* Jury Feedback Card */}
|
||||||
{totalEvaluations > 0 && (
|
{totalEvaluations > 0 && (
|
||||||
<AnimatedCard index={4}>
|
<AnimatedCard index={4}>
|
||||||
@@ -334,7 +408,9 @@ export default function ApplicantDashboardPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Star className="h-5 w-5" />
|
<div className="rounded-lg bg-yellow-500/10 p-1.5">
|
||||||
|
<Star className="h-4 w-4 text-yellow-500" />
|
||||||
|
</div>
|
||||||
Jury Feedback
|
Jury Feedback
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
@@ -344,17 +420,53 @@ export default function ApplicantDashboardPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
{evaluations?.map((round) => {
|
||||||
{totalEvaluations} evaluation{totalEvaluations !== 1 ? 's' : ''} available from{' '}
|
const scores = round.evaluations
|
||||||
{evaluations?.length ?? 0} round{(evaluations?.length ?? 0) !== 1 ? 's' : ''}.
|
.map((ev) => ev.globalScore)
|
||||||
</p>
|
.filter((s): s is number => s !== null)
|
||||||
|
const avgScore = scores.length > 0
|
||||||
|
? scores.reduce((a, b) => a + b, 0) / scores.length
|
||||||
|
: null
|
||||||
|
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100
|
||||||
|
const pct = avgScore !== null ? (avgScore / maxScore) * 100 : 0
|
||||||
|
const roundIcon = round.roundType === 'LIVE_FINAL'
|
||||||
|
? <Trophy className="h-3.5 w-3.5 text-amber-500" />
|
||||||
|
: round.roundType === 'DELIBERATION'
|
||||||
|
? <Vote className="h-3.5 w-3.5 text-violet-500" />
|
||||||
|
: <Star className="h-3.5 w-3.5 text-yellow-500" />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={round.roundId} className="rounded-lg border p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
{roundIcon}
|
||||||
|
{round.roundName}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{round.evaluationCount} review{round.evaluationCount !== 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{avgScore !== null && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Avg Score</span>
|
||||||
|
<span className="font-semibold text-foreground tabular-nums">
|
||||||
|
{avgScore.toFixed(1)}<span className="text-muted-foreground font-normal"> / {maxScore}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={pct} className="h-1.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Team overview */}
|
{/* Team overview — proper cards */}
|
||||||
<AnimatedCard index={5}>
|
<AnimatedCard index={5}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -370,27 +482,25 @@ export default function ApplicantDashboardPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-2">
|
||||||
{project.teamMembers.length > 0 ? (
|
{project.teamMembers.length > 0 ? (
|
||||||
project.teamMembers.slice(0, 5).map((member) => (
|
project.teamMembers.slice(0, 5).map((member) => (
|
||||||
<div key={member.id} className="flex items-center gap-3">
|
<div key={member.id} className="flex items-center gap-3 rounded-lg border p-2.5">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted shrink-0">
|
||||||
{member.role === 'LEAD' ? (
|
{member.role === 'LEAD' ? (
|
||||||
<Crown className="h-4 w-4 text-yellow-500" />
|
<Crown className="h-4 w-4 text-amber-500" />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs font-medium">
|
<UserCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
{member.user.name?.charAt(0).toUpperCase() || '?'}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium truncate">
|
<p className="text-sm font-medium truncate">
|
||||||
{member.user.name || member.user.email}
|
{member.user.name || member.user.email}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Badge variant="outline" className="shrink-0 text-[10px] px-1.5 py-0">
|
||||||
|
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -460,3 +570,69 @@ export default function ApplicantDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EditableDescription({ projectId, initialDescription }: { projectId: string; initialDescription: string }) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [description, setDescription] = useState(initialDescription)
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const mutation = trpc.applicant.updateDescription.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.applicant.getMyDashboard.invalidate()
|
||||||
|
setIsEditing(false)
|
||||||
|
toast.success('Description updated')
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
mutation.mutate({ projectId, description })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setDescription(initialDescription)
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditing) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Description</p>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs gap-1" onClick={() => setIsEditing(true)}>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap">{initialDescription || <span className="text-muted-foreground italic">No description yet. Click Edit to add one.</span>}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground mb-1">Description</p>
|
||||||
|
<Textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
className="mb-2"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleCancel} disabled={mutation.isPending}>
|
||||||
|
<X className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-3.5 w-3.5 mr-1" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -27,6 +26,7 @@ const ResourceRenderer = dynamic(
|
|||||||
|
|
||||||
export default function ApplicantResourceDetailPage() {
|
export default function ApplicantResourceDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
const resourceId = params.id as string
|
const resourceId = params.id as string
|
||||||
|
|
||||||
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||||
@@ -73,11 +73,9 @@ export default function ApplicantResourceDetailPage() {
|
|||||||
This resource may have been removed or you don't have access.
|
This resource may have been removed or you don't have access.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<Button asChild>
|
<Button onClick={() => router.back()}>
|
||||||
<Link href="/applicant/resources">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Resources
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -87,11 +85,9 @@ export default function ApplicantResourceDetailPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" onClick={() => router.back()} className="-ml-4">
|
||||||
<Link href="/applicant/resources">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Resources
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{resource.externalUrl && (
|
{resource.externalUrl && (
|
||||||
|
|||||||
@@ -46,7 +46,12 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { CountrySelect } from '@/components/ui/country-select'
|
||||||
|
import { Checkbox as CheckboxPrimitive } from '@/components/ui/checkbox'
|
||||||
|
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
||||||
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
import {
|
import {
|
||||||
|
FolderOpen,
|
||||||
Users,
|
Users,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Crown,
|
Crown,
|
||||||
@@ -57,13 +62,26 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
FileText,
|
FileText,
|
||||||
|
ImageIcon,
|
||||||
|
MapPin,
|
||||||
|
Waves,
|
||||||
|
GraduationCap,
|
||||||
|
Heart,
|
||||||
|
Calendar,
|
||||||
|
Pencil,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
|
import { CountryDisplay } from '@/components/shared/country-display'
|
||||||
|
|
||||||
const inviteSchema = z.object({
|
const inviteSchema = z.object({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, 'Name is required'),
|
||||||
email: z.string().email('Invalid email address'),
|
email: z.string().email('Invalid email address'),
|
||||||
role: z.enum(['MEMBER', 'ADVISOR']),
|
role: z.enum(['MEMBER', 'ADVISOR']),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
|
nationality: z.string().optional(),
|
||||||
|
country: z.string().optional(),
|
||||||
|
institution: z.string().optional(),
|
||||||
|
sendInvite: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
type InviteFormData = z.infer<typeof inviteSchema>
|
type InviteFormData = z.infer<typeof inviteSchema>
|
||||||
@@ -80,7 +98,21 @@ const statusLabels: Record<string, { label: string; icon: React.ComponentType<{
|
|||||||
SUSPENDED: { label: 'Suspended', icon: AlertCircle },
|
SUSPENDED: { label: 'Suspended', icon: AlertCircle },
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ApplicantTeamPage() {
|
const OCEAN_ISSUE_LABELS: Record<string, string> = {
|
||||||
|
POLLUTION_REDUCTION: 'Pollution Reduction',
|
||||||
|
CLIMATE_MITIGATION: 'Climate Mitigation',
|
||||||
|
TECHNOLOGY_INNOVATION: 'Technology Innovation',
|
||||||
|
SUSTAINABLE_SHIPPING: 'Sustainable Shipping',
|
||||||
|
BLUE_CARBON: 'Blue Carbon',
|
||||||
|
HABITAT_RESTORATION: 'Habitat Restoration',
|
||||||
|
COMMUNITY_CAPACITY: 'Community Capacity',
|
||||||
|
SUSTAINABLE_FISHING: 'Sustainable Fishing',
|
||||||
|
CONSUMER_AWARENESS: 'Consumer Awareness',
|
||||||
|
OCEAN_ACIDIFICATION: 'Ocean Acidification',
|
||||||
|
OTHER: 'Other',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ApplicantProjectPage() {
|
||||||
const { data: session, status: sessionStatus } = useSession()
|
const { data: session, status: sessionStatus } = useSession()
|
||||||
const isAuthenticated = sessionStatus === 'authenticated'
|
const isAuthenticated = sessionStatus === 'authenticated'
|
||||||
const [isInviteOpen, setIsInviteOpen] = useState(false)
|
const [isInviteOpen, setIsInviteOpen] = useState(false)
|
||||||
@@ -90,13 +122,21 @@ export default function ApplicantTeamPage() {
|
|||||||
{ enabled: isAuthenticated }
|
{ enabled: isAuthenticated }
|
||||||
)
|
)
|
||||||
|
|
||||||
const projectId = dashboardData?.project?.id
|
const project = dashboardData?.project
|
||||||
|
const projectId = project?.id
|
||||||
|
const isIntakeOpen = dashboardData?.isIntakeOpen ?? false
|
||||||
|
const isRejected = dashboardData?.isRejected ?? false
|
||||||
|
|
||||||
const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
|
const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
|
||||||
{ projectId: projectId! },
|
{ projectId: projectId! },
|
||||||
{ enabled: !!projectId }
|
{ enabled: !!projectId }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { data: logoUrl, refetch: refetchLogo } = trpc.applicant.getProjectLogoUrl.useQuery(
|
||||||
|
{ projectId: projectId! },
|
||||||
|
{ enabled: !!projectId }
|
||||||
|
)
|
||||||
|
|
||||||
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
|
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
if (result.requiresAccountSetup) {
|
if (result.requiresAccountSetup) {
|
||||||
@@ -129,6 +169,10 @@ export default function ApplicantTeamPage() {
|
|||||||
email: '',
|
email: '',
|
||||||
role: 'MEMBER',
|
role: 'MEMBER',
|
||||||
title: '',
|
title: '',
|
||||||
|
nationality: '',
|
||||||
|
country: '',
|
||||||
|
institution: '',
|
||||||
|
sendInvite: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -170,18 +214,18 @@ export default function ApplicantTeamPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!projectId) {
|
if (!projectId || !project) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Team</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Project</h1>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||||
<h2 className="text-xl font-semibold mb-2">No Project</h2>
|
<h2 className="text-xl font-semibold mb-2">No Project</h2>
|
||||||
<p className="text-muted-foreground text-center">
|
<p className="text-muted-foreground text-center">
|
||||||
Submit a project first to manage your team.
|
Submit a project first to view details.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -200,123 +244,314 @@ export default function ApplicantTeamPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Project logo — clickable for any team member to change */}
|
||||||
|
<ProjectLogoUpload
|
||||||
|
projectId={projectId}
|
||||||
|
currentLogoUrl={logoUrl}
|
||||||
|
onUploadComplete={() => refetchLogo()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group relative shrink-0 flex flex-col items-center gap-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="relative h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden hover:ring-2 hover:ring-primary/30 transition-all">
|
||||||
|
{logoUrl ? (
|
||||||
|
<img src={logoUrl} alt={project.title} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<FolderOpen className="h-7 w-7 text-muted-foreground/60" />
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
||||||
|
<Pencil className="h-4 w-4 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-primary/70 group-hover:text-primary transition-colors">
|
||||||
|
{logoUrl ? 'Change' : 'Add logo'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</ProjectLogoUpload>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
<Users className="h-6 w-6" />
|
{project.title}
|
||||||
Team Members
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Manage your project team
|
{project.teamName ? `Team: ${project.teamName}` : 'Project details and team management'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isTeamLead && (
|
|
||||||
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button>
|
|
||||||
<UserPlus className="mr-2 h-4 w-4" />
|
|
||||||
Invite Member
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Invite Team Member</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Send an invitation to join your project team. They will receive an email
|
|
||||||
with instructions to create their account.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Full Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
placeholder="Jane Doe"
|
|
||||||
{...form.register('name')}
|
|
||||||
/>
|
|
||||||
{form.formState.errors.name && (
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
{form.formState.errors.name.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">Email Address</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="jane@example.com"
|
|
||||||
{...form.register('email')}
|
|
||||||
/>
|
|
||||||
{form.formState.errors.email && (
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
{form.formState.errors.email.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="role">Role</Label>
|
|
||||||
<Select
|
|
||||||
value={form.watch('role')}
|
|
||||||
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select role" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="MEMBER">Team Member</SelectItem>
|
|
||||||
<SelectItem value="ADVISOR">Advisor</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="title">Title (optional)</Label>
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
placeholder="CTO, Designer..."
|
|
||||||
{...form.register('title')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-muted/50 border p-3 text-sm">
|
|
||||||
<p className="font-medium mb-1">What invited members can do:</p>
|
|
||||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
|
||||||
<li>Upload documents for submission rounds</li>
|
|
||||||
<li>View project status and competition progress</li>
|
|
||||||
<li>Receive email notifications about round updates</li>
|
|
||||||
</ul>
|
|
||||||
<p className="mt-2 text-muted-foreground">Only the Team Lead can invite or remove members.</p>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsInviteOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={inviteMutation.isPending}>
|
|
||||||
{inviteMutation.isPending && (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
Send Invitation
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Project Details Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
Project Information
|
||||||
|
</CardTitle>
|
||||||
|
{isIntakeOpen && (
|
||||||
|
<Badge variant="outline" className="text-amber-600 border-amber-200 bg-amber-50">
|
||||||
|
Editable during intake
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Category & Ocean Issue badges */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{project.competitionCategory && (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<GraduationCap className="h-3 w-3" />
|
||||||
|
{project.competitionCategory === 'STARTUP' ? 'Start-up' : 'Business Concept'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{project.oceanIssue && (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<Waves className="h-3 w-3" />
|
||||||
|
{OCEAN_ISSUE_LABELS[project.oceanIssue] || project.oceanIssue.replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{project.wantsMentorship && (
|
||||||
|
<Badge variant="outline" className="gap-1 text-pink-600 border-pink-200 bg-pink-50">
|
||||||
|
<Heart className="h-3 w-3" />
|
||||||
|
Wants Mentorship
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{project.description && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground mb-1">Description</p>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location, Institution, Founded */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{(project.country || project.geographicZone) && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Location</p>
|
||||||
|
<p className="text-sm">{project.geographicZone}{project.geographicZone && project.country ? ', ' : ''}{project.country ? <CountryDisplay country={project.country} /> : null}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{project.institution && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<GraduationCap className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Institution</p>
|
||||||
|
<p className="text-sm">{project.institution}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{project.foundedAt && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Founded</p>
|
||||||
|
<p className="text-sm">{formatDateOnly(project.foundedAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mentor info */}
|
||||||
|
{project.mentorAssignment?.mentor && (
|
||||||
|
<div className="rounded-lg border p-3 bg-muted/50">
|
||||||
|
<p className="text-sm font-medium mb-1">Assigned Mentor</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{project.mentorAssignment.mentor.name} ({project.mentorAssignment.mentor.email})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{project.tags && project.tags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground mb-1">Tags</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{project.tags.map((tag: string) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-xs">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Project Logo */}
|
||||||
|
{projectId && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="h-5 w-5" />
|
||||||
|
Project Logo
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Click the image to upload or change your project logo.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex justify-center">
|
||||||
|
<ProjectLogoUpload
|
||||||
|
projectId={projectId}
|
||||||
|
currentLogoUrl={logoUrl}
|
||||||
|
onUploadComplete={() => refetchLogo()}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Team Members List */}
|
{/* Team Members List */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Team ({teamData?.teamMembers.length || 0} members)</CardTitle>
|
<div className="flex items-center justify-between">
|
||||||
<CardDescription>
|
<div>
|
||||||
Everyone on this list can view and collaborate on this project.
|
<CardTitle className="flex items-center gap-2">
|
||||||
</CardDescription>
|
<Users className="h-5 w-5" />
|
||||||
|
Team ({teamData?.teamMembers.length || 0} members)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Everyone on this list can view and collaborate on this project.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{isTeamLead && !isRejected && (
|
||||||
|
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm">
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
Invite
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Invite Team Member</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Send an invitation to join your project team. They will receive an email
|
||||||
|
with instructions to create their account.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Full Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="Jane Doe"
|
||||||
|
{...form.register('name')}
|
||||||
|
/>
|
||||||
|
{form.formState.errors.name && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{form.formState.errors.name.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email Address</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="jane@example.com"
|
||||||
|
{...form.register('email')}
|
||||||
|
/>
|
||||||
|
{form.formState.errors.email && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{form.formState.errors.email.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="role">Role</Label>
|
||||||
|
<Select
|
||||||
|
value={form.watch('role')}
|
||||||
|
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="MEMBER">Team Member</SelectItem>
|
||||||
|
<SelectItem value="ADVISOR">Advisor</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Title (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
placeholder="CTO, Designer..."
|
||||||
|
{...form.register('title')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Nationality</Label>
|
||||||
|
<CountrySelect
|
||||||
|
value={form.watch('nationality') || ''}
|
||||||
|
onChange={(v) => form.setValue('nationality', v)}
|
||||||
|
placeholder="Select nationality"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Country of Residence</Label>
|
||||||
|
<CountrySelect
|
||||||
|
value={form.watch('country') || ''}
|
||||||
|
onChange={(v) => form.setValue('country', v)}
|
||||||
|
placeholder="Select country"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="institution">Institution (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="institution"
|
||||||
|
placeholder="e.g., Ocean Research Institute"
|
||||||
|
{...form.register('institution')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckboxPrimitive
|
||||||
|
id="sendInvite"
|
||||||
|
checked={form.watch('sendInvite')}
|
||||||
|
onCheckedChange={(checked) => form.setValue('sendInvite', !!checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="sendInvite" className="text-sm font-normal cursor-pointer">
|
||||||
|
Send platform invite email
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/50 border p-3 text-sm">
|
||||||
|
<p className="font-medium mb-1">What invited members can do:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||||
|
<li>Upload documents for submission rounds</li>
|
||||||
|
<li>View project status and competition progress</li>
|
||||||
|
<li>Receive email notifications about round updates</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-2 text-muted-foreground">Only the Team Lead can invite or remove members.</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsInviteOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={inviteMutation.isPending}>
|
||||||
|
{inviteMutation.isPending && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Send Invitation
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{teamData?.teamMembers.map((member) => {
|
{teamData?.teamMembers.map((member) => {
|
||||||
@@ -328,13 +563,16 @@ export default function ApplicantTeamPage() {
|
|||||||
className="flex items-center justify-between rounded-lg border p-4"
|
className="flex items-center justify-between rounded-lg border p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
<div className="relative">
|
||||||
{member.role === 'LEAD' ? (
|
<UserAvatar
|
||||||
<Crown className="h-5 w-5 text-yellow-500" />
|
user={member.user}
|
||||||
) : (
|
avatarUrl={teamData?.avatarUrls?.[member.userId] || null}
|
||||||
<span className="text-sm font-medium">
|
size="md"
|
||||||
{member.user.name?.charAt(0).toUpperCase() || '?'}
|
/>
|
||||||
</span>
|
{member.role === 'LEAD' && (
|
||||||
|
<div className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-yellow-100 ring-2 ring-white">
|
||||||
|
<Crown className="h-2.5 w-2.5 text-yellow-600" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -360,7 +598,7 @@ export default function ApplicantTeamPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isTeamLead && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (
|
{isTeamLead && !isRejected && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="text-destructive">
|
<Button variant="ghost" size="icon" className="text-destructive">
|
||||||
@@ -409,25 +647,6 @@ export default function ApplicantTeamPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Team Documents - visible via applicant documents page */}
|
|
||||||
|
|
||||||
{/* Info Card */}
|
|
||||||
<Card className="bg-muted/50">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
<p className="font-medium text-foreground">About Team Access</p>
|
|
||||||
<p className="mt-1">
|
|
||||||
All team members can view project details and status updates.
|
|
||||||
Only the team lead can invite or remove team members.
|
|
||||||
Invited members will receive an email to set up their account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { auth } from '@/lib/auth'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { requireRole } from '@/lib/auth-redirect'
|
||||||
import { ApplicantNav } from '@/components/layouts/applicant-nav'
|
import { ApplicantNav } from '@/components/layouts/applicant-nav'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -9,14 +10,23 @@ export default async function ApplicantLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const session = await auth()
|
const session = await requireRole('APPLICANT')
|
||||||
|
const isImpersonating = !!session.user.impersonating
|
||||||
|
|
||||||
if (!session?.user) {
|
// Check if user has completed onboarding (skip during impersonation)
|
||||||
redirect('/login')
|
if (!isImpersonating) {
|
||||||
}
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { onboardingCompletedAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
if (session.user.role !== 'APPLICANT') {
|
if (!user) {
|
||||||
redirect('/login')
|
redirect('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.onboardingCompletedAt) {
|
||||||
|
redirect('/onboarding')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,11 +12,58 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock } from 'lucide-react'
|
import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock } from 'lucide-react'
|
||||||
|
import Image from 'next/image'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
|
||||||
type InviteState = 'loading' | 'valid' | 'accepting' | 'error'
|
type InviteState = 'loading' | 'valid' | 'accepting' | 'error'
|
||||||
|
|
||||||
|
function ErrorRedirectCard({
|
||||||
|
errorContent,
|
||||||
|
redirectTarget,
|
||||||
|
}: {
|
||||||
|
errorContent: { icon: React.ReactNode; title: string; description: string; redirect?: string }
|
||||||
|
redirectTarget: string
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
router.push(redirectTarget)
|
||||||
|
}, 4000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [redirectTarget, router])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedCard>
|
||||||
|
<Card className="w-full max-w-md overflow-hidden">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100">
|
||||||
|
{errorContent.icon}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">{errorContent.title}</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
{errorContent.description}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => router.push(redirectTarget)}
|
||||||
|
>
|
||||||
|
Go to Login
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-center text-muted-foreground">
|
||||||
|
Redirecting to login in a few seconds...
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function AcceptInviteContent() {
|
function AcceptInviteContent() {
|
||||||
const [state, setState] = useState<InviteState>('loading')
|
const [state, setState] = useState<InviteState>('loading')
|
||||||
const [errorType, setErrorType] = useState<string | null>(null)
|
const [errorType, setErrorType] = useState<string | null>(null)
|
||||||
@@ -104,18 +151,21 @@ function AcceptInviteContent() {
|
|||||||
icon: <XCircle className="h-6 w-6 text-red-600" />,
|
icon: <XCircle className="h-6 w-6 text-red-600" />,
|
||||||
title: 'Invalid Invitation',
|
title: 'Invalid Invitation',
|
||||||
description: 'This invitation link is not valid. It may have already been used or the link is incorrect.',
|
description: 'This invitation link is not valid. It may have already been used or the link is incorrect.',
|
||||||
|
redirect: '/login?expired=1',
|
||||||
}
|
}
|
||||||
case 'EXPIRED_TOKEN':
|
case 'EXPIRED_TOKEN':
|
||||||
return {
|
return {
|
||||||
icon: <Clock className="h-6 w-6 text-amber-600" />,
|
icon: <Clock className="h-6 w-6 text-amber-600" />,
|
||||||
title: 'Invitation Expired',
|
title: 'Invitation Expired',
|
||||||
description: 'This invitation has expired. Please contact your administrator to receive a new invitation.',
|
description: 'This invitation has expired. Please contact your administrator to receive a new invitation.',
|
||||||
|
redirect: '/login?expired=1',
|
||||||
}
|
}
|
||||||
case 'ALREADY_ACCEPTED':
|
case 'ALREADY_ACCEPTED':
|
||||||
return {
|
return {
|
||||||
icon: <CheckCircle2 className="h-6 w-6 text-blue-600" />,
|
icon: <CheckCircle2 className="h-6 w-6 text-blue-600" />,
|
||||||
title: 'Already Accepted',
|
title: 'Already Accepted',
|
||||||
description: 'This invitation has already been accepted. You can sign in with your credentials.',
|
description: 'This invitation has already been accepted. You can sign in with your credentials.',
|
||||||
|
redirect: '/login',
|
||||||
}
|
}
|
||||||
case 'AUTH_FAILED':
|
case 'AUTH_FAILED':
|
||||||
return {
|
return {
|
||||||
@@ -147,45 +197,24 @@ function AcceptInviteContent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error state
|
// Error state — auto-redirect to login after 4 seconds for known errors
|
||||||
if (state === 'error') {
|
if (state === 'error') {
|
||||||
const errorContent = getErrorContent()
|
const errorContent = getErrorContent()
|
||||||
return (
|
const redirectTarget = errorContent.redirect || '/login'
|
||||||
<AnimatedCard>
|
|
||||||
<Card className="w-full max-w-md overflow-hidden">
|
return <ErrorRedirectCard errorContent={errorContent} redirectTarget={redirectTarget} />
|
||||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
|
||||||
<CardHeader className="text-center">
|
|
||||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100">
|
|
||||||
{errorContent.icon}
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-xl">{errorContent.title}</CardTitle>
|
|
||||||
<CardDescription className="text-base">
|
|
||||||
{errorContent.description}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => router.push('/login')}
|
|
||||||
>
|
|
||||||
Go to Login
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valid invitation - show welcome
|
// Valid invitation - show welcome
|
||||||
const user = data?.user
|
const user = data?.user
|
||||||
|
const team = data?.team
|
||||||
return (
|
return (
|
||||||
<AnimatedCard>
|
<AnimatedCard>
|
||||||
<Card className="w-full max-w-md overflow-hidden">
|
<Card className="w-full max-w-md overflow-hidden">
|
||||||
<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="text-center">
|
<CardHeader className="text-center">
|
||||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50">
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-white shadow-sm border">
|
||||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
<Image src="/images/MOPC-blue-small.png" alt="MOPC" width={32} height={32} className="object-contain" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-xl">
|
<CardTitle className="text-xl">
|
||||||
{user?.name ? `Welcome, ${user.name}!` : 'Welcome!'}
|
{user?.name ? `Welcome, ${user.name}!` : 'Welcome!'}
|
||||||
@@ -196,6 +225,14 @@ function AcceptInviteContent() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{team?.projectTitle && (
|
||||||
|
<div className="rounded-md border border-blue-200 bg-blue-50 p-3 text-center">
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
You've been invited to join the team for
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-blue-900">“{team.projectTitle}”</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{user?.email && (
|
{user?.email && (
|
||||||
<div className="rounded-md bg-muted/50 p-3 text-center">
|
<div className="rounded-md bg-muted/50 p-3 text-center">
|
||||||
<p className="text-sm text-muted-foreground">Signing in as</p>
|
<p className="text-sm text-muted-foreground">Signing in as</p>
|
||||||
|
|||||||
@@ -1,50 +1,96 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
|
import { useEffect, Suspense } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Logo } from '@/components/shared/logo'
|
import { Logo } from '@/components/shared/logo'
|
||||||
import { AlertCircle } from 'lucide-react'
|
import { AlertCircle, Clock, Loader2 } from 'lucide-react'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
|
||||||
const errorMessages: Record<string, string> = {
|
const errorMessages: Record<string, string> = {
|
||||||
Configuration: 'There is a problem with the server configuration.',
|
Configuration: 'There is a problem with the server configuration.',
|
||||||
AccessDenied: 'You do not have access to this resource.',
|
AccessDenied: 'You do not have access to this resource.',
|
||||||
Verification: 'The verification link has expired or already been used.',
|
Verification: 'This sign-in link has expired or has already been used. Please request a new one.',
|
||||||
Default: 'An error occurred during authentication.',
|
Default: 'An error occurred during authentication.',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuthErrorPage() {
|
function AuthErrorContent() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
const error = searchParams.get('error') || 'Default'
|
const error = searchParams.get('error') || 'Default'
|
||||||
const message = errorMessages[error] || errorMessages.Default
|
const message = errorMessages[error] || errorMessages.Default
|
||||||
|
const isExpired = error === 'Verification'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isExpired) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
router.push('/login?expired=1')
|
||||||
|
}, 5000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [isExpired, router])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedCard>
|
<AnimatedCard>
|
||||||
<Card className="w-full max-w-md overflow-hidden">
|
<Card className="w-full max-w-md overflow-hidden">
|
||||||
<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="text-center">
|
<CardHeader className="text-center">
|
||||||
<div className="mx-auto mb-4">
|
<div className="mx-auto mb-4">
|
||||||
<Logo variant="small" />
|
<Logo variant="small" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10">
|
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10">
|
||||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
{isExpired ? (
|
||||||
</div>
|
<Clock className="h-6 w-6 text-amber-600" />
|
||||||
<CardTitle className="text-xl">Authentication Error</CardTitle>
|
) : (
|
||||||
</CardHeader>
|
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||||
<CardContent className="space-y-4 text-center">
|
)}
|
||||||
<p className="text-muted-foreground">{message}</p>
|
</div>
|
||||||
<div className="flex gap-3 justify-center border-t pt-4">
|
<CardTitle className="text-xl">
|
||||||
<Button asChild>
|
{isExpired ? 'Link Expired' : 'Authentication Error'}
|
||||||
<Link href="/login">Return to Login</Link>
|
</CardTitle>
|
||||||
</Button>
|
</CardHeader>
|
||||||
<Button variant="outline" asChild>
|
<CardContent className="space-y-4 text-center">
|
||||||
<Link href="/">Home</Link>
|
<p className="text-muted-foreground">{message}</p>
|
||||||
</Button>
|
{isExpired && (
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">
|
||||||
</CardContent>
|
Redirecting to login in 5 seconds...
|
||||||
</Card>
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 justify-center border-t pt-4">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/login">
|
||||||
|
{isExpired ? 'Sign In Again' : 'Return to Login'}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{!isExpired && (
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/">Home</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function AuthErrorPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<AnimatedCard>
|
||||||
|
<Card className="w-full max-w-md overflow-hidden">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AuthErrorContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
144
src/app/(auth)/forgot-password/page.tsx
Normal file
144
src/app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Mail, Loader2, CheckCircle2, AlertCircle, ArrowLeft } from 'lucide-react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [isSent, setIsSent] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const requestReset = trpc.user.requestPasswordReset.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsSent(true)
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err.message || 'Something went wrong. Please try again.')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
requestReset.mutate({ email: email.trim() })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSent) {
|
||||||
|
return (
|
||||||
|
<AnimatedCard>
|
||||||
|
<Card className="w-full max-w-md overflow-hidden">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-50 animate-in zoom-in-50 duration-300">
|
||||||
|
<CheckCircle2 className="h-8 w-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">Check your email</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
If an account exists for <strong>{email}</strong>, we've sent a password reset link.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="rounded-lg border bg-muted/50 p-4 text-sm text-muted-foreground space-y-2">
|
||||||
|
<p>Click the link in the email to reset your password. The link will expire in 30 minutes.</p>
|
||||||
|
<p>If you don't see it, check your spam folder.</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-t pt-4 space-y-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
setIsSent(false)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try a different email
|
||||||
|
</Button>
|
||||||
|
<div className="text-center">
|
||||||
|
<Link href="/login" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||||
|
<ArrowLeft className="inline h-3.5 w-3.5 mr-1" />
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedCard>
|
||||||
|
<Card className="w-full max-w-md overflow-hidden">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-muted">
|
||||||
|
<Mail className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">Reset your password</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your email address and we'll send you a link to reset your password.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email address</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={requestReset.isPending}
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={requestReset.isPending || !email.trim()}>
|
||||||
|
{requestReset.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
|
Send reset link
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center pt-2">
|
||||||
|
<Link href="/login" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||||
|
<ArrowLeft className="inline h-3.5 w-3.5 mr-1" />
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,22 +19,30 @@ export default async function AuthLayout({
|
|||||||
// Redirect logged-in users to their dashboard
|
// Redirect logged-in users to their dashboard
|
||||||
// But NOT if they still need to set their password
|
// But NOT if they still need to set their password
|
||||||
if (session?.user && !session.user.mustSetPassword) {
|
if (session?.user && !session.user.mustSetPassword) {
|
||||||
// Verify user still exists in DB (handles deleted accounts with stale sessions)
|
// Verify user still exists in DB and check onboarding status
|
||||||
const dbUser = await prisma.user.findUnique({
|
const dbUser = await prisma.user.findUnique({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
select: { id: true },
|
select: { id: true, onboardingCompletedAt: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (dbUser) {
|
if (dbUser) {
|
||||||
const role = session.user.role
|
// If user hasn't completed onboarding, don't redirect away from auth pages.
|
||||||
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
|
// The /onboarding page lives in this (auth) layout, so they need to stay here.
|
||||||
redirect('/admin')
|
if (!dbUser.onboardingCompletedAt) {
|
||||||
} else if (role === 'JURY_MEMBER') {
|
// Fall through — let them access /onboarding (and other auth pages)
|
||||||
redirect('/jury')
|
} else {
|
||||||
} else if (role === 'OBSERVER') {
|
const role = session.user.role
|
||||||
redirect('/observer')
|
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
|
||||||
} else if (role === 'MENTOR') {
|
redirect('/admin')
|
||||||
redirect('/mentor')
|
} else if (role === 'JURY_MEMBER') {
|
||||||
|
redirect('/jury')
|
||||||
|
} else if (role === 'OBSERVER') {
|
||||||
|
redirect('/observer')
|
||||||
|
} else if (role === 'MENTOR') {
|
||||||
|
redirect('/mentor')
|
||||||
|
} else if (role === 'APPLICANT') {
|
||||||
|
redirect('/applicant')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If user doesn't exist in DB, fall through and show auth page
|
// If user doesn't exist in DB, fall through and show auth page
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import type { Route } from 'next'
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import { signIn } from 'next-auth/react'
|
import { signIn } from 'next-auth/react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import Link from 'next/link'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
@@ -13,7 +16,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Mail, Loader2, CheckCircle2, AlertCircle, Lock, KeyRound } from 'lucide-react'
|
import { Mail, Loader2, CheckCircle2, AlertCircle, Lock, KeyRound, Clock } from 'lucide-react'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
|
||||||
type LoginMode = 'password' | 'magic-link'
|
type LoginMode = 'password' | 'magic-link'
|
||||||
@@ -30,6 +33,7 @@ export default function LoginPage() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const callbackUrl = searchParams.get('callbackUrl') || '/'
|
const callbackUrl = searchParams.get('callbackUrl') || '/'
|
||||||
const errorParam = searchParams.get('error')
|
const errorParam = searchParams.get('error')
|
||||||
|
const isExpiredLink = searchParams.get('expired') === '1'
|
||||||
|
|
||||||
const handlePasswordLogin = async (e: React.FormEvent) => {
|
const handlePasswordLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -67,6 +71,19 @@ export default function LoginPage() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Pre-check: does this email exist?
|
||||||
|
const checkRes = await fetch('/api/auth/check-email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
})
|
||||||
|
const checkData = await checkRes.json()
|
||||||
|
if (!checkData.exists) {
|
||||||
|
setError('No account found with this email address. Please check the email you used to sign up, or contact the administrator.')
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Get CSRF token first
|
// Get CSRF token first
|
||||||
const csrfRes = await fetch('/api/auth/csrf')
|
const csrfRes = await fetch('/api/auth/csrf')
|
||||||
const { csrfToken } = await csrfRes.json()
|
const { csrfToken } = await csrfRes.json()
|
||||||
@@ -149,6 +166,15 @@ export default function LoginPage() {
|
|||||||
<Card className="w-full max-w-md overflow-hidden">
|
<Card className="w-full max-w-md overflow-hidden">
|
||||||
<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="text-center">
|
<CardHeader className="text-center">
|
||||||
|
<div className="flex justify-center mb-2">
|
||||||
|
<Image
|
||||||
|
src="/images/MOPC-blue-small.png"
|
||||||
|
alt="MOPC"
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="h-12 w-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<CardTitle className="text-2xl">Welcome back</CardTitle>
|
<CardTitle className="text-2xl">Welcome back</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{mode === 'password'
|
{mode === 'password'
|
||||||
@@ -157,6 +183,17 @@ export default function LoginPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{isExpiredLink && (
|
||||||
|
<div className="mb-4 flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm">
|
||||||
|
<Clock className="h-4 w-4 mt-0.5 text-amber-600 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-amber-900">Your link has expired</p>
|
||||||
|
<p className="text-amber-700 mt-0.5">
|
||||||
|
Sign-in links expire after 15 minutes for security. Please sign in below or request a new magic link.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{mode === 'password' ? (
|
{mode === 'password' ? (
|
||||||
// Password login form
|
// Password login form
|
||||||
<form onSubmit={handlePasswordLogin} className="space-y-4">
|
<form onSubmit={handlePasswordLogin} className="space-y-4">
|
||||||
@@ -192,16 +229,12 @@ export default function LoginPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<button
|
<Link
|
||||||
type="button"
|
href={'/forgot-password' as Route}
|
||||||
className="text-sm text-muted-foreground hover:text-primary transition-colors"
|
className="text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
onClick={() => {
|
|
||||||
setMode('magic-link')
|
|
||||||
setError(null)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
@@ -302,6 +335,12 @@ export default function LoginPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground/70 text-center">
|
||||||
|
Don't remember which email you used?{' '}
|
||||||
|
<a href="mailto:contact@monaco-opc.com" className="underline hover:text-primary transition-colors">
|
||||||
|
Contact the MOPC team
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
691
src/app/(auth)/onboarding/applicant-wizard.tsx
Normal file
691
src/app/(auth)/onboarding/applicant-wizard.tsx
Normal file
@@ -0,0 +1,691 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Image from 'next/image'
|
||||||
|
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 { CountrySelect } from '@/components/ui/country-select'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { AvatarUpload } from '@/components/shared/avatar-upload'
|
||||||
|
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
Bell,
|
||||||
|
CheckCircle,
|
||||||
|
Loader2,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
|
Camera,
|
||||||
|
Globe,
|
||||||
|
FileText,
|
||||||
|
Building2,
|
||||||
|
Flag,
|
||||||
|
ImageIcon,
|
||||||
|
Compass,
|
||||||
|
LayoutDashboard,
|
||||||
|
Upload,
|
||||||
|
ClipboardList,
|
||||||
|
Users,
|
||||||
|
Trophy,
|
||||||
|
BookOpen,
|
||||||
|
GraduationCap,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
|
|
||||||
|
type Step =
|
||||||
|
| 'name'
|
||||||
|
| 'photo'
|
||||||
|
| 'nationality'
|
||||||
|
| 'country'
|
||||||
|
| 'institution'
|
||||||
|
| 'bio'
|
||||||
|
| 'logo'
|
||||||
|
| 'preferences'
|
||||||
|
| 'guide'
|
||||||
|
| 'complete'
|
||||||
|
|
||||||
|
type ApplicantWizardProps = {
|
||||||
|
userData: {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
email: string
|
||||||
|
role: string
|
||||||
|
country: string | null
|
||||||
|
nationality: string | null
|
||||||
|
institution: string | null
|
||||||
|
bio: string | null
|
||||||
|
profileImageKey: string | null
|
||||||
|
notificationPreference: string
|
||||||
|
}
|
||||||
|
avatarUrl: string | null | undefined
|
||||||
|
refetchUser: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApplicantOnboardingWizard({
|
||||||
|
userData,
|
||||||
|
avatarUrl,
|
||||||
|
refetchUser,
|
||||||
|
}: ApplicantWizardProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [step, setStep] = useState<Step>('name')
|
||||||
|
const [initialized, setInitialized] = useState(false)
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [nationality, setNationality] = useState('')
|
||||||
|
const [country, setCountry] = useState('')
|
||||||
|
const [institution, setInstitution] = useState('')
|
||||||
|
const [bio, setBio] = useState('')
|
||||||
|
const [notificationPreference, setNotificationPreference] = useState<
|
||||||
|
'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE'
|
||||||
|
>('EMAIL')
|
||||||
|
|
||||||
|
// Fetch onboarding context (project info)
|
||||||
|
const { data: onboardingCtx } = trpc.applicant.getOnboardingContext.useQuery()
|
||||||
|
const { data: logoUrl, refetch: refetchLogo } = trpc.applicant.getProjectLogoUrl.useQuery(
|
||||||
|
{ projectId: onboardingCtx?.projectId ?? '' },
|
||||||
|
{ enabled: !!onboardingCtx?.projectId }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize form with user data
|
||||||
|
useEffect(() => {
|
||||||
|
if (userData && !initialized) {
|
||||||
|
if (userData.name) setName(userData.name)
|
||||||
|
if (userData.country) setCountry(userData.country)
|
||||||
|
if (userData.nationality) setNationality(userData.nationality)
|
||||||
|
if (userData.institution) setInstitution(userData.institution)
|
||||||
|
if (userData.bio) setBio(userData.bio)
|
||||||
|
if (userData.notificationPreference) {
|
||||||
|
setNotificationPreference(userData.notificationPreference as typeof notificationPreference)
|
||||||
|
}
|
||||||
|
setInitialized(true)
|
||||||
|
}
|
||||||
|
}, [userData, initialized])
|
||||||
|
|
||||||
|
// Prefill institution from project if user hasn't set one
|
||||||
|
useEffect(() => {
|
||||||
|
if (onboardingCtx?.institution && !institution && initialized) {
|
||||||
|
setInstitution(onboardingCtx.institution)
|
||||||
|
}
|
||||||
|
}, [onboardingCtx, institution, initialized])
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const completeOnboarding = trpc.user.completeOnboarding.useMutation({
|
||||||
|
onSuccess: () => utils.user.me.invalidate(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const steps: Step[] = useMemo(() => {
|
||||||
|
const base: Step[] = [
|
||||||
|
'name',
|
||||||
|
'photo',
|
||||||
|
'nationality',
|
||||||
|
'country',
|
||||||
|
'institution',
|
||||||
|
'bio',
|
||||||
|
]
|
||||||
|
// Only show logo step if applicant has a project
|
||||||
|
if (onboardingCtx?.projectId) {
|
||||||
|
base.push('logo')
|
||||||
|
}
|
||||||
|
base.push('preferences', 'guide', 'complete')
|
||||||
|
return base
|
||||||
|
}, [onboardingCtx?.projectId])
|
||||||
|
|
||||||
|
const currentIndex = steps.indexOf(step)
|
||||||
|
const totalVisibleSteps = steps.length - 1
|
||||||
|
|
||||||
|
const goNext = () => {
|
||||||
|
if (step === 'name' && !name.trim()) {
|
||||||
|
toast.error('Please enter your name')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextIndex = currentIndex + 1
|
||||||
|
if (nextIndex < steps.length) {
|
||||||
|
setStep(steps[nextIndex])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
const prevIndex = currentIndex - 1
|
||||||
|
if (prevIndex >= 0) {
|
||||||
|
setStep(steps[prevIndex])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleComplete = async () => {
|
||||||
|
try {
|
||||||
|
await completeOnboarding.mutateAsync({
|
||||||
|
name,
|
||||||
|
country: country || undefined,
|
||||||
|
nationality: nationality || undefined,
|
||||||
|
institution: institution || undefined,
|
||||||
|
bio: bio || undefined,
|
||||||
|
notificationPreference,
|
||||||
|
})
|
||||||
|
setStep('complete')
|
||||||
|
toast.success('Welcome to MOPC!')
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/applicant')
|
||||||
|
}, 2000)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to complete onboarding')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepLabels: Record<Step, string> = {
|
||||||
|
name: 'Name',
|
||||||
|
photo: 'Photo',
|
||||||
|
nationality: 'Nationality',
|
||||||
|
country: 'Residence',
|
||||||
|
institution: 'Institution',
|
||||||
|
bio: 'About',
|
||||||
|
logo: 'Logo',
|
||||||
|
preferences: 'Settings',
|
||||||
|
guide: 'Guide',
|
||||||
|
complete: 'Done',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-[#053d57] bg-[url('https://s3.monaco-opc.com/public/ocean.png')] bg-cover bg-center bg-no-repeat">
|
||||||
|
<AnimatedCard>
|
||||||
|
<Card className="w-full max-w-lg max-h-[85vh] overflow-y-auto overflow-x-hidden shadow-2xl">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||||
|
|
||||||
|
{/* Progress indicator */}
|
||||||
|
{step !== 'complete' && (
|
||||||
|
<div className="px-6 pt-6">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{steps.slice(0, -1).map((s, i) => (
|
||||||
|
<div key={s} className="flex-1 flex flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
className={`h-2 w-full rounded-full transition-colors ${
|
||||||
|
i < currentIndex
|
||||||
|
? 'bg-primary'
|
||||||
|
: i === currentIndex
|
||||||
|
? 'bg-primary/60'
|
||||||
|
: 'bg-muted'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] leading-none',
|
||||||
|
i <= currentIndex ? 'text-primary font-medium' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{stepLabels[s]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Step {currentIndex + 1} of {totalVisibleSteps}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Name */}
|
||||||
|
{step === 'name' && (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<User className="h-5 w-5 text-primary" />
|
||||||
|
Welcome to MOPC
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Let's get your profile set up. What should we call you?
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Full Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Enter your full name"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={goNext} className="w-full" disabled={!name.trim()}>
|
||||||
|
Continue
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Profile Photo */}
|
||||||
|
{step === 'photo' && (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Camera className="h-5 w-5 text-primary" />
|
||||||
|
Profile Photo
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Add a profile photo so others can recognize you. This step is optional.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<AvatarUpload
|
||||||
|
user={{
|
||||||
|
name: userData?.name,
|
||||||
|
email: userData?.email,
|
||||||
|
profileImageKey: userData?.profileImageKey,
|
||||||
|
}}
|
||||||
|
currentAvatarUrl={avatarUrl}
|
||||||
|
onUploadComplete={() => refetchUser()}
|
||||||
|
>
|
||||||
|
<div className="cursor-pointer">
|
||||||
|
<UserAvatar
|
||||||
|
user={{ name: userData?.name, email: userData?.email }}
|
||||||
|
avatarUrl={avatarUrl}
|
||||||
|
size="2xl"
|
||||||
|
showEditOverlay
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AvatarUpload>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Click the avatar to upload a new photo.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={goBack} className="flex-1">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={goNext} className="flex-1">
|
||||||
|
{avatarUrl ? 'Continue' : 'Skip for now'}
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Nationality */}
|
||||||
|
{step === 'nationality' && (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Flag className="h-5 w-5 text-primary" />
|
||||||
|
Nationality
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Select your nationality.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nationality">Nationality</Label>
|
||||||
|
<CountrySelect
|
||||||
|
value={nationality}
|
||||||
|
onChange={setNationality}
|
||||||
|
placeholder="Select your nationality"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={goBack} className="flex-1">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={goNext} className="flex-1">
|
||||||
|
{nationality ? 'Continue' : 'Skip for now'}
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Country of Residence */}
|
||||||
|
{step === 'country' && (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Globe className="h-5 w-5 text-primary" />
|
||||||
|
Country of Residence
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Where are you currently based?
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="country">Country</Label>
|
||||||
|
<CountrySelect
|
||||||
|
value={country}
|
||||||
|
onChange={setCountry}
|
||||||
|
placeholder="Select your country"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={goBack} className="flex-1">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={goNext} className="flex-1">
|
||||||
|
{country ? 'Continue' : 'Skip for now'}
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Institution */}
|
||||||
|
{step === 'institution' && (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-5 w-5 text-primary" />
|
||||||
|
Institution
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Your organization or institution name.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="institution">Institution / Organization</Label>
|
||||||
|
<Input
|
||||||
|
id="institution"
|
||||||
|
value={institution}
|
||||||
|
onChange={(e) => setInstitution(e.target.value)}
|
||||||
|
placeholder="e.g., Ocean Research Institute"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={goBack} className="flex-1">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={goNext} className="flex-1">
|
||||||
|
{institution ? 'Continue' : 'Skip for now'}
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Bio */}
|
||||||
|
{step === 'bio' && (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5 text-primary" />
|
||||||
|
About You
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Tell us a bit about yourself and your work. (Optional)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bio">Bio</Label>
|
||||||
|
<Textarea
|
||||||
|
id="bio"
|
||||||
|
value={bio}
|
||||||
|
onChange={(e) => setBio(e.target.value)}
|
||||||
|
placeholder="e.g., Marine biologist working on coral reef conservation..."
|
||||||
|
rows={4}
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground text-right">
|
||||||
|
{bio.length}/500 characters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={goBack} className="flex-1">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={goNext} className="flex-1">
|
||||||
|
{bio ? 'Continue' : 'Skip for now'}
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Project Logo */}
|
||||||
|
{step === 'logo' && onboardingCtx?.projectId && (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="h-5 w-5 text-primary" />
|
||||||
|
Project Logo
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Upload a logo for "{onboardingCtx.projectTitle}". This step is optional.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<ProjectLogoUpload
|
||||||
|
projectId={onboardingCtx.projectId}
|
||||||
|
currentLogoUrl={logoUrl}
|
||||||
|
onUploadComplete={() => refetchLogo()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Click the image area to upload a logo.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={goBack} className="flex-1">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={goNext} className="flex-1">
|
||||||
|
{logoUrl ? 'Continue' : 'Skip for now'}
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Notification Preferences */}
|
||||||
|
{step === 'preferences' && (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Bell className="h-5 w-5 text-primary" />
|
||||||
|
Notification Preferences
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
How would you like to receive notifications?
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="notifications">Notification Channel</Label>
|
||||||
|
<Select
|
||||||
|
value={notificationPreference}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setNotificationPreference(v as typeof notificationPreference)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="notifications">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="EMAIL">Email only</SelectItem>
|
||||||
|
<SelectItem value="NONE">No notifications</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-4 bg-muted/50">
|
||||||
|
<h4 className="font-medium mb-2">Summary</h4>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Name:</span> {name}
|
||||||
|
</p>
|
||||||
|
{nationality && (
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Nationality:</span> {nationality}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{country && (
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Country:</span> {country}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{institution && (
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Institution:</span> {institution}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{bio && (
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Bio:</span>{' '}
|
||||||
|
{bio.length > 50 ? `${bio.substring(0, 50)}...` : bio}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={goBack} className="flex-1">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={goNext} className="flex-1">
|
||||||
|
Continue
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Portal Guide */}
|
||||||
|
{step === 'guide' && (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Compass className="h-5 w-5 text-primary" />
|
||||||
|
Your Applicant Portal
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Here's what you can do through the MOPC Applicant Portal.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
title: 'Dashboard',
|
||||||
|
desc: 'Overview of your project status, team, and upcoming deadlines.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Upload,
|
||||||
|
title: 'Documents',
|
||||||
|
desc: 'Upload required files for each round and track submission progress.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ClipboardList,
|
||||||
|
title: 'Evaluations',
|
||||||
|
desc: 'View anonymized jury feedback and scores for your project.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
title: 'Team',
|
||||||
|
desc: 'Manage your team members, invite collaborators, and update your project logo.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Trophy,
|
||||||
|
title: 'Competition',
|
||||||
|
desc: 'Track your progress through competition rounds and milestones.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: GraduationCap,
|
||||||
|
title: 'Mentorship',
|
||||||
|
desc: 'Connect with your assigned mentor for guidance and support.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: BookOpen,
|
||||||
|
title: 'Resources',
|
||||||
|
desc: 'Access helpful materials, guides, and competition resources.',
|
||||||
|
},
|
||||||
|
].map(({ icon: Icon, title, desc }) => (
|
||||||
|
<div key={title} className="flex items-start gap-3 rounded-lg border p-3">
|
||||||
|
<div className="rounded-md bg-primary/10 p-2 shrink-0">
|
||||||
|
<Icon className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={goBack} className="flex-1">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleComplete}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={completeOnboarding.isPending}
|
||||||
|
>
|
||||||
|
{completeOnboarding.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Complete Setup
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Complete */}
|
||||||
|
{step === 'complete' && (
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<div className="mb-4 animate-in zoom-in-50 duration-500">
|
||||||
|
<Image src="/images/MOPC-blue-small.png" alt="MOPC Logo" width={64} height={64} className="h-16 w-16" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2 animate-in fade-in slide-in-from-bottom-2 duration-500 delay-200">
|
||||||
|
Welcome, {name}!
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground text-center mb-4">
|
||||||
|
Your profile is all set up. You'll be redirected to your dashboard shortly.
|
||||||
|
</p>
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
Scale,
|
Scale,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
import { ApplicantOnboardingWizard } from './applicant-wizard'
|
||||||
|
|
||||||
type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'jury' | 'preferences' | 'complete'
|
type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'jury' | 'preferences' | 'complete'
|
||||||
|
|
||||||
@@ -208,6 +209,8 @@ export default function OnboardingPage() {
|
|||||||
router.push('/mentor')
|
router.push('/mentor')
|
||||||
} else if (role === 'OBSERVER') {
|
} else if (role === 'OBSERVER') {
|
||||||
router.push('/observer')
|
router.push('/observer')
|
||||||
|
} else if (role === 'APPLICANT') {
|
||||||
|
router.push('/applicant')
|
||||||
} else {
|
} else {
|
||||||
router.push('/jury')
|
router.push('/jury')
|
||||||
}
|
}
|
||||||
@@ -234,6 +237,17 @@ export default function OnboardingPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Applicant users get a dedicated onboarding wizard
|
||||||
|
if (userData?.role === 'APPLICANT') {
|
||||||
|
return (
|
||||||
|
<ApplicantOnboardingWizard
|
||||||
|
userData={userData as unknown as Parameters<typeof ApplicantOnboardingWizard>[0]['userData']}
|
||||||
|
avatarUrl={avatarUrl}
|
||||||
|
refetchUser={refetchUser}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-[#053d57] bg-[url('https://s3.monaco-opc.com/public/ocean.png')] bg-cover bg-center bg-no-repeat">
|
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-[#053d57] bg-[url('https://s3.monaco-opc.com/public/ocean.png')] bg-cover bg-center bg-no-repeat">
|
||||||
<AnimatedCard>
|
<AnimatedCard>
|
||||||
|
|||||||
278
src/app/(auth)/reset-password/page.tsx
Normal file
278
src/app/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import { Lock, Loader2, CheckCircle2, AlertCircle, Eye, EyeOff, ArrowLeft } from 'lucide-react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const token = searchParams.get('token')
|
||||||
|
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isSuccess, setIsSuccess] = useState(false)
|
||||||
|
|
||||||
|
const resetPassword = trpc.user.resetPassword.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsSuccess(true)
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err.message || 'Failed to reset password. Please try again.')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Password validation
|
||||||
|
const validatePassword = (pwd: string) => {
|
||||||
|
const errors: string[] = []
|
||||||
|
if (pwd.length < 8) errors.push('At least 8 characters')
|
||||||
|
if (!/[A-Z]/.test(pwd)) errors.push('One uppercase letter')
|
||||||
|
if (!/[a-z]/.test(pwd)) errors.push('One lowercase letter')
|
||||||
|
if (!/[0-9]/.test(pwd)) errors.push('One number')
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordErrors = validatePassword(password)
|
||||||
|
const isPasswordValid = passwordErrors.length === 0
|
||||||
|
const doPasswordsMatch = password === confirmPassword && password.length > 0
|
||||||
|
|
||||||
|
const getPasswordStrength = (pwd: string) => {
|
||||||
|
let score = 0
|
||||||
|
if (pwd.length >= 8) score++
|
||||||
|
if (pwd.length >= 12) score++
|
||||||
|
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++
|
||||||
|
if (/[0-9]/.test(pwd)) score++
|
||||||
|
if (/[^a-zA-Z0-9]/.test(pwd)) score++
|
||||||
|
const normalizedScore = Math.min(4, score)
|
||||||
|
const labels = ['Very Weak', 'Weak', 'Fair', 'Strong', 'Very Strong']
|
||||||
|
const colors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-green-500', 'bg-green-600']
|
||||||
|
return { score: normalizedScore, label: labels[normalizedScore], color: colors[normalizedScore] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const strength = getPasswordStrength(password)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
setError('Password does not meet requirements.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!doPasswordsMatch) {
|
||||||
|
setError('Passwords do not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
setError('Invalid reset link. Please request a new one.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetPassword.mutate({ token, password, confirmPassword })
|
||||||
|
}
|
||||||
|
|
||||||
|
// No token in URL
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<AnimatedCard>
|
||||||
|
<Card className="w-full max-w-md overflow-hidden">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10">
|
||||||
|
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">Invalid Reset Link</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
This password reset link is invalid or has expired.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<Link href={'/forgot-password' as Route}>Request a new reset link</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="text-center">
|
||||||
|
<Link href="/login" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||||
|
<ArrowLeft className="inline h-3.5 w-3.5 mr-1" />
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success state
|
||||||
|
if (isSuccess) {
|
||||||
|
return (
|
||||||
|
<AnimatedCard>
|
||||||
|
<Card className="w-full max-w-md overflow-hidden">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50">
|
||||||
|
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">Password Reset Successfully</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Your password has been updated. You can now sign in with your new password.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<Link href="/login">Sign in</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedCard>
|
||||||
|
<Card className="w-full max-w-md overflow-hidden">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-xl">Choose a new password</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create a secure password for your account.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">New Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
placeholder="Enter a secure password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={resetPassword.isPending}
|
||||||
|
autoComplete="new-password"
|
||||||
|
autoFocus
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{password.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress value={(strength.score / 4) * 100} className={`h-2 ${strength.color}`} />
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">{strength.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1 text-xs">
|
||||||
|
{[
|
||||||
|
{ label: '8+ characters', met: password.length >= 8 },
|
||||||
|
{ label: 'Uppercase', met: /[A-Z]/.test(password) },
|
||||||
|
{ label: 'Lowercase', met: /[a-z]/.test(password) },
|
||||||
|
{ label: 'Number', met: /[0-9]/.test(password) },
|
||||||
|
].map((req) => (
|
||||||
|
<div
|
||||||
|
key={req.label}
|
||||||
|
className={`flex items-center gap-1 ${req.met ? 'text-green-600' : 'text-muted-foreground'}`}
|
||||||
|
>
|
||||||
|
{req.met ? (
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<div className="h-3 w-3 rounded-full border border-current" />
|
||||||
|
)}
|
||||||
|
{req.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={resetPassword.isPending}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{confirmPassword.length > 0 && (
|
||||||
|
<p className={`text-xs ${doPasswordsMatch ? 'text-green-600' : 'text-destructive'}`}>
|
||||||
|
{doPasswordsMatch ? 'Passwords match' : 'Passwords do not match'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={resetPassword.isPending || !isPasswordValid || !doPasswordsMatch}
|
||||||
|
>
|
||||||
|
{resetPassword.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Resetting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Lock className="mr-2 h-4 w-4" />
|
||||||
|
Reset Password
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center pt-2">
|
||||||
|
<Link href="/login" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||||
|
<ArrowLeft className="inline h-3.5 w-3.5 mr-1" />
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -36,15 +36,9 @@ export default function SetPasswordPage() {
|
|||||||
setIsSuccess(true)
|
setIsSuccess(true)
|
||||||
// Update the session to reflect the password has been set
|
// Update the session to reflect the password has been set
|
||||||
await updateSession()
|
await updateSession()
|
||||||
// Redirect after a short delay
|
// Redirect after a short delay — all roles go to onboarding first
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (session?.user?.role === 'JURY_MEMBER') {
|
router.push('/onboarding')
|
||||||
router.push('/jury')
|
|
||||||
} else if (session?.user?.role === 'SUPER_ADMIN' || session?.user?.role === 'PROGRAM_ADMIN') {
|
|
||||||
router.push('/admin')
|
|
||||||
} else {
|
|
||||||
router.push('/')
|
|
||||||
}
|
|
||||||
}, 2000)
|
}, 2000)
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
@@ -146,7 +140,7 @@ export default function SetPasswordPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-center">
|
<CardContent className="text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Redirecting you to the dashboard...
|
Redirecting you to onboarding...
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { use, useState } from 'react'
|
import { use, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
GripVertical,
|
GripVertical,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { CountryDisplay } from '@/components/shared/country-display'
|
||||||
|
|
||||||
export default function JuryAwardVotingPage({
|
export default function JuryAwardVotingPage({
|
||||||
params,
|
params,
|
||||||
@@ -29,6 +30,7 @@ export default function JuryAwardVotingPage({
|
|||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}) {
|
}) {
|
||||||
const { id: awardId } = use(params)
|
const { id: awardId } = use(params)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const { data, isLoading, refetch } =
|
const { data, isLoading, refetch } =
|
||||||
@@ -120,11 +122,9 @@ export default function JuryAwardVotingPage({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" onClick={() => router.back()} className="-ml-4">
|
||||||
<Link href="/jury/awards">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Awards
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ export default function JuryAwardVotingPage({
|
|||||||
)}
|
)}
|
||||||
{project.country && (
|
{project.country && (
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{project.country}
|
<CountryDisplay country={project.country} />
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -286,7 +286,7 @@ export default function JuryAwardVotingPage({
|
|||||||
)}
|
)}
|
||||||
{project.country && (
|
{project.country && (
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{project.country}
|
<CountryDisplay country={project.country} />
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
@@ -8,12 +8,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { ArrowLeft, CheckCircle2, Clock, Circle } from 'lucide-react'
|
import { ArrowLeft, CheckCircle2, Clock, Circle, Eye } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { JurorProgressDashboard } from '@/components/jury/juror-progress-dashboard'
|
import { JurorProgressDashboard } from '@/components/jury/juror-progress-dashboard'
|
||||||
|
|
||||||
export default function JuryRoundDetailPage() {
|
export default function JuryRoundDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
const roundId = params.roundId as string
|
const roundId = params.roundId as string
|
||||||
|
|
||||||
const { data: assignments, isLoading } = trpc.roundAssignment.getMyAssignments.useQuery(
|
const { data: assignments, isLoading } = trpc.roundAssignment.getMyAssignments.useQuery(
|
||||||
@@ -38,11 +39,9 @@ export default function JuryRoundDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||||
<Link href={'/jury/competitions' as Route} aria-label="Back to competitions list">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back
|
|
||||||
</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 dark:text-foreground">
|
||||||
@@ -82,10 +81,13 @@ export default function JuryRoundDetailPage() {
|
|||||||
const isDraft = assignment.evaluation?.status === 'DRAFT'
|
const isDraft = assignment.evaluation?.status === 'DRAFT'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<div
|
||||||
key={assignment.id}
|
key={assignment.id}
|
||||||
href={`/jury/competitions/${roundId}/projects/${assignment.projectId}` as Route}
|
role="button"
|
||||||
className="flex items-center justify-between p-4 rounded-lg border border-border/60 hover:border-brand-blue/30 hover:bg-brand-blue/5 transition-all"
|
tabIndex={0}
|
||||||
|
onClick={() => router.push(`/jury/competitions/${roundId}/projects/${assignment.projectId}`)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') router.push(`/jury/competitions/${roundId}/projects/${assignment.projectId}`) }}
|
||||||
|
className="flex items-center justify-between p-4 rounded-lg border border-border/60 hover:border-brand-blue/30 hover:bg-brand-blue/5 transition-all cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium truncate">{assignment.project.title}</p>
|
<p className="font-medium truncate">{assignment.project.title}</p>
|
||||||
@@ -97,12 +99,26 @@ export default function JuryRoundDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 ml-4">
|
<div className="flex items-center gap-2 ml-4">
|
||||||
{isCompleted ? (
|
{isCompleted ? (
|
||||||
<Badge variant="default" className="bg-emerald-50 text-emerald-700 border-emerald-200">
|
<>
|
||||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
<Badge variant="default" className="bg-emerald-50 text-emerald-700 border-emerald-200">
|
||||||
Completed
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
</Badge>
|
Completed
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
asChild
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Link href={`/jury/competitions/${roundId}/projects/${assignment.projectId}/evaluate` as Route}>
|
||||||
|
<Eye className="mr-1 h-3 w-3" />
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
) : isDraft ? (
|
) : isDraft ? (
|
||||||
<Badge variant="secondary" className="bg-amber-50 text-amber-700 border-amber-200">
|
<Badge variant="secondary" className="bg-amber-50 text-amber-700 border-amber-200">
|
||||||
<Clock className="mr-1 h-3 w-3" />
|
<Clock className="mr-1 h-3 w-3" />
|
||||||
@@ -115,7 +131,7 @@ export default function JuryRoundDetailPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { cn } from '@/lib/utils'
|
|||||||
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { COIDeclarationDialog } from '@/components/forms/coi-declaration-dialog'
|
import { COIDeclarationDialog } from '@/components/forms/coi-declaration-dialog'
|
||||||
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert } from 'lucide-react'
|
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert, Lock } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import type { EvaluationConfig } from '@/types/competition-configs'
|
import type { EvaluationConfig } from '@/types/competition-configs'
|
||||||
|
|
||||||
@@ -468,8 +468,10 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
|
|
||||||
// Check if round is active
|
// Check if round is active
|
||||||
const isRoundActive = round.status === 'ROUND_ACTIVE'
|
const isRoundActive = round.status === 'ROUND_ACTIVE'
|
||||||
|
const isSubmittedEvaluation = existingEvaluation?.status === 'SUBMITTED'
|
||||||
|
|
||||||
if (!isRoundActive) {
|
// If round is not active and no submitted evaluation to view, block access
|
||||||
|
if (!isRoundActive && !isSubmittedEvaluation) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -502,8 +504,11 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// COI gate: if COI is required, not yet declared, and we have an assignment
|
// Read-only view for submitted evaluations in closed rounds
|
||||||
if (coiRequired && myAssignment && !coiLoading && !coiDeclared) {
|
const isReadOnly = !isRoundActive && isSubmittedEvaluation
|
||||||
|
|
||||||
|
// COI gate: if COI is required, not yet declared, and we have an assignment (skip for read-only views)
|
||||||
|
if (coiRequired && !isReadOnly && myAssignment && !coiLoading && !coiDeclared) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -533,8 +538,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// COI conflict declared — block evaluation
|
// COI conflict declared — block evaluation (skip for read-only views)
|
||||||
if (coiRequired && coiConflict) {
|
if (coiRequired && !isReadOnly && coiConflict) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -578,15 +583,22 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
{isReadOnly ? (
|
||||||
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
|
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Project
|
Back
|
||||||
</Link>
|
</Button>
|
||||||
</Button>
|
) : (
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Project
|
||||||
|
</Link>
|
||||||
|
</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 dark:text-foreground">
|
||||||
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">
|
||||||
<p className="text-muted-foreground">{project.title}</p>
|
<p className="text-muted-foreground">{project.title}</p>
|
||||||
@@ -606,21 +618,37 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isReadOnly && (
|
||||||
|
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50 dark:bg-blue-950/20">
|
||||||
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
|
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-sm">View-Only</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
This evaluation has been submitted and the round is now closed. You are viewing a read-only copy of your submission.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Project Documents */}
|
{/* Project Documents */}
|
||||||
<MultiWindowDocViewer roundId={roundId} projectId={projectId} />
|
<MultiWindowDocViewer roundId={roundId} projectId={projectId} />
|
||||||
|
|
||||||
<Card className="border-l-4 border-l-amber-500">
|
{!isReadOnly && (
|
||||||
<CardContent className="flex items-start gap-3 p-4">
|
<Card className="border-l-4 border-l-amber-500">
|
||||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
<div className="flex-1">
|
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
||||||
<p className="font-medium text-sm">Important Reminder</p>
|
<div className="flex-1">
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="font-medium text-sm">Important Reminder</p>
|
||||||
Your evaluation will be used to assess this project. Please provide thoughtful and
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
constructive feedback. Your progress is automatically saved as a draft.
|
Your evaluation will be used to assess this project. Please provide thoughtful and
|
||||||
</p>
|
constructive feedback. Your progress is automatically saved as a draft.
|
||||||
</div>
|
</p>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -673,12 +701,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={isReadOnly}
|
||||||
onClick={() => handleCriterionChange(criterion.id, true)}
|
onClick={() => handleCriterionChange(criterion.id, true)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
|
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
|
||||||
currentValue === true
|
currentValue === true
|
||||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200'
|
? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200'
|
||||||
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
|
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50',
|
||||||
|
isReadOnly && 'opacity-60 cursor-default'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ThumbsUp className="mr-2 h-5 w-5" />
|
<ThumbsUp className="mr-2 h-5 w-5" />
|
||||||
@@ -686,12 +716,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={isReadOnly}
|
||||||
onClick={() => handleCriterionChange(criterion.id, false)}
|
onClick={() => handleCriterionChange(criterion.id, false)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
|
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
|
||||||
currentValue === false
|
currentValue === false
|
||||||
? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200'
|
? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200'
|
||||||
: 'border-border hover:border-red-300 hover:bg-red-50/50'
|
: 'border-border hover:border-red-300 hover:bg-red-50/50',
|
||||||
|
isReadOnly && 'opacity-60 cursor-default'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ThumbsDown className="mr-2 h-5 w-5" />
|
<ThumbsDown className="mr-2 h-5 w-5" />
|
||||||
@@ -718,12 +750,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={isReadOnly}
|
||||||
onClick={() => handleCriterionChange(criterion.id, true)}
|
onClick={() => handleCriterionChange(criterion.id, true)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
|
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
|
||||||
currentValue === true
|
currentValue === true
|
||||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
|
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
|
||||||
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
|
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50',
|
||||||
|
isReadOnly && 'opacity-60 cursor-default'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ThumbsUp className="mr-2 h-4 w-4" />
|
<ThumbsUp className="mr-2 h-4 w-4" />
|
||||||
@@ -731,12 +765,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={isReadOnly}
|
||||||
onClick={() => handleCriterionChange(criterion.id, false)}
|
onClick={() => handleCriterionChange(criterion.id, false)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
|
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
|
||||||
currentValue === false
|
currentValue === false
|
||||||
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
|
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
|
||||||
: 'border-border hover:border-red-300 hover:bg-red-50/50'
|
: 'border-border hover:border-red-300 hover:bg-red-50/50',
|
||||||
|
isReadOnly && 'opacity-60 cursor-default'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ThumbsDown className="mr-2 h-4 w-4" />
|
<ThumbsDown className="mr-2 h-4 w-4" />
|
||||||
@@ -766,6 +802,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
placeholder={criterion.placeholder || 'Enter your response...'}
|
placeholder={criterion.placeholder || 'Enter your response...'}
|
||||||
rows={4}
|
rows={4}
|
||||||
maxLength={criterion.maxLength}
|
maxLength={criterion.maxLength}
|
||||||
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground text-right">
|
<p className="text-xs text-muted-foreground text-right">
|
||||||
{currentValue.length}/{criterion.maxLength}
|
{currentValue.length}/{criterion.maxLength}
|
||||||
@@ -807,6 +844,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
value={[sliderValue]}
|
value={[sliderValue]}
|
||||||
onValueChange={(v) => handleCriterionChange(criterion.id, v[0])}
|
onValueChange={(v) => handleCriterionChange(criterion.id, v[0])}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground w-4">{max}</span>
|
<span className="text-xs text-muted-foreground w-4">{max}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -816,6 +854,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
<button
|
<button
|
||||||
key={num}
|
key={num}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={isReadOnly}
|
||||||
onClick={() => handleCriterionChange(criterion.id, num)}
|
onClick={() => handleCriterionChange(criterion.id, num)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
||||||
@@ -823,7 +862,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
: displayValue !== undefined && displayValue > num
|
: displayValue !== undefined && displayValue > num
|
||||||
? 'bg-primary/20 text-primary'
|
? 'bg-primary/20 text-primary'
|
||||||
: 'bg-muted hover:bg-muted/80'
|
: 'bg-muted hover:bg-muted/80',
|
||||||
|
isReadOnly && 'cursor-default'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{num}
|
{num}
|
||||||
@@ -856,6 +896,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
value={[globalScore ? parseInt(globalScore, 10) : 5]}
|
value={[globalScore ? parseInt(globalScore, 10) : 5]}
|
||||||
onValueChange={(v) => handleGlobalScoreChange(v[0].toString())}
|
onValueChange={(v) => handleGlobalScoreChange(v[0].toString())}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">10</span>
|
<span className="text-xs text-muted-foreground">10</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -866,6 +907,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
<button
|
<button
|
||||||
key={num}
|
key={num}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={isReadOnly}
|
||||||
onClick={() => handleGlobalScoreChange(num.toString())}
|
onClick={() => handleGlobalScoreChange(num.toString())}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
||||||
@@ -873,7 +915,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
: current > num
|
: current > num
|
||||||
? 'bg-primary/20 text-primary'
|
? 'bg-primary/20 text-primary'
|
||||||
: 'bg-muted hover:bg-muted/80'
|
: 'bg-muted hover:bg-muted/80',
|
||||||
|
isReadOnly && 'cursor-default'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{num}
|
{num}
|
||||||
@@ -890,7 +933,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
<Label>
|
<Label>
|
||||||
Decision <span className="text-destructive">*</span>
|
Decision <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<RadioGroup value={binaryDecision} onValueChange={(v) => handleBinaryChange(v as 'accept' | 'reject')}>
|
<RadioGroup value={binaryDecision} onValueChange={(v) => handleBinaryChange(v as 'accept' | 'reject')} disabled={isReadOnly}>
|
||||||
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50">
|
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50">
|
||||||
<RadioGroupItem value="accept" id="accept" />
|
<RadioGroupItem value="accept" id="accept" />
|
||||||
<Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1">
|
<Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1">
|
||||||
@@ -921,6 +964,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
onChange={(e) => handleFeedbackChange(e.target.value)}
|
onChange={(e) => handleFeedbackChange(e.target.value)}
|
||||||
placeholder="Provide your feedback on the project..."
|
placeholder="Provide your feedback on the project..."
|
||||||
rows={8}
|
rows={8}
|
||||||
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
{requireFeedback && (
|
{requireFeedback && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -931,32 +975,44 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
{isReadOnly ? (
|
||||||
<Button
|
<div className="flex items-center">
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push(`/jury/competitions/${roundId}/projects/${projectId}` as Route)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleSaveDraft}
|
onClick={() => router.back()}
|
||||||
disabled={autosaveMutation.isPending || submitMutation.isPending}
|
|
||||||
>
|
>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
{autosaveMutation.isPending ? 'Saving...' : 'Save Draft'}
|
Back
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={submitMutation.isPending || isSubmitting}
|
|
||||||
className="bg-brand-blue hover:bg-brand-blue-light"
|
|
||||||
>
|
|
||||||
<Send className="mr-2 h-4 w-4" />
|
|
||||||
{submitMutation.isPending ? 'Submitting...' : 'Submit Evaluation'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push(`/jury/competitions/${roundId}/projects/${projectId}` as Route)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSaveDraft}
|
||||||
|
disabled={autosaveMutation.isPending || submitMutation.isPending}
|
||||||
|
>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{autosaveMutation.isPending ? 'Saving...' : 'Save Draft'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitMutation.isPending || isSubmitting}
|
||||||
|
className="bg-brand-blue hover:bg-brand-blue-light"
|
||||||
|
>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
{submitMutation.isPending ? 'Submitting...' : 'Submit Evaluation'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
@@ -10,9 +10,11 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
||||||
import { ArrowLeft, FileText, Users, MapPin, Target, Tag } from 'lucide-react'
|
import { ArrowLeft, FileText, Users, MapPin, Target, Tag } from 'lucide-react'
|
||||||
|
import { CountryDisplay } from '@/components/shared/country-display'
|
||||||
|
|
||||||
export default function JuryProjectDetailPage() {
|
export default function JuryProjectDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
const roundId = params.roundId as string
|
const roundId = params.roundId as string
|
||||||
const projectId = params.projectId as string
|
const projectId = params.projectId as string
|
||||||
|
|
||||||
@@ -42,11 +44,9 @@ export default function JuryProjectDetailPage() {
|
|||||||
if (!project) {
|
if (!project) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||||
<Link href={`/jury/competitions/${roundId}` as Route}>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
@@ -61,11 +61,9 @@ export default function JuryProjectDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||||
<Link href={`/jury/competitions/${roundId}` as Route}>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,7 +108,7 @@ export default function JuryProjectDetailPage() {
|
|||||||
{project.country && (
|
{project.country && (
|
||||||
<Badge variant="outline" className="gap-1">
|
<Badge variant="outline" className="gap-1">
|
||||||
<MapPin className="h-3 w-3" />
|
<MapPin className="h-3 w-3" />
|
||||||
{project.country}
|
<CountryDisplay country={project.country} />
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -18,8 +19,10 @@ import {
|
|||||||
FileEdit,
|
FileEdit,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatDateOnly, formatEnumLabel } from '@/lib/utils'
|
import { formatDateOnly, formatEnumLabel } from '@/lib/utils'
|
||||||
|
import { CountryDisplay } from '@/components/shared/country-display'
|
||||||
|
|
||||||
export default function JuryAssignmentsPage() {
|
export default function JuryAssignmentsPage() {
|
||||||
|
const router = useRouter()
|
||||||
const { data: assignments, isLoading } = trpc.assignment.myAssignments.useQuery({})
|
const { data: assignments, isLoading } = trpc.assignment.myAssignments.useQuery({})
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -58,11 +61,9 @@ export default function JuryAssignmentsPage() {
|
|||||||
Projects assigned to you for evaluation
|
Projects assigned to you for evaluation
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" asChild className="hidden md:inline-flex">
|
<Button variant="ghost" size="sm" onClick={() => router.back()} className="hidden md:inline-flex">
|
||||||
<Link href={'/jury' as Route}>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Dashboard
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,7 +136,7 @@ export default function JuryAssignmentsPage() {
|
|||||||
{project.title}
|
{project.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{[project.teamName, project.country].filter(Boolean).join(' \u00b7 ')}
|
{project.teamName}{project.teamName && project.country ? ' · ' : ''}{project.country ? <CountryDisplay country={project.country} /> : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -27,6 +26,7 @@ const ResourceRenderer = dynamic(
|
|||||||
|
|
||||||
export default function JuryResourceDetailPage() {
|
export default function JuryResourceDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
const resourceId = params.id as string
|
const resourceId = params.id as string
|
||||||
|
|
||||||
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||||
@@ -73,11 +73,9 @@ export default function JuryResourceDetailPage() {
|
|||||||
This resource may have been removed or you don't have access.
|
This resource may have been removed or you don't have access.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<Button asChild>
|
<Button onClick={() => router.back()}>
|
||||||
<Link href="/jury/learning">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Learning Hub
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -87,11 +85,9 @@ export default function JuryResourceDetailPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" onClick={() => router.back()} className="-ml-4">
|
||||||
<Link href="/jury/learning">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Learning Hub
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{resource.externalUrl && (
|
{resource.externalUrl && (
|
||||||
|
|||||||
@@ -623,7 +623,7 @@ async function JuryDashboardContent() {
|
|||||||
<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 dark:bg-brand-teal/20">
|
||||||
<BarChart3 className="h-4 w-4 text-brand-teal" />
|
<BarChart3 className="h-4 w-4 text-brand-teal" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-lg">Stage Summary</CardTitle>
|
<CardTitle className="text-lg">Round Summary</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
|||||||
@@ -11,20 +11,22 @@ export default async function JuryLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const session = await requireRole('JURY_MEMBER')
|
const session = await requireRole('JURY_MEMBER')
|
||||||
|
const isImpersonating = !!session.user.impersonating
|
||||||
|
|
||||||
// Check if user has completed onboarding
|
// Check if user has completed onboarding (skip during impersonation)
|
||||||
const user = await prisma.user.findUnique({
|
if (!isImpersonating) {
|
||||||
where: { id: session.user.id },
|
const user = await prisma.user.findUnique({
|
||||||
select: { onboardingCompletedAt: true },
|
where: { id: session.user.id },
|
||||||
})
|
select: { onboardingCompletedAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// User was deleted — session is stale, send to login
|
redirect('/login')
|
||||||
redirect('/login')
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.onboardingCompletedAt) {
|
if (!user.onboardingCompletedAt) {
|
||||||
redirect('/onboarding')
|
redirect('/onboarding')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,16 +12,16 @@ export default async function MentorLayout({
|
|||||||
}) {
|
}) {
|
||||||
const session = await requireRole('MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
|
const session = await requireRole('MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
|
||||||
|
|
||||||
// Check if user has completed onboarding (for mentors)
|
// Check if user has completed onboarding (for mentors, skip during impersonation)
|
||||||
|
const isImpersonating = !!session.user.impersonating
|
||||||
const userRoles = session.user.roles?.length ? session.user.roles : [session.user.role]
|
const userRoles = session.user.roles?.length ? session.user.roles : [session.user.role]
|
||||||
if (userRoles.includes('MENTOR') && !userRoles.some(r => r === 'SUPER_ADMIN' || r === 'PROGRAM_ADMIN')) {
|
if (!isImpersonating && userRoles.includes('MENTOR') && !userRoles.some(r => r === 'SUPER_ADMIN' || r === 'PROGRAM_ADMIN')) {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
select: { onboardingCompletedAt: true },
|
select: { onboardingCompletedAt: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// User was deleted — session is stale, send to login
|
|
||||||
redirect('/login')
|
redirect('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
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'
|
||||||
|
|
||||||
// Status badge colors
|
// Status badge colors
|
||||||
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
@@ -336,7 +337,7 @@ export default function MentorDashboard() {
|
|||||||
{project.country && (
|
{project.country && (
|
||||||
<Badge variant="outline" className="gap-1">
|
<Badge variant="outline" className="gap-1">
|
||||||
<MapPin className="h-3 w-3" />
|
<MapPin className="h-3 w-3" />
|
||||||
{project.country}
|
<CountryDisplay country={project.country} />
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Suspense, use, useState, useEffect } from 'react'
|
import { Suspense, use, useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import { useRouter } from 'next/navigation'
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -58,6 +57,7 @@ import {
|
|||||||
EyeOff,
|
EyeOff,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatDateOnly, getInitials } from '@/lib/utils'
|
import { formatDateOnly, getInitials } from '@/lib/utils'
|
||||||
|
import { CountryDisplay } from '@/components/shared/country-display'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -75,6 +75,7 @@ const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'ou
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||||
|
const router = useRouter()
|
||||||
const { data: project, isLoading, error } = trpc.mentor.getProjectDetail.useQuery({
|
const { data: project, isLoading, error } = trpc.mentor.getProjectDetail.useQuery({
|
||||||
projectId,
|
projectId,
|
||||||
})
|
})
|
||||||
@@ -106,11 +107,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
if (error || !project) {
|
if (error || !project) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href={'/mentor' as Route}>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Dashboard
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -122,8 +121,8 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
You may not have access to view this project.
|
You may not have access to view this project.
|
||||||
</p>
|
</p>
|
||||||
<Button asChild className="mt-4">
|
<Button className="mt-4" onClick={() => router.back()}>
|
||||||
<Link href={'/mentor' as Route}>Back to Dashboard</Link>
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -140,11 +139,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href={'/mentor' as Route}>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Dashboard
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -255,7 +252,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">Location</p>
|
<p className="text-sm font-medium text-muted-foreground">Location</p>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{[project.geographicZone, project.country].filter(Boolean).join(', ')}
|
{project.geographicZone}{project.geographicZone && project.country ? ', ' : ''}{project.country ? <CountryDisplay country={project.country} /> : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Crown,
|
Crown,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
|
import { CountryDisplay } from '@/components/shared/country-display'
|
||||||
|
|
||||||
// Status badge colors
|
// Status badge colors
|
||||||
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
@@ -143,7 +144,7 @@ export default function MentorProjectsPage() {
|
|||||||
{project.country && (
|
{project.country && (
|
||||||
<Badge variant="outline" className="gap-1">
|
<Badge variant="outline" className="gap-1">
|
||||||
<MapPin className="h-3 w-3" />
|
<MapPin className="h-3 w-3" />
|
||||||
{project.country}
|
<CountryDisplay country={project.country} />
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -27,6 +26,7 @@ const ResourceRenderer = dynamic(
|
|||||||
|
|
||||||
export default function MentorResourceDetailPage() {
|
export default function MentorResourceDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
const resourceId = params.id as string
|
const resourceId = params.id as string
|
||||||
|
|
||||||
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||||
@@ -73,11 +73,9 @@ export default function MentorResourceDetailPage() {
|
|||||||
This resource may have been removed or you don't have access.
|
This resource may have been removed or you don't have access.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<Button asChild>
|
<Button onClick={() => router.back()}>
|
||||||
<Link href="/mentor/resources">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Resources
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -87,11 +85,9 @@ export default function MentorResourceDetailPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/mentor/resources">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Resources
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{resource.externalUrl && (
|
{resource.externalUrl && (
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
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'
|
||||||
@@ -16,6 +14,7 @@ import { toast } from 'sonner'
|
|||||||
|
|
||||||
export default function MentorWorkspaceDetailPage() {
|
export default function MentorWorkspaceDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
const projectId = params.projectId as string
|
const projectId = params.projectId as string
|
||||||
|
|
||||||
// Get mentor assignment for this project
|
// Get mentor assignment for this project
|
||||||
@@ -39,11 +38,9 @@ export default function MentorWorkspaceDetailPage() {
|
|||||||
if (!project) {
|
if (!project) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||||
<Link href={'/mentor/workspace' as Route}>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
@@ -58,11 +55,9 @@ export default function MentorWorkspaceDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||||
<Link href={'/mentor/workspace' as Route}>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -20,6 +21,7 @@ const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'ou
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MentorWorkspacePage() {
|
export default function MentorWorkspacePage() {
|
||||||
|
const router = useRouter()
|
||||||
const { data: assignments, isLoading } = trpc.mentor.getMyProjects.useQuery()
|
const { data: assignments, isLoading } = trpc.mentor.getMyProjects.useQuery()
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -46,11 +48,9 @@ export default function MentorWorkspacePage() {
|
|||||||
Collaborate with your assigned mentee projects
|
Collaborate with your assigned mentee projects
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||||
<Link href={'/mentor' as Route}>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Dashboard
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,34 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
import { requireRole } from '@/lib/auth-redirect'
|
import { requireRole } from '@/lib/auth-redirect'
|
||||||
import { ObserverNav } from '@/components/layouts/observer-nav'
|
import { ObserverNav } from '@/components/layouts/observer-nav'
|
||||||
import { EditionProvider } from '@/components/observer/observer-edition-context'
|
import { EditionProvider } from '@/components/observer/observer-edition-context'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export default async function ObserverLayout({
|
export default async function ObserverLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const session = await requireRole('OBSERVER')
|
const session = await requireRole('OBSERVER')
|
||||||
|
const isImpersonating = !!session.user.impersonating
|
||||||
|
|
||||||
|
// Check if user has completed onboarding (skip during impersonation)
|
||||||
|
if (!isImpersonating) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { onboardingCompletedAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.onboardingCompletedAt) {
|
||||||
|
redirect('/onboarding')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
@@ -67,6 +67,7 @@ const fileTypeLabels: Record<string, string> = {
|
|||||||
|
|
||||||
export function SubmissionDetailClient() {
|
export function SubmissionDetailClient() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const projectId = params.id as string
|
const projectId = params.id as string
|
||||||
const [activeTab, setActiveTab] = useState('details')
|
const [activeTab, setActiveTab] = useState('details')
|
||||||
@@ -116,11 +117,9 @@ export function SubmissionDetailClient() {
|
|||||||
{error?.message || 'Submission not found'}
|
{error?.message || 'Submission not found'}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<Button asChild className="mt-4">
|
<Button className="mt-4" onClick={() => router.back()}>
|
||||||
<Link href="/my-submission">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to My Submissions
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -133,11 +132,9 @@ export function SubmissionDetailClient() {
|
|||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/my-submission">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to My Submissions
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -203,10 +203,8 @@ export default function TeamManagementPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="icon" asChild>
|
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||||
<Link href={`/my-submission/${projectId}`}>
|
<ArrowLeft className="h-5 w-5" />
|
||||||
<ArrowLeft className="h-5 w-5" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||||
|
|||||||
@@ -94,10 +94,10 @@ export default function ProfileSettingsPage() {
|
|||||||
setExpertiseTags(user.expertiseTags || [])
|
setExpertiseTags(user.expertiseTags || [])
|
||||||
setDigestFrequency(user.digestFrequency || 'none')
|
setDigestFrequency(user.digestFrequency || 'none')
|
||||||
setPreferredWorkload(user.preferredWorkload ?? null)
|
setPreferredWorkload(user.preferredWorkload ?? null)
|
||||||
const avail = user.availabilityJson as { startDate?: string; endDate?: string } | null
|
const avail = user.availabilityJson as Array<{ start?: string; end?: string }> | null
|
||||||
if (avail) {
|
if (avail && avail.length > 0) {
|
||||||
setAvailabilityStart(avail.startDate || '')
|
setAvailabilityStart(avail[0].start || '')
|
||||||
setAvailabilityEnd(avail.endDate || '')
|
setAvailabilityEnd(avail[0].end || '')
|
||||||
}
|
}
|
||||||
setProfileLoaded(true)
|
setProfileLoaded(true)
|
||||||
}
|
}
|
||||||
@@ -114,10 +114,10 @@ export default function ProfileSettingsPage() {
|
|||||||
expertiseTags,
|
expertiseTags,
|
||||||
digestFrequency: digestFrequency as 'none' | 'daily' | 'weekly',
|
digestFrequency: digestFrequency as 'none' | 'daily' | 'weekly',
|
||||||
preferredWorkload: preferredWorkload ?? undefined,
|
preferredWorkload: preferredWorkload ?? undefined,
|
||||||
availabilityJson: (availabilityStart || availabilityEnd) ? {
|
availabilityJson: (availabilityStart || availabilityEnd) ? [{
|
||||||
startDate: availabilityStart || undefined,
|
start: availabilityStart || '',
|
||||||
endDate: availabilityEnd || undefined,
|
end: availabilityEnd || '',
|
||||||
} : undefined,
|
}] : undefined,
|
||||||
})
|
})
|
||||||
toast.success('Profile updated successfully')
|
toast.success('Profile updated successfully')
|
||||||
refetch()
|
refetch()
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { checkRateLimit } from '@/lib/rate-limit'
|
|||||||
const AUTH_RATE_LIMIT = 10 // requests per window
|
const AUTH_RATE_LIMIT = 10 // requests per window
|
||||||
const AUTH_RATE_WINDOW_MS = 60 * 1000 // 1 minute
|
const AUTH_RATE_WINDOW_MS = 60 * 1000 // 1 minute
|
||||||
|
|
||||||
|
const CSRF_RATE_LIMIT = 20 // requests per window
|
||||||
|
const CSRF_RATE_WINDOW_MS = 15 * 60 * 1000 // 15 minutes
|
||||||
|
|
||||||
function getClientIp(req: Request): string {
|
function getClientIp(req: Request): string {
|
||||||
return (
|
return (
|
||||||
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||||
@@ -12,15 +15,35 @@ function getClientIp(req: Request): string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function withRateLimit(handler: (req: Request) => Promise<Response>) {
|
function withPostRateLimit(handler: (req: Request) => Promise<Response>) {
|
||||||
return async (req: Request) => {
|
return async (req: Request) => {
|
||||||
// Only rate limit POST requests (sign-in, magic link sends)
|
const ip = getClientIp(req)
|
||||||
if (req.method === 'POST') {
|
const { success, resetAt } = checkRateLimit(`auth:${ip}`, AUTH_RATE_LIMIT, AUTH_RATE_WINDOW_MS)
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Too many authentication attempts' }), {
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': String(Math.ceil((resetAt - Date.now()) / 1000)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withGetRateLimit(handler: (req: Request) => Promise<Response>) {
|
||||||
|
return async (req: Request) => {
|
||||||
|
// Rate-limit the CSRF token endpoint to prevent token farming
|
||||||
|
const url = new URL(req.url)
|
||||||
|
if (url.pathname.endsWith('/csrf')) {
|
||||||
const ip = getClientIp(req)
|
const ip = getClientIp(req)
|
||||||
const { success, resetAt } = checkRateLimit(`auth:${ip}`, AUTH_RATE_LIMIT, AUTH_RATE_WINDOW_MS)
|
const { success, resetAt } = checkRateLimit(`csrf:${ip}`, CSRF_RATE_LIMIT, CSRF_RATE_WINDOW_MS)
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return new Response(JSON.stringify({ error: 'Too many authentication attempts' }), {
|
return new Response(JSON.stringify({ error: 'Too many requests' }), {
|
||||||
status: 429,
|
status: 429,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -34,5 +57,5 @@ function withRateLimit(handler: (req: Request) => Promise<Response>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET = handlers.GET
|
export const GET = withGetRateLimit(handlers.GET as (req: Request) => Promise<Response>)
|
||||||
export const POST = withRateLimit(handlers.POST as (req: Request) => Promise<Response>)
|
export const POST = withPostRateLimit(handlers.POST as (req: Request) => Promise<Response>)
|
||||||
|
|||||||
37
src/app/api/auth/check-email/route.ts
Normal file
37
src/app/api/auth/check-email/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { checkRateLimit } from '@/lib/rate-limit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-check whether an email exists before sending a magic link.
|
||||||
|
* This is a closed platform (no self-registration) so revealing
|
||||||
|
* email existence is acceptable and helps users who mistype.
|
||||||
|
* Rate-limited to 10 requests per 15 minutes per IP.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
|
||||||
|
const rateResult = checkRateLimit(`check-email:${ip}`, 10, 15 * 60 * 1000)
|
||||||
|
if (!rateResult.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ exists: false, error: 'Too many requests' },
|
||||||
|
{ status: 429 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email } = await req.json()
|
||||||
|
if (!email || typeof email !== 'string') {
|
||||||
|
return NextResponse.json({ exists: false }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: email.toLowerCase().trim() },
|
||||||
|
select: { status: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const exists = !!user && user.status !== 'SUSPENDED'
|
||||||
|
return NextResponse.json({ exists })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ exists: false }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/app/api/cron/process-grace-periods/route.ts
Normal file
47
src/app/api/cron/process-grace-periods/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { processRoundClose } from '@/server/services/round-finalization'
|
||||||
|
|
||||||
|
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 now = new Date()
|
||||||
|
|
||||||
|
// Find rounds with expired grace periods that haven't been finalized
|
||||||
|
const expiredRounds = await prisma.round.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'ROUND_CLOSED',
|
||||||
|
gracePeriodEndsAt: { lt: now },
|
||||||
|
finalizedAt: null,
|
||||||
|
},
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const results: Array<{ roundId: string; roundName: string; processed: number }> = []
|
||||||
|
|
||||||
|
for (const round of expiredRounds) {
|
||||||
|
try {
|
||||||
|
const result = await processRoundClose(round.id, 'system-cron', prisma)
|
||||||
|
results.push({ roundId: round.id, roundName: round.name, processed: result.processed })
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Cron] processRoundClose failed for round ${round.id}:`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
processedRounds: results.length,
|
||||||
|
results,
|
||||||
|
timestamp: now.toISOString(),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cron grace period processing failed:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export async function GET(request: NextRequest): Promise<Response> {
|
export async function GET(request: NextRequest): Promise<Response> {
|
||||||
|
// Require authentication — prevent unauthenticated access to live vote data
|
||||||
|
const userSession = await auth()
|
||||||
|
if (!userSession?.user) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const sessionId = searchParams.get('sessionId')
|
const sessionId = searchParams.get('sessionId')
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
import Script from "next/script";
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
import { Providers } from './providers'
|
import { Providers } from './providers'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
|
import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
|
||||||
|
import { VersionGuard } from '@/components/shared/version-guard'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@@ -21,8 +24,27 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
{process.env.NODE_ENV === "development" && (
|
||||||
|
<Script
|
||||||
|
src="//unpkg.com/react-grab/dist/index.global.js"
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{process.env.NODE_ENV === "development" && (
|
||||||
|
<Script
|
||||||
|
src="//unpkg.com/@react-grab/mcp/dist/client.global.js"
|
||||||
|
strategy="lazyOnload"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</head>
|
||||||
<body className="min-h-screen bg-background font-sans antialiased">
|
<body className="min-h-screen bg-background font-sans antialiased">
|
||||||
<Providers>{children}</Providers>
|
<Providers>
|
||||||
|
<VersionGuard />
|
||||||
|
<ImpersonationBanner />
|
||||||
|
{children}
|
||||||
|
</Providers>
|
||||||
<Toaster
|
<Toaster
|
||||||
position="top-right"
|
position="top-right"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user