Compare commits
30 Commits
67670472f7
...
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 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -61,3 +61,4 @@ build-output.txt
|
||||
|
||||
# Private keys and secrets
|
||||
private/
|
||||
public/build-id.json
|
||||
|
||||
@@ -23,9 +23,9 @@ COPY . .
|
||||
# Generate Prisma client
|
||||
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
|
||||
RUN npm run build
|
||||
RUN --mount=type=cache,target=/app/.next/cache npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
@@ -69,5 +69,8 @@ EXPOSE 7600
|
||||
ENV PORT=7600
|
||||
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)
|
||||
CMD ["/app/docker-entrypoint.sh"]
|
||||
|
||||
@@ -68,7 +68,7 @@ services:
|
||||
env_file:
|
||||
- ../.env
|
||||
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_SECRET=${NEXTAUTH_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
|
||||
environment:
|
||||
- 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_SECRET=${NEXTAUTH_SECRET}
|
||||
- AUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
|
||||
@@ -59,4 +59,18 @@ else
|
||||
fi
|
||||
|
||||
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 = {
|
||||
output: 'standalone',
|
||||
typedRoutes: true,
|
||||
serverExternalPackages: ['@prisma/client', 'minio'],
|
||||
typescript: {
|
||||
ignoreBuildErrors: false,
|
||||
},
|
||||
experimental: {
|
||||
optimizePackageImports: ['lucide-react'],
|
||||
optimizePackageImports: [
|
||||
'lucide-react',
|
||||
'sonner',
|
||||
'date-fns',
|
||||
'recharts',
|
||||
'motion/react',
|
||||
'zod',
|
||||
'@radix-ui/react-icons',
|
||||
],
|
||||
},
|
||||
images: {
|
||||
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",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"prebuild": "node -e \"require('fs').writeFileSync('public/build-id.json', JSON.stringify({buildId: Date.now().toString()}))\"",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
@@ -95,6 +96,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@react-grab/mcp": "^0.1.25",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^25.0.10",
|
||||
@@ -109,6 +111,7 @@
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"prisma": "^6.19.2",
|
||||
"react-grab": "^0.1.25",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tsx": "^4.19.2",
|
||||
|
||||
@@ -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 {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -130,13 +134,6 @@ enum PartnerType {
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum OverrideReasonCode {
|
||||
DATA_CORRECTION
|
||||
POLICY_EXCEPTION
|
||||
JURY_CONFLICT
|
||||
SPONSOR_DECISION
|
||||
ADMIN_DISCRETION
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPETITION / ROUND ENGINE ENUMS
|
||||
@@ -175,13 +172,6 @@ enum ProjectRoundStateValue {
|
||||
WITHDRAWN
|
||||
}
|
||||
|
||||
enum AdvancementRuleType {
|
||||
AUTO_ADVANCE
|
||||
SCORE_THRESHOLD
|
||||
TOP_N
|
||||
ADMIN_SELECTION
|
||||
AI_RECOMMENDED
|
||||
}
|
||||
|
||||
enum CapMode {
|
||||
HARD
|
||||
@@ -427,7 +417,6 @@ model User {
|
||||
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
|
||||
resultLocksCreated ResultLock[] @relation("ResultLockCreator")
|
||||
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
|
||||
assignmentExceptionsApproved AssignmentException[] @relation("AssignmentExceptionApprover")
|
||||
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
|
||||
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
|
||||
|
||||
@@ -559,7 +548,6 @@ model EvaluationForm {
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
programId String
|
||||
roundId String?
|
||||
status ProjectStatus @default(SUBMITTED)
|
||||
|
||||
// Core fields
|
||||
@@ -759,7 +747,6 @@ model Assignment {
|
||||
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||
evaluation Evaluation?
|
||||
conflictOfInterest ConflictOfInterest?
|
||||
exceptions AssignmentException[]
|
||||
|
||||
@@unique([userId, projectId, roundId])
|
||||
@@index([roundId])
|
||||
@@ -768,6 +755,7 @@ model Assignment {
|
||||
@@index([isCompleted])
|
||||
@@index([projectId, userId])
|
||||
@@index([juryGroupId])
|
||||
@@index([roundId, isCompleted])
|
||||
}
|
||||
|
||||
model Evaluation {
|
||||
@@ -785,11 +773,6 @@ model Evaluation {
|
||||
binaryDecision Boolean? // Yes/No for semi-finalist
|
||||
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
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -964,6 +947,7 @@ model NotificationLog {
|
||||
@@index([projectId])
|
||||
@@index([batchId])
|
||||
@@index([email])
|
||||
@@index([type, status])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -1494,6 +1478,7 @@ model RankingSnapshot {
|
||||
@@index([roundId])
|
||||
@@index([triggeredById])
|
||||
@@index([createdAt])
|
||||
@@index([roundId, createdAt])
|
||||
}
|
||||
|
||||
// Tracks progress of long-running AI tagging jobs
|
||||
@@ -1722,7 +1707,6 @@ model ConflictOfInterest {
|
||||
assignmentId String @unique
|
||||
userId String
|
||||
projectId String
|
||||
roundId String? // Legacy — kept for historical data
|
||||
hasConflict Boolean @default(false)
|
||||
conflictType String? // "financial", "personal", "organizational", "other"
|
||||
description String? @db.Text
|
||||
@@ -1740,6 +1724,8 @@ model ConflictOfInterest {
|
||||
|
||||
@@index([userId])
|
||||
@@index([hasConflict])
|
||||
@@index([projectId])
|
||||
@@index([userId, hasConflict])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -2102,24 +2088,6 @@ model LiveProgressCursor {
|
||||
@@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 {
|
||||
id String @id @default(cuid())
|
||||
eventType String // stage.transitioned, routing.executed, filtering.completed, etc.
|
||||
@@ -2137,21 +2105,6 @@ model DecisionAuditLog {
|
||||
@@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)
|
||||
// =============================================================================
|
||||
@@ -2227,7 +2180,6 @@ model Round {
|
||||
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
|
||||
projectRoundStates ProjectRoundState[]
|
||||
advancementRules AdvancementRule[]
|
||||
visibleSubmissionWindows RoundSubmissionVisibility[]
|
||||
assignmentIntents AssignmentIntent[]
|
||||
deliberationSessions DeliberationSession[]
|
||||
@@ -2283,24 +2235,7 @@ model ProjectRoundState {
|
||||
@@index([projectId])
|
||||
@@index([roundId])
|
||||
@@index([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])
|
||||
@@index([roundId, state])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -2479,22 +2414,6 @@ model AssignmentIntent {
|
||||
@@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)
|
||||
// =============================================================================
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
RoundStatus,
|
||||
CapMode,
|
||||
JuryGroupMemberRole,
|
||||
AdvancementRuleType,
|
||||
} from '@prisma/client'
|
||||
import bcrypt from 'bcryptjs'
|
||||
// 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 = [
|
||||
{ email: 'matt@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' },
|
||||
{ email: 'matt@letsbe.solutions', name: 'Matt (Applicant)', role: UserRole.APPLICANT, password: '195260Mp!' },
|
||||
{ email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' },
|
||||
{ email: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' },
|
||||
]
|
||||
@@ -323,10 +323,10 @@ async function main() {
|
||||
const staffUsers: Record<string, string> = {}
|
||||
for (const account of staffAccounts) {
|
||||
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({
|
||||
where: { email: account.email },
|
||||
update: isSuperAdmin
|
||||
update: needsPassword
|
||||
? {
|
||||
status: UserStatus.ACTIVE,
|
||||
passwordHash,
|
||||
@@ -348,11 +348,11 @@ async function main() {
|
||||
name: account.name,
|
||||
role: account.role,
|
||||
roles: [account.role],
|
||||
status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE,
|
||||
passwordHash: isSuperAdmin ? passwordHash : null,
|
||||
mustSetPassword: !isSuperAdmin,
|
||||
passwordSetAt: isSuperAdmin ? new Date() : null,
|
||||
onboardingCompletedAt: isSuperAdmin ? new Date() : null,
|
||||
status: needsPassword ? UserStatus.ACTIVE : UserStatus.NONE,
|
||||
passwordHash: needsPassword ? passwordHash : null,
|
||||
mustSetPassword: !needsPassword,
|
||||
passwordSetAt: needsPassword ? new Date() : null,
|
||||
onboardingCompletedAt: needsPassword ? new Date() : null,
|
||||
},
|
||||
})
|
||||
staffUsers[account.email] = user.id
|
||||
@@ -857,24 +857,6 @@ async function main() {
|
||||
}
|
||||
console.log(` ✓ ${rounds.length} rounds created (R1-R8)`)
|
||||
|
||||
// --- Advancement Rules (auto-advance between rounds) ---
|
||||
for (let i = 0; i < rounds.length - 1; i++) {
|
||||
await prisma.advancementRule.upsert({
|
||||
where: {
|
||||
roundId_sortOrder: { roundId: rounds[i].id, sortOrder: 0 },
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
roundId: rounds[i].id,
|
||||
ruleType: AdvancementRuleType.AUTO_ADVANCE,
|
||||
sortOrder: 0,
|
||||
targetRoundId: rounds[i + 1].id,
|
||||
configJson: {},
|
||||
},
|
||||
})
|
||||
}
|
||||
console.log(` ✓ ${rounds.length - 1} advancement rules created`)
|
||||
|
||||
// --- Assign all projects to intake round (COMPLETED, since intake is closed) ---
|
||||
const intakeRound = rounds[0]
|
||||
const allProjects = await prisma.project.findMany({
|
||||
@@ -916,6 +898,28 @@ async function main() {
|
||||
}
|
||||
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 ---
|
||||
await prisma.systemSettings.upsert({
|
||||
where: { key: 'feature.useCompetitionModel' },
|
||||
|
||||
@@ -56,6 +56,7 @@ import { Switch } from '@/components/ui/switch'
|
||||
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
|
||||
// Action type options (manual audit actions + auto-generated mutation audit actions)
|
||||
const ACTION_TYPES = [
|
||||
@@ -223,6 +224,26 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
|
||||
}
|
||||
|
||||
|
||||
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() {
|
||||
// Filter state
|
||||
const [filters, setFilters] = useState({
|
||||
@@ -555,14 +576,24 @@ export default function AuditLogPage() {
|
||||
{formatDate(log.timestamp)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{log.user?.name || 'System'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{log.user?.email}
|
||||
</p>
|
||||
</div>
|
||||
{log.userId ? (
|
||||
<Link
|
||||
href={`/admin/members/${log.userId}`}
|
||||
className="group block"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="font-medium text-sm group-hover:text-primary group-hover:underline">
|
||||
{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>
|
||||
<Badge
|
||||
@@ -574,11 +605,22 @@ export default function AuditLogPage() {
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="text-sm">{log.entityType}</p>
|
||||
{log.entityId && (
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
{log.entityId.slice(0, 8)}...
|
||||
</p>
|
||||
)}
|
||||
{log.entityId && (() => {
|
||||
const link = getEntityLink(log.entityType, log.entityId)
|
||||
return link ? (
|
||||
<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>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
@@ -601,9 +643,18 @@ export default function AuditLogPage() {
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Entity ID
|
||||
</p>
|
||||
<p className="font-mono text-sm">
|
||||
{log.entityId || 'N/A'}
|
||||
</p>
|
||||
{log.entityId ? (() => {
|
||||
const link = getEntityLink(log.entityType, log.entityId)
|
||||
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>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
@@ -700,12 +751,23 @@ export default function AuditLogPage() {
|
||||
{formatDate(log.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<User className="h-3 w-3" />
|
||||
<span className="text-xs">
|
||||
{log.user?.name || 'System'}
|
||||
</span>
|
||||
</div>
|
||||
{log.userId ? (
|
||||
<Link
|
||||
href={`/admin/members/${log.userId}`}
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-primary"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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>
|
||||
|
||||
{isExpanded && (
|
||||
|
||||
@@ -122,11 +122,9 @@ export default function EditAwardPage({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/awards/${awardId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Award
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ import {
|
||||
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'> = {
|
||||
DRAFT: 'secondary',
|
||||
@@ -663,11 +664,9 @@ export default function AwardDetailPage({
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/awards">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Awards
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1024,7 +1023,7 @@ export default function AwardDetailPage({
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{project.country || '-'}</TableCell>
|
||||
<TableCell className="text-sm">{project.country ? <CountryDisplay country={project.country} /> : '-'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1190,7 +1189,7 @@ export default function AwardDetailPage({
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{e.project.country || '-'}</TableCell>
|
||||
<TableCell>{e.project.country ? <CountryDisplay country={e.project.country} /> : '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs gap-1">
|
||||
{e.method === 'MANUAL' ? 'Manual' : <><Bot className="h-3 w-3" />AI Assessed</>}
|
||||
|
||||
@@ -69,11 +69,9 @@ export default function CreateAwardPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/awards">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Awards
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -194,10 +194,8 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
|
||||
<Card className="border-dashed">
|
||||
<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>
|
||||
<Button asChild className="mt-4" variant="outline">
|
||||
<Link href={'/admin/juries' as Route}>
|
||||
Back to Juries
|
||||
</Link>
|
||||
<Button className="mt-4" variant="outline" onClick={() => router.back()}>
|
||||
Back
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -212,13 +210,11 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="mb-2"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<Link href={'/admin/juries' as Route}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back to Juries
|
||||
</Link>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -257,11 +256,9 @@ export default function EditLearningResourcePage() {
|
||||
The resource you're looking for does not exist.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/admin/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
<Button onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -271,11 +268,9 @@ export default function EditLearningResourcePage() {
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* 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">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/admin/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -165,11 +164,9 @@ export default function NewLearningResourcePage() {
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* 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">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/admin/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { directSessionUpdate } from '@/lib/session-update'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -64,8 +65,40 @@ import {
|
||||
Building2,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
LogIn,
|
||||
Calendar,
|
||||
Clock,
|
||||
} 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() {
|
||||
const params = useParams()
|
||||
@@ -78,6 +111,7 @@ export default function MemberDetailPage() {
|
||||
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
|
||||
const updateUser = trpc.user.update.useMutation()
|
||||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
||||
const startImpersonation = trpc.user.startImpersonation.useMutation()
|
||||
|
||||
// Mentor assignments (only fetched for mentors)
|
||||
const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery(
|
||||
@@ -129,7 +163,6 @@ export default function MemberDetailPage() {
|
||||
utils.user.get.invalidate({ id: userId })
|
||||
utils.user.list.invalidate()
|
||||
toast.success('Member updated successfully')
|
||||
router.push('/admin/members')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update member')
|
||||
}
|
||||
@@ -146,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) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-32" />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-16 w-16 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -172,64 +222,75 @@ export default function MemberDetailPage() {
|
||||
<AlertTitle>Error Loading Member</AlertTitle>
|
||||
<AlertDescription>
|
||||
{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>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/admin/members">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Members
|
||||
</Link>
|
||||
<Button onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const displayRoles = user.roles?.length ? user.roles : [user.role]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/members">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Members
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Back nav */}
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Header Hero */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<UserAvatar user={user} avatarUrl={user.avatarUrl} size="lg" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{user.name || 'Unnamed Member'}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-muted-foreground">{user.email}</p>
|
||||
<Badge variant={user.status === 'ACTIVE' ? 'success' : user.status === 'SUSPENDED' ? 'destructive' : 'secondary'}>
|
||||
<p className="text-muted-foreground">{user.email}</p>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<Badge variant={statusVariant[user.status] || 'secondary'}>
|
||||
{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>
|
||||
{(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
|
||||
variant="outline"
|
||||
onClick={handleSendInvitation}
|
||||
disabled={sendInvitation.isPending}
|
||||
onClick={handleImpersonate}
|
||||
disabled={startImpersonation.isPending}
|
||||
>
|
||||
{sendInvitation.isPending ? (
|
||||
{startImpersonation.isPending ? (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="profile" className="space-y-6">
|
||||
@@ -252,351 +313,369 @@ export default function MemberDetailPage() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-6">
|
||||
{/* Profile Details (read-only) */}
|
||||
{(user.nationality || user.country || user.institution || user.bio) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
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-2">
|
||||
<span className="text-lg 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">{getCountryName(user.nationality)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user.country && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg 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">{getCountryName(user.country)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user.institution && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Building2 className="h-4 w-4 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">{user.institution}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user.bio && (
|
||||
<div className="flex items-start gap-2 sm:col-span-2">
|
||||
<FileText className="h-4 w-4 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">{user.bio}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Team Memberships / Projects */}
|
||||
{user.teamMemberships && user.teamMemberships.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="h-5 w-5" />
|
||||
Projects ({user.teamMemberships.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2">
|
||||
{user.teamMemberships.map((tm) => (
|
||||
<Link
|
||||
key={tm.id}
|
||||
href={`/admin/projects/${tm.project.id}`}
|
||||
className="flex items-center justify-between p-3 rounded-lg border 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 (for jury members) */}
|
||||
{user.juryGroupMemberships && user.juryGroupMemberships.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
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 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>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Basic Information
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Expertise & Capacity — only for jury/mentor/observer/admin roles */}
|
||||
{!['APPLICANT', 'AUDIENCE'].includes(user.role) && (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{assignment.project.competitionCategory ? (
|
||||
<Badge variant="outline">
|
||||
{assignment.project.competitionCategory.replace('_', ' ')}
|
||||
{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>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Activity Log */}
|
||||
<UserActivityLog userId={userId} />
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{/* Activity Log */}
|
||||
<UserActivityLog userId={userId} />
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
</Button>
|
||||
</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 */}
|
||||
@@ -611,7 +690,6 @@ export default function MemberDetailPage() {
|
||||
</Card>
|
||||
) : (
|
||||
(() => {
|
||||
// Group evaluations by round
|
||||
const byRound = new Map<string, typeof jurorEvaluations>()
|
||||
for (const ev of jurorEvaluations) {
|
||||
const key = ev.roundName
|
||||
@@ -712,7 +790,6 @@ export default function MemberDetailPage() {
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
|
||||
{/* Super Admin Confirmation Dialog */}
|
||||
<AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}>
|
||||
<AlertDialogContent>
|
||||
@@ -725,11 +802,7 @@ export default function MemberDetailPage() {
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
onClick={() => {
|
||||
setPendingSuperAdminRole(false)
|
||||
}}
|
||||
>
|
||||
<AlertDialogCancel onClick={() => setPendingSuperAdminRole(false)}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Papa from 'papaparse'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -257,6 +258,7 @@ function TagPicker({
|
||||
}
|
||||
|
||||
export default function MemberInvitePage() {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState<Step>('input')
|
||||
const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual')
|
||||
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
|
||||
@@ -1044,11 +1046,9 @@ export default function MemberInvitePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/members">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Members
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -83,6 +83,7 @@ const defaultForm: TemplateFormData = {
|
||||
}
|
||||
|
||||
export default function MessageTemplatesPage() {
|
||||
const router = useRouter()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
@@ -183,11 +184,9 @@ export default function MessageTemplatesPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/messages">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Messages
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -135,11 +135,9 @@ export default function EditPartnerPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Edit Partner</h1>
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -66,11 +66,9 @@ export default function NewPartnerPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Add Partner</h1>
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -134,11 +134,9 @@ export default function EditProgramPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/admin/programs/${id}`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Edit Program</h1>
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -68,6 +67,7 @@ const defaultMilestoneForm: MilestoneFormData = {
|
||||
|
||||
export default function MentorshipMilestonesPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const programId = params.id as string
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
@@ -184,11 +184,9 @@ export default function MentorshipMilestonesPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/programs">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Programs
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -56,11 +56,9 @@ export default function NewProgramPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/programs">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Create Program</h1>
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -300,19 +300,17 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium">Project Not Found</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/projects">Back to Projects</Link>
|
||||
<Button className="mt-4" onClick={() => router.back()}>
|
||||
Back
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -330,11 +328,9 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/projects/${projectId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Project
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -46,6 +46,7 @@ interface MentorSuggestion {
|
||||
}
|
||||
|
||||
function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
const router = useRouter()
|
||||
const [selectedMentorId, setSelectedMentorId] = useState<string | null>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
@@ -128,11 +129,9 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/projects/${projectId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Project
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Suspense, use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -78,6 +79,7 @@ import {
|
||||
import { toast } from 'sonner'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
@@ -102,6 +104,7 @@ const evalStatusColors: Record<string, 'default' | 'secondary' | 'destructive' |
|
||||
}
|
||||
|
||||
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
const router = useRouter()
|
||||
// Fetch project + assignments + stats in a single combined query
|
||||
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery(
|
||||
{ id: projectId },
|
||||
@@ -171,6 +174,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
},
|
||||
})
|
||||
|
||||
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')
|
||||
@@ -189,19 +202,17 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium">Project Not Found</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/projects">Back to Projects</Link>
|
||||
<Button className="mt-4" onClick={() => router.back()}>
|
||||
Back
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -213,11 +224,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -365,7 +374,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<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.country}</p>
|
||||
<p className="text-sm">{project.geographicZone}{project.geographicZone && project.country ? ', ' : ''}{project.country ? <CountryDisplay country={project.country} /> : null}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -538,9 +547,25 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<Link href={`/admin/members/${member.user.id}`} className="font-medium text-sm truncate hover:underline text-primary">
|
||||
{member.user.name || 'Unnamed'}
|
||||
</Link>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
||||
</Badge>
|
||||
<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}
|
||||
@@ -790,33 +815,48 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* All Files list */}
|
||||
{/* All Files list — grouped by round */}
|
||||
{files && files.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<FileViewer
|
||||
projectId={projectId}
|
||||
files={files.map((f) => ({
|
||||
id: f.id,
|
||||
fileName: f.fileName,
|
||||
fileType: f.fileType,
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
bucket: f.bucket,
|
||||
objectKey: f.objectKey,
|
||||
pageCount: f.pageCount,
|
||||
textPreview: f.textPreview,
|
||||
detectedLang: f.detectedLang,
|
||||
langConfidence: f.langConfidence,
|
||||
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
|
||||
requirementId: f.requirementId,
|
||||
requirement: f.requirement ? {
|
||||
id: f.requirement.id,
|
||||
name: f.requirement.name,
|
||||
description: f.requirement.description,
|
||||
isRequired: f.requirement.isRequired,
|
||||
} : null,
|
||||
}))}
|
||||
groupedFiles={(() => {
|
||||
const groups = new Map<string, { roundId: string | null; roundName: string; sortOrder: number; files: typeof mappedFiles }>()
|
||||
const mappedFiles = files.map((f) => ({
|
||||
id: f.id,
|
||||
fileName: f.fileName,
|
||||
fileType: f.fileType as 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' | 'BUSINESS_PLAN' | 'VIDEO_PITCH' | 'SUPPORTING_DOC',
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
bucket: f.bucket,
|
||||
objectKey: f.objectKey,
|
||||
pageCount: f.pageCount,
|
||||
textPreview: f.textPreview,
|
||||
detectedLang: f.detectedLang,
|
||||
langConfidence: f.langConfidence,
|
||||
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
|
||||
requirementId: f.requirementId,
|
||||
requirement: f.requirement ? {
|
||||
id: f.requirement.id,
|
||||
name: f.requirement.name,
|
||||
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())
|
||||
})()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -62,6 +63,7 @@ type UploadState = {
|
||||
type UploadMap = Record<string, UploadState>
|
||||
|
||||
export default function BulkUploadPage() {
|
||||
const router = useRouter()
|
||||
const [roundId, setRoundId] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
@@ -296,10 +298,8 @@ export default function BulkUploadPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Bulk Document Upload</h1>
|
||||
|
||||
@@ -59,11 +59,9 @@ function ImportPageContent() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -246,11 +246,9 @@ function NewProjectPageContent() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link'
|
||||
import { useSearchParams, usePathname } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -145,6 +146,9 @@ function parseFiltersFromParams(
|
||||
statuses: searchParams.get('status')
|
||||
? searchParams.get('status')!.split(',')
|
||||
: [],
|
||||
roundStates: searchParams.get('roundState')
|
||||
? searchParams.get('roundState')!.split(',')
|
||||
: [],
|
||||
roundId: searchParams.get('round') || '',
|
||||
competitionCategory: searchParams.get('category') || '',
|
||||
oceanIssue: searchParams.get('issue') || '',
|
||||
@@ -179,6 +183,8 @@ function filtersToParams(
|
||||
if (filters.search) params.set('q', filters.search)
|
||||
if (filters.statuses.length > 0)
|
||||
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.competitionCategory)
|
||||
params.set('category', filters.competitionCategory)
|
||||
@@ -204,6 +210,7 @@ export default function ProjectsPage() {
|
||||
const [filters, setFilters] = useState<ProjectFilters>({
|
||||
search: parsed.search,
|
||||
statuses: parsed.statuses,
|
||||
roundStates: parsed.roundStates,
|
||||
roundId: parsed.roundId,
|
||||
competitionCategory: parsed.competitionCategory,
|
||||
oceanIssue: parsed.oceanIssue,
|
||||
@@ -309,6 +316,12 @@ export default function ProjectsPage() {
|
||||
wantsMentorship: filters.wantsMentorship,
|
||||
hasFiles: filters.hasFiles,
|
||||
hasAssignments: filters.hasAssignments,
|
||||
roundStates:
|
||||
filters.roundStates.length > 0
|
||||
? (filters.roundStates as Array<
|
||||
'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'PASSED' | 'REJECTED' | 'WITHDRAWN'
|
||||
>)
|
||||
: undefined,
|
||||
page,
|
||||
perPage,
|
||||
sortBy: sortBy as 'title' | 'category' | 'program' | 'assignments' | 'status' | 'createdAt' | undefined,
|
||||
@@ -752,7 +765,7 @@ export default function ProjectsPage() {
|
||||
/>
|
||||
|
||||
{/* 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 flex-wrap items-center gap-2 text-sm">
|
||||
{Object.entries(data.statusCounts ?? {})
|
||||
@@ -760,15 +773,43 @@ export default function ProjectsPage() {
|
||||
const order = ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN']
|
||||
return order.indexOf(a) - order.indexOf(b)
|
||||
})
|
||||
.map(([status, count]) => (
|
||||
<Badge
|
||||
key={status}
|
||||
variant={statusColors[status] || 'secondary'}
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
{count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')}
|
||||
</Badge>
|
||||
))}
|
||||
.map(([status, count]) => {
|
||||
const isActive = filters.roundStates.includes(status)
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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 && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
(page {data.page} of {data.totalPages})
|
||||
|
||||
@@ -63,6 +63,7 @@ const ISSUE_LABELS: Record<string, string> = {
|
||||
export interface ProjectFilters {
|
||||
search: string
|
||||
statuses: string[]
|
||||
roundStates: string[]
|
||||
roundId: string
|
||||
competitionCategory: string
|
||||
oceanIssue: string
|
||||
@@ -94,6 +95,7 @@ export function ProjectFiltersBar({
|
||||
|
||||
const activeFilterCount = [
|
||||
filters.statuses.length > 0,
|
||||
filters.roundStates.length > 0,
|
||||
filters.roundId !== '',
|
||||
filters.competitionCategory !== '',
|
||||
filters.oceanIssue !== '',
|
||||
@@ -114,6 +116,7 @@ export function ProjectFiltersBar({
|
||||
onChange({
|
||||
search: filters.search,
|
||||
statuses: [],
|
||||
roundStates: [],
|
||||
roundId: '',
|
||||
competitionCategory: '',
|
||||
oceanIssue: '',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
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 type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
@@ -77,6 +77,8 @@ import {
|
||||
ArrowRight,
|
||||
RotateCcw,
|
||||
ListChecks,
|
||||
FileText,
|
||||
Languages,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -150,8 +152,7 @@ const stateColors: Record<string, string> = Object.fromEntries(
|
||||
export default function RoundDetailPage() {
|
||||
const params = useParams()
|
||||
const roundId = params.roundId as string
|
||||
const searchParams = useSearchParams()
|
||||
const backUrl = searchParams.get('from')
|
||||
const router = useRouter()
|
||||
|
||||
const [config, setConfig] = useState<Record<string, unknown>>({})
|
||||
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
@@ -544,11 +545,9 @@ export default function RoundDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Round Not Found</h1>
|
||||
<p className="text-sm text-muted-foreground">This round does not exist.</p>
|
||||
@@ -620,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 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="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" />
|
||||
<span className="text-xs hidden sm:inline">{round.specialAwardId ? 'Back to Award' : 'Back to Rounds'}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<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()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="text-xs hidden sm:inline">Back</span>
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{/* 4.6 Inline-editable round name */}
|
||||
@@ -1471,6 +1468,9 @@ export default function RoundDetailPage() {
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
|
||||
{/* Document Language Summary */}
|
||||
<DocumentLanguageSummary roundId={roundId as string} />
|
||||
</TabsContent>
|
||||
|
||||
{/* ═══════════ PROJECTS TAB ═══════════ */}
|
||||
@@ -2482,3 +2482,75 @@ export default function RoundDetailPage() {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -650,6 +650,8 @@ function RoundRow({ round, isLast }: { round: RoundWithStats; isLast: boolean })
|
||||
<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">
|
||||
@@ -688,7 +690,7 @@ function RoundRow({ round, isLast }: { round: RoundWithStats; isLast: boolean })
|
||||
style={{ borderLeftColor: typeColors.dot }}
|
||||
>
|
||||
<span className={cn(
|
||||
'text-[10px] font-semibold uppercase tracking-wider shrink-0 w-[70px]',
|
||||
'text-[10px] font-semibold uppercase tracking-wider shrink-0 w-[88px]',
|
||||
typeColors.text
|
||||
)}>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -212,6 +212,7 @@ function SortableTagRow({
|
||||
}
|
||||
|
||||
export default function TagsSettingsPage() {
|
||||
const router = useRouter()
|
||||
const utils = trpc.useUtils()
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||
const [editingTag, setEditingTag] = useState<Tag | null>(null)
|
||||
@@ -384,11 +385,9 @@ export default function TagsSettingsPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/settings">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Settings
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -86,6 +86,7 @@ const defaultForm: WebhookFormData = {
|
||||
}
|
||||
|
||||
export default function WebhooksPage() {
|
||||
const router = useRouter()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
@@ -209,11 +210,16 @@ export default function WebhooksPage() {
|
||||
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 = {
|
||||
name: formData.name,
|
||||
url: formData.url,
|
||||
events: formData.events,
|
||||
headers: formData.headers.filter((h) => h.key) as Record<string, string>[] | undefined,
|
||||
headers: headersRecord,
|
||||
maxRetries: formData.maxRetries,
|
||||
}
|
||||
|
||||
@@ -254,11 +260,9 @@ export default function WebhooksPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/settings">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Settings
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline'
|
||||
import { ArrowLeft, FileText, Calendar } from 'lucide-react'
|
||||
import { ArrowLeft, FileText } from 'lucide-react'
|
||||
|
||||
export default function ApplicantCompetitionPage() {
|
||||
const router = useRouter()
|
||||
const { data: session } = useSession()
|
||||
const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
||||
enabled: !!session,
|
||||
@@ -36,11 +36,9 @@ export default function ApplicantCompetitionPage() {
|
||||
Track your progress through competition rounds
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/applicant' as Route} aria-label="Back to applicant dashboard">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -61,29 +59,6 @@ export default function ApplicantCompetitionPage() {
|
||||
<ApplicantCompetitionTimeline />
|
||||
</div>
|
||||
<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>
|
||||
<CardHeader>
|
||||
<CardTitle>Timeline Info</CardTitle>
|
||||
|
||||
@@ -8,8 +8,82 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { 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() {
|
||||
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>
|
||||
<p className="text-muted-foreground">Anonymous evaluations from jury members</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-px overflow-hidden rounded-lg border bg-border">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-card p-4">
|
||||
<Skeleton className="h-5 w-20 mb-2" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<Card key={i}>
|
||||
@@ -37,6 +119,28 @@ export default function ApplicantEvaluationsPage() {
|
||||
|
||||
const hasEvaluations = rounds && rounds.length > 0
|
||||
|
||||
// Compute global stats
|
||||
const allScores: number[] = []
|
||||
let totalEvaluations = 0
|
||||
if (rounds) {
|
||||
for (const round of rounds) {
|
||||
totalEvaluations += round.evaluationCount
|
||||
for (const ev of round.evaluations) {
|
||||
if (ev.globalScore !== null && round.roundType !== 'DELIBERATION') {
|
||||
// Normalize to 0-100 for live final scores
|
||||
const normalized = round.roundType === 'LIVE_FINAL'
|
||||
? ev.globalScore * 10
|
||||
: ev.globalScore
|
||||
allScores.push(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const globalAvg = allScores.length > 0
|
||||
? allScores.reduce((a, b) => a + b, 0) / allScores.length
|
||||
: null
|
||||
const globalHighest = allScores.length > 0 ? Math.max(...allScores) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -49,7 +153,9 @@ export default function ApplicantEvaluationsPage() {
|
||||
{!hasEvaluations ? (
|
||||
<Card>
|
||||
<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>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
Evaluations will appear here once jury review is complete and results are published.
|
||||
@@ -58,88 +164,188 @@ export default function ApplicantEvaluationsPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{rounds.map((round) => (
|
||||
<Card key={round.roundId}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{round.roundName}</CardTitle>
|
||||
<Badge variant="secondary">
|
||||
{round.evaluationCount} evaluation{round.evaluationCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</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>
|
||||
)}
|
||||
{/* Stats Summary Strip */}
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<div className="grid grid-cols-3 divide-x divide-border">
|
||||
<div className="p-4 text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 mb-1">
|
||||
<BarChart3 className="h-3.5 w-3.5 text-blue-500" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Reviews</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
<p className="text-2xl font-bold tabular-nums">{totalEvaluations}</p>
|
||||
</div>
|
||||
<div className="p-4 text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 mb-1">
|
||||
<TrendingUp className="h-3.5 w-3.5 text-emerald-500" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Avg Score</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{globalAvg !== null ? globalAvg.toFixed(1) : '—'}
|
||||
{globalAvg !== null && <span className="text-sm font-normal text-muted-foreground"> / 100</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 mb-1">
|
||||
<Award className="h-3.5 w-3.5 text-amber-500" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Highest</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{globalHighest !== null ? globalHighest : '—'}
|
||||
{globalHighest !== null && <span className="text-sm font-normal text-muted-foreground"> / 100</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</AnimatedCard>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Evaluator identities are kept confidential.
|
||||
</p>
|
||||
{/* Per-Round Cards */}
|
||||
{rounds.map((round, roundIdx) => {
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
@@ -13,12 +14,12 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { StatusTracker } from '@/components/shared/status-tracker'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
|
||||
import { WithdrawButton } from '@/components/applicant/withdraw-button'
|
||||
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
FileText,
|
||||
Calendar,
|
||||
@@ -31,7 +32,27 @@ import {
|
||||
Star,
|
||||
AlertCircle,
|
||||
Pencil,
|
||||
Loader2,
|
||||
Check,
|
||||
X,
|
||||
UserCircle,
|
||||
Trophy,
|
||||
Vote,
|
||||
Clock,
|
||||
} 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'> = {
|
||||
DRAFT: 'secondary',
|
||||
@@ -44,6 +65,9 @@ const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destru
|
||||
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() {
|
||||
const { data: session, status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
@@ -65,6 +89,17 @@ export default function ApplicantDashboardPage() {
|
||||
enabled: isAuthenticated,
|
||||
})
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
@@ -118,23 +153,24 @@ export default function ApplicantDashboardPage() {
|
||||
const programYear = project.program?.year
|
||||
const programName = project.program?.name
|
||||
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
|
||||
const canEditDescription = flags?.applicantAllowDescriptionEdit && !isRejected
|
||||
|
||||
return (
|
||||
<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-center gap-4">
|
||||
{/* Project logo — clickable for team leads to change */}
|
||||
{project.isTeamLead ? (
|
||||
<ProjectLogoUpload
|
||||
projectId={project.id}
|
||||
currentLogoUrl={data.logoUrl}
|
||||
onUploadComplete={() => utils.applicant.getMyDashboard.invalidate()}
|
||||
{/* Project logo — clickable for any team member to change */}
|
||||
<ProjectLogoUpload
|
||||
projectId={project.id}
|
||||
currentLogoUrl={data.logoUrl}
|
||||
onUploadComplete={() => utils.applicant.getMyDashboard.invalidate()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="group relative shrink-0 flex flex-col items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="group relative shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary/30 transition-all"
|
||||
>
|
||||
<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" />
|
||||
) : (
|
||||
@@ -143,17 +179,12 @@ export default function ApplicantDashboardPage() {
|
||||
<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>
|
||||
</button>
|
||||
</ProjectLogoUpload>
|
||||
) : (
|
||||
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden">
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
@@ -168,11 +199,50 @@ export default function ApplicantDashboardPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{project.isTeamLead && currentStatus !== 'REJECTED' && (currentStatus as string) !== 'WINNER' && (
|
||||
<WithdrawButton projectId={project.id} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active round deadline banner */}
|
||||
{!isRejected && openRounds.length > 0 && (() => {
|
||||
const submissionTypes = new Set(['INTAKE', 'SUBMISSION', 'MENTORING'])
|
||||
const roundsWithDeadline = openRounds.filter((r) => r.windowCloseAt && submissionTypes.has(r.roundType))
|
||||
if (roundsWithDeadline.length === 0) return null
|
||||
return roundsWithDeadline.map((round) => {
|
||||
const closeAt = new Date(round.windowCloseAt!).getTime()
|
||||
const remaining = closeAt - now
|
||||
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">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
@@ -189,12 +259,19 @@ export default function ApplicantDashboardPage() {
|
||||
<p>{project.teamName}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.description && (
|
||||
{/* Description — editable if admin allows */}
|
||||
{project.description && !canEditDescription && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Description</p>
|
||||
<p className="whitespace-pre-wrap">{project.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{canEditDescription && (
|
||||
<EditableDescription
|
||||
projectId={project.id}
|
||||
initialDescription={project.description || ''}
|
||||
/>
|
||||
)}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
|
||||
@@ -208,22 +285,27 @@ export default function ApplicantDashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-3">Additional Information</p>
|
||||
<dl className="space-y-2">
|
||||
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<dt className="text-sm text-muted-foreground capitalize">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</dt>
|
||||
<dd className="text-sm font-medium">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
{/* Metadata — filter out team members (shown in sidebar) */}
|
||||
{project.metadataJson && (() => {
|
||||
const entries = Object.entries(project.metadataJson as Record<string, unknown>)
|
||||
.filter(([key]) => !HIDDEN_METADATA_KEYS.has(key))
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-3">Additional Information</p>
|
||||
<dl className="space-y-2">
|
||||
{entries.map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between gap-4">
|
||||
<dt className="text-sm text-muted-foreground capitalize shrink-0">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</dt>
|
||||
<dd className="text-sm font-medium text-right">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Meta info row */}
|
||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground border-t pt-4 mt-4">
|
||||
@@ -260,53 +342,6 @@ export default function ApplicantDashboardPage() {
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Quick actions */}
|
||||
{!isRejected && (
|
||||
<AnimatedCard index={2}>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<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">
|
||||
<div className="rounded-xl bg-blue-500/10 p-2.5 transition-colors group-hover:bg-blue-500/20">
|
||||
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
|
||||
<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 */}
|
||||
{docCompleteness && docCompleteness.length > 0 && (
|
||||
@@ -343,7 +378,7 @@ export default function ApplicantDashboardPage() {
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Competition timeline or status tracker */}
|
||||
{/* Competition timeline */}
|
||||
<AnimatedCard index={3}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -355,7 +390,7 @@ export default function ApplicantDashboardPage() {
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Mentoring Request Card — show when there's an active MENTORING round */}
|
||||
{/* Mentoring Request Card */}
|
||||
{project.isTeamLead && openRounds.filter((r) => r.roundType === 'MENTORING').map((mentoringRound) => (
|
||||
<AnimatedCard key={mentoringRound.id} index={4}>
|
||||
<MentoringRequestCard
|
||||
@@ -373,7 +408,9 @@ export default function ApplicantDashboardPage() {
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<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
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
@@ -383,17 +420,53 @@ export default function ApplicantDashboardPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalEvaluations} evaluation{totalEvaluations !== 1 ? 's' : ''} available from{' '}
|
||||
{evaluations?.length ?? 0} round{(evaluations?.length ?? 0) !== 1 ? 's' : ''}.
|
||||
</p>
|
||||
<CardContent className="space-y-3">
|
||||
{evaluations?.map((round) => {
|
||||
const scores = round.evaluations
|
||||
.map((ev) => ev.globalScore)
|
||||
.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>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Team overview */}
|
||||
{/* Team overview — proper cards */}
|
||||
<AnimatedCard index={5}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -409,27 +482,25 @@ export default function ApplicantDashboardPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<CardContent className="space-y-2">
|
||||
{project.teamMembers.length > 0 ? (
|
||||
project.teamMembers.slice(0, 5).map((member) => (
|
||||
<div key={member.id} className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
|
||||
<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 shrink-0">
|
||||
{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">
|
||||
{member.user.name?.charAt(0).toUpperCase() || '?'}
|
||||
</span>
|
||||
<UserCircle className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{member.user.name || member.user.email}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
||||
</p>
|
||||
</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>
|
||||
))
|
||||
) : (
|
||||
@@ -499,3 +570,69 @@ export default function ApplicantDashboardPage() {
|
||||
</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'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -27,6 +26,7 @@ const ResourceRenderer = dynamic(
|
||||
|
||||
export default function ApplicantResourceDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const resourceId = params.id as string
|
||||
|
||||
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.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/applicant/resources">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Resources
|
||||
</Link>
|
||||
<Button onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -87,11 +85,9 @@ export default function ApplicantResourceDetailPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/applicant/resources">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Resources
|
||||
</Link>
|
||||
<Button variant="ghost" onClick={() => router.back()} className="-ml-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.externalUrl && (
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
Pencil,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
|
||||
const inviteSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
@@ -244,17 +245,17 @@ export default function ApplicantProjectPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Project logo — clickable for team leads */}
|
||||
{isTeamLead ? (
|
||||
<ProjectLogoUpload
|
||||
projectId={projectId}
|
||||
currentLogoUrl={logoUrl}
|
||||
onUploadComplete={() => refetchLogo()}
|
||||
{/* 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"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="group relative shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary/30 transition-all"
|
||||
>
|
||||
<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" />
|
||||
) : (
|
||||
@@ -263,17 +264,12 @@ export default function ApplicantProjectPage() {
|
||||
<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>
|
||||
</button>
|
||||
</ProjectLogoUpload>
|
||||
) : (
|
||||
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-primary/70 group-hover:text-primary transition-colors">
|
||||
{logoUrl ? 'Change' : 'Add logo'}
|
||||
</span>
|
||||
</button>
|
||||
</ProjectLogoUpload>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{project.title}
|
||||
@@ -337,7 +333,7 @@ export default function ApplicantProjectPage() {
|
||||
<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.country}</p>
|
||||
<p className="text-sm">{project.geographicZone}{project.geographicZone && project.country ? ', ' : ''}{project.country ? <CountryDisplay country={project.country} /> : null}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -388,7 +384,7 @@ export default function ApplicantProjectPage() {
|
||||
</Card>
|
||||
|
||||
{/* Project Logo */}
|
||||
{isTeamLead && projectId && (
|
||||
{projectId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
|
||||
@@ -18,6 +18,52 @@ import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
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() {
|
||||
const [state, setState] = useState<InviteState>('loading')
|
||||
const [errorType, setErrorType] = useState<string | null>(null)
|
||||
@@ -105,18 +151,21 @@ function AcceptInviteContent() {
|
||||
icon: <XCircle className="h-6 w-6 text-red-600" />,
|
||||
title: 'Invalid Invitation',
|
||||
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':
|
||||
return {
|
||||
icon: <Clock className="h-6 w-6 text-amber-600" />,
|
||||
title: 'Invitation Expired',
|
||||
description: 'This invitation has expired. Please contact your administrator to receive a new invitation.',
|
||||
redirect: '/login?expired=1',
|
||||
}
|
||||
case 'ALREADY_ACCEPTED':
|
||||
return {
|
||||
icon: <CheckCircle2 className="h-6 w-6 text-blue-600" />,
|
||||
title: 'Already Accepted',
|
||||
description: 'This invitation has already been accepted. You can sign in with your credentials.',
|
||||
redirect: '/login',
|
||||
}
|
||||
case 'AUTH_FAILED':
|
||||
return {
|
||||
@@ -148,34 +197,12 @@ function AcceptInviteContent() {
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
// Error state — auto-redirect to login after 4 seconds for known errors
|
||||
if (state === 'error') {
|
||||
const errorContent = getErrorContent()
|
||||
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>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => router.push('/login')}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
const redirectTarget = errorContent.redirect || '/login'
|
||||
|
||||
return <ErrorRedirectCard errorContent={errorContent} redirectTarget={redirectTarget} />
|
||||
}
|
||||
|
||||
// Valid invitation - show welcome
|
||||
|
||||
@@ -1,50 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useEffect, Suspense } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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'
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
Configuration: 'There is a problem with the server configuration.',
|
||||
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.',
|
||||
}
|
||||
|
||||
export default function AuthErrorPage() {
|
||||
function AuthErrorContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const error = searchParams.get('error') || '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 (
|
||||
<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">
|
||||
<Logo variant="small" />
|
||||
</div>
|
||||
<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" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Authentication Error</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">{message}</p>
|
||||
<div className="flex gap-3 justify-center border-t pt-4">
|
||||
<Button asChild>
|
||||
<Link href="/login">Return to Login</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/">Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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">
|
||||
<Logo variant="small" />
|
||||
</div>
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10">
|
||||
{isExpired ? (
|
||||
<Clock className="h-6 w-6 text-amber-600" />
|
||||
) : (
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-xl">
|
||||
{isExpired ? 'Link Expired' : 'Authentication Error'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">{message}</p>
|
||||
{isExpired && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Redirecting to login in 5 seconds...
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
||||
import type { Route } from 'next'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} 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'
|
||||
|
||||
type LoginMode = 'password' | 'magic-link'
|
||||
@@ -32,6 +33,7 @@ export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const callbackUrl = searchParams.get('callbackUrl') || '/'
|
||||
const errorParam = searchParams.get('error')
|
||||
const isExpiredLink = searchParams.get('expired') === '1'
|
||||
|
||||
const handlePasswordLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -69,6 +71,19 @@ export default function LoginPage() {
|
||||
setError(null)
|
||||
|
||||
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
|
||||
const csrfRes = await fetch('/api/auth/csrf')
|
||||
const { csrfToken } = await csrfRes.json()
|
||||
@@ -151,6 +166,15 @@ export default function LoginPage() {
|
||||
<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="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>
|
||||
<CardDescription>
|
||||
{mode === 'password'
|
||||
@@ -159,6 +183,17 @@ export default function LoginPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<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' ? (
|
||||
// Password login form
|
||||
<form onSubmit={handlePasswordLogin} className="space-y-4">
|
||||
@@ -300,6 +335,12 @@ export default function LoginPage() {
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
GripVertical,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
|
||||
export default function JuryAwardVotingPage({
|
||||
params,
|
||||
@@ -29,6 +30,7 @@ export default function JuryAwardVotingPage({
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: awardId } = use(params)
|
||||
const router = useRouter()
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data, isLoading, refetch } =
|
||||
@@ -120,11 +122,9 @@ export default function JuryAwardVotingPage({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/jury/awards">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Awards
|
||||
</Link>
|
||||
<Button variant="ghost" onClick={() => router.back()} className="-ml-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -192,7 +192,7 @@ export default function JuryAwardVotingPage({
|
||||
)}
|
||||
{project.country && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.country}
|
||||
<CountryDisplay country={project.country} />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -286,7 +286,7 @@ export default function JuryAwardVotingPage({
|
||||
)}
|
||||
{project.country && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.country}
|
||||
<CountryDisplay country={project.country} />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'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'
|
||||
@@ -8,12 +8,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowLeft, CheckCircle2, Clock, Circle } from 'lucide-react'
|
||||
import { ArrowLeft, CheckCircle2, Clock, Circle, Eye } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { JurorProgressDashboard } from '@/components/jury/juror-progress-dashboard'
|
||||
|
||||
export default function JuryRoundDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const roundId = params.roundId as string
|
||||
|
||||
const { data: assignments, isLoading } = trpc.roundAssignment.getMyAssignments.useQuery(
|
||||
@@ -38,11 +39,9 @@ export default function JuryRoundDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/jury/competitions' as Route} aria-label="Back to competitions list">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<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'
|
||||
|
||||
return (
|
||||
<Link
|
||||
<div
|
||||
key={assignment.id}
|
||||
href={`/jury/competitions/${roundId}/projects/${assignment.projectId}` as Route}
|
||||
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"
|
||||
role="button"
|
||||
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">
|
||||
<p className="font-medium truncate">{assignment.project.title}</p>
|
||||
@@ -97,12 +99,26 @@ export default function JuryRoundDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-4">
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{isCompleted ? (
|
||||
<Badge variant="default" className="bg-emerald-50 text-emerald-700 border-emerald-200">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Completed
|
||||
</Badge>
|
||||
<>
|
||||
<Badge variant="default" className="bg-emerald-50 text-emerald-700 border-emerald-200">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
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 ? (
|
||||
<Badge variant="secondary" className="bg-amber-50 text-amber-700 border-amber-200">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
@@ -115,7 +131,7 @@ export default function JuryRoundDetailPage() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { cn } from '@/lib/utils'
|
||||
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { COIDeclarationDialog } from '@/components/forms/coi-declaration-dialog'
|
||||
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert } from 'lucide-react'
|
||||
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert, Lock } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { EvaluationConfig } from '@/types/competition-configs'
|
||||
|
||||
@@ -468,8 +468,10 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
|
||||
// Check if round is 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 (
|
||||
<div className="space-y-6">
|
||||
<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
|
||||
if (coiRequired && myAssignment && !coiLoading && !coiDeclared) {
|
||||
// Read-only view for submitted evaluations in closed rounds
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -533,8 +538,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// COI conflict declared — block evaluation
|
||||
if (coiRequired && coiConflict) {
|
||||
// COI conflict declared — block evaluation (skip for read-only views)
|
||||
if (coiRequired && !isReadOnly && coiConflict) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -578,15 +583,22 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
|
||||
{isReadOnly ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Project
|
||||
</Link>
|
||||
</Button>
|
||||
Back
|
||||
</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>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||
Evaluate Project
|
||||
{isReadOnly ? 'Submitted Evaluation' : 'Evaluate Project'}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-muted-foreground">{project.title}</p>
|
||||
@@ -606,21 +618,37 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</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 */}
|
||||
<MultiWindowDocViewer roundId={roundId} projectId={projectId} />
|
||||
|
||||
<Card className="border-l-4 border-l-amber-500">
|
||||
<CardContent className="flex items-start gap-3 p-4">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">Important Reminder</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Your evaluation will be used to assess this project. Please provide thoughtful and
|
||||
constructive feedback. Your progress is automatically saved as a draft.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{!isReadOnly && (
|
||||
<Card className="border-l-4 border-l-amber-500">
|
||||
<CardContent className="flex items-start gap-3 p-4">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">Important Reminder</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Your evaluation will be used to assess this project. Please provide thoughtful and
|
||||
constructive feedback. Your progress is automatically saved as a draft.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -673,12 +701,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleCriterionChange(criterion.id, true)}
|
||||
className={cn(
|
||||
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
|
||||
currentValue === true
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200'
|
||||
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
|
||||
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50',
|
||||
isReadOnly && 'opacity-60 cursor-default'
|
||||
)}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-5 w-5" />
|
||||
@@ -686,12 +716,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleCriterionChange(criterion.id, false)}
|
||||
className={cn(
|
||||
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
|
||||
currentValue === false
|
||||
? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200'
|
||||
: 'border-border hover:border-red-300 hover:bg-red-50/50'
|
||||
: 'border-border hover:border-red-300 hover:bg-red-50/50',
|
||||
isReadOnly && 'opacity-60 cursor-default'
|
||||
)}
|
||||
>
|
||||
<ThumbsDown className="mr-2 h-5 w-5" />
|
||||
@@ -718,12 +750,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleCriterionChange(criterion.id, true)}
|
||||
className={cn(
|
||||
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
|
||||
currentValue === true
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
|
||||
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
|
||||
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50',
|
||||
isReadOnly && 'opacity-60 cursor-default'
|
||||
)}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-4 w-4" />
|
||||
@@ -731,12 +765,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleCriterionChange(criterion.id, false)}
|
||||
className={cn(
|
||||
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
|
||||
currentValue === false
|
||||
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
|
||||
: 'border-border hover:border-red-300 hover:bg-red-50/50'
|
||||
: 'border-border hover:border-red-300 hover:bg-red-50/50',
|
||||
isReadOnly && 'opacity-60 cursor-default'
|
||||
)}
|
||||
>
|
||||
<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...'}
|
||||
rows={4}
|
||||
maxLength={criterion.maxLength}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{currentValue.length}/{criterion.maxLength}
|
||||
@@ -807,6 +844,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
value={[sliderValue]}
|
||||
onValueChange={(v) => handleCriterionChange(criterion.id, v[0])}
|
||||
className="flex-1"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-4">{max}</span>
|
||||
</div>
|
||||
@@ -816,6 +854,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
<button
|
||||
key={num}
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleCriterionChange(criterion.id, num)}
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
||||
@@ -823,7 +862,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: displayValue !== undefined && displayValue > num
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted hover:bg-muted/80'
|
||||
: 'bg-muted hover:bg-muted/80',
|
||||
isReadOnly && 'cursor-default'
|
||||
)}
|
||||
>
|
||||
{num}
|
||||
@@ -856,6 +896,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
value={[globalScore ? parseInt(globalScore, 10) : 5]}
|
||||
onValueChange={(v) => handleGlobalScoreChange(v[0].toString())}
|
||||
className="flex-1"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">10</span>
|
||||
</div>
|
||||
@@ -866,6 +907,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
<button
|
||||
key={num}
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleGlobalScoreChange(num.toString())}
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
||||
@@ -873,7 +915,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: current > num
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted hover:bg-muted/80'
|
||||
: 'bg-muted hover:bg-muted/80',
|
||||
isReadOnly && 'cursor-default'
|
||||
)}
|
||||
>
|
||||
{num}
|
||||
@@ -890,7 +933,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
<Label>
|
||||
Decision <span className="text-destructive">*</span>
|
||||
</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">
|
||||
<RadioGroupItem value="accept" id="accept" />
|
||||
<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)}
|
||||
placeholder="Provide your feedback on the project..."
|
||||
rows={8}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{requireFeedback && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -931,32 +975,44 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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">
|
||||
{isReadOnly ? (
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSaveDraft}
|
||||
disabled={autosaveMutation.isPending || submitMutation.isPending}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<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'}
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'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'
|
||||
@@ -10,9 +10,11 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
||||
import { ArrowLeft, FileText, Users, MapPin, Target, Tag } from 'lucide-react'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
|
||||
export default function JuryProjectDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const roundId = params.roundId as string
|
||||
const projectId = params.projectId as string
|
||||
|
||||
@@ -42,11 +44,9 @@ export default function JuryProjectDetailPage() {
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/competitions/${roundId}` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
@@ -61,11 +61,9 @@ export default function JuryProjectDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/competitions/${roundId}` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -110,7 +108,7 @@ export default function JuryProjectDetailPage() {
|
||||
{project.country && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{project.country}
|
||||
<CountryDisplay country={project.country} />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -18,8 +19,10 @@ import {
|
||||
FileEdit,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly, formatEnumLabel } from '@/lib/utils'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
|
||||
export default function JuryAssignmentsPage() {
|
||||
const router = useRouter()
|
||||
const { data: assignments, isLoading } = trpc.assignment.myAssignments.useQuery({})
|
||||
|
||||
if (isLoading) {
|
||||
@@ -58,11 +61,9 @@ export default function JuryAssignmentsPage() {
|
||||
Projects assigned to you for evaluation
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild className="hidden md:inline-flex">
|
||||
<Link href={'/jury' as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()} className="hidden md:inline-flex">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -135,7 +136,7 @@ export default function JuryAssignmentsPage() {
|
||||
{project.title}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -27,6 +26,7 @@ const ResourceRenderer = dynamic(
|
||||
|
||||
export default function JuryResourceDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const resourceId = params.id as string
|
||||
|
||||
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.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/jury/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
<Button onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -87,11 +85,9 @@ export default function JuryResourceDetailPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/jury/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
<Button variant="ghost" onClick={() => router.back()} className="-ml-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{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">
|
||||
<BarChart3 className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Stage Summary</CardTitle>
|
||||
<CardTitle className="text-lg">Round Summary</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
|
||||
// Status badge colors
|
||||
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
@@ -336,7 +337,7 @@ export default function MentorDashboard() {
|
||||
{project.country && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{project.country}
|
||||
<CountryDisplay country={project.country} />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -58,6 +57,7 @@ import {
|
||||
EyeOff,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly, getInitials } from '@/lib/utils'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface PageProps {
|
||||
@@ -75,6 +75,7 @@ const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'ou
|
||||
}
|
||||
|
||||
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
const router = useRouter()
|
||||
const { data: project, isLoading, error } = trpc.mentor.getProjectDetail.useQuery({
|
||||
projectId,
|
||||
})
|
||||
@@ -106,11 +107,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
if (error || !project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={'/mentor' as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
@@ -122,8 +121,8 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You may not have access to view this project.
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href={'/mentor' as Route}>Back to Dashboard</Link>
|
||||
<Button className="mt-4" onClick={() => router.back()}>
|
||||
Back
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -140,11 +139,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={'/mentor' as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -255,7 +252,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Location</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Crown,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
|
||||
// Status badge colors
|
||||
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
@@ -143,7 +144,7 @@ export default function MentorProjectsPage() {
|
||||
{project.country && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{project.country}
|
||||
<CountryDisplay country={project.country} />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -27,6 +26,7 @@ const ResourceRenderer = dynamic(
|
||||
|
||||
export default function MentorResourceDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const resourceId = params.id as string
|
||||
|
||||
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.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/mentor/resources">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Resources
|
||||
</Link>
|
||||
<Button onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -87,11 +85,9 @@ export default function MentorResourceDetailPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/mentor/resources">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Resources
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.externalUrl && (
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -16,6 +14,7 @@ import { toast } from 'sonner'
|
||||
|
||||
export default function MentorWorkspaceDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const projectId = params.projectId as string
|
||||
|
||||
// Get mentor assignment for this project
|
||||
@@ -39,11 +38,9 @@ export default function MentorWorkspaceDetailPage() {
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/mentor/workspace' as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
@@ -58,11 +55,9 @@ export default function MentorWorkspaceDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/mentor/workspace' as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -20,6 +21,7 @@ const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'ou
|
||||
}
|
||||
|
||||
export default function MentorWorkspacePage() {
|
||||
const router = useRouter()
|
||||
const { data: assignments, isLoading } = trpc.mentor.getMyProjects.useQuery()
|
||||
|
||||
if (isLoading) {
|
||||
@@ -46,11 +48,9 @@ export default function MentorWorkspacePage() {
|
||||
Collaborate with your assigned mentee projects
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/mentor' as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useSession } from 'next-auth/react'
|
||||
@@ -67,6 +67,7 @@ const fileTypeLabels: Record<string, string> = {
|
||||
|
||||
export function SubmissionDetailClient() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { data: session } = useSession()
|
||||
const projectId = params.id as string
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
@@ -116,11 +117,9 @@ export function SubmissionDetailClient() {
|
||||
{error?.message || 'Submission not found'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/my-submission">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to My Submissions
|
||||
</Link>
|
||||
<Button className="mt-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -133,11 +132,9 @@ export function SubmissionDetailClient() {
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/my-submission">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to My Submissions
|
||||
</Link>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -203,10 +203,8 @@ export default function TeamManagementPage() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/my-submission/${projectId}`}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
|
||||
@@ -94,10 +94,10 @@ export default function ProfileSettingsPage() {
|
||||
setExpertiseTags(user.expertiseTags || [])
|
||||
setDigestFrequency(user.digestFrequency || 'none')
|
||||
setPreferredWorkload(user.preferredWorkload ?? null)
|
||||
const avail = user.availabilityJson as { startDate?: string; endDate?: string } | null
|
||||
if (avail) {
|
||||
setAvailabilityStart(avail.startDate || '')
|
||||
setAvailabilityEnd(avail.endDate || '')
|
||||
const avail = user.availabilityJson as Array<{ start?: string; end?: string }> | null
|
||||
if (avail && avail.length > 0) {
|
||||
setAvailabilityStart(avail[0].start || '')
|
||||
setAvailabilityEnd(avail[0].end || '')
|
||||
}
|
||||
setProfileLoaded(true)
|
||||
}
|
||||
@@ -114,10 +114,10 @@ export default function ProfileSettingsPage() {
|
||||
expertiseTags,
|
||||
digestFrequency: digestFrequency as 'none' | 'daily' | 'weekly',
|
||||
preferredWorkload: preferredWorkload ?? undefined,
|
||||
availabilityJson: (availabilityStart || availabilityEnd) ? {
|
||||
startDate: availabilityStart || undefined,
|
||||
endDate: availabilityEnd || undefined,
|
||||
} : undefined,
|
||||
availabilityJson: (availabilityStart || availabilityEnd) ? [{
|
||||
start: availabilityStart || '',
|
||||
end: availabilityEnd || '',
|
||||
}] : undefined,
|
||||
})
|
||||
toast.success('Profile updated successfully')
|
||||
refetch()
|
||||
|
||||
@@ -4,6 +4,9 @@ import { checkRateLimit } from '@/lib/rate-limit'
|
||||
const AUTH_RATE_LIMIT = 10 // requests per window
|
||||
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 {
|
||||
return (
|
||||
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) => {
|
||||
// Only rate limit POST requests (sign-in, magic link sends)
|
||||
if (req.method === 'POST') {
|
||||
const ip = getClientIp(req)
|
||||
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 { 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) {
|
||||
return new Response(JSON.stringify({ error: 'Too many authentication attempts' }), {
|
||||
return new Response(JSON.stringify({ error: 'Too many requests' }), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -34,5 +57,5 @@ function withRateLimit(handler: (req: Request) => Promise<Response>) {
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = handlers.GET
|
||||
export const POST = withRateLimit(handlers.POST as (req: Request) => Promise<Response>)
|
||||
export const GET = withGetRateLimit(handlers.GET 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 })
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Script from "next/script";
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
import { Toaster } from 'sonner'
|
||||
import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
|
||||
import { VersionGuard } from '@/components/shared/version-guard'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -21,9 +23,25 @@ export default function RootLayout({
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
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">
|
||||
<Providers>
|
||||
<VersionGuard />
|
||||
<ImpersonationBanner />
|
||||
{children}
|
||||
</Providers>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link'
|
||||
import { useSearchParams, usePathname } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
@@ -426,7 +427,28 @@ export function MembersContent() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
{user.role === 'MENTOR' ? (
|
||||
{user.role === 'APPLICANT' ? (
|
||||
(() => {
|
||||
const info = (user as unknown as { applicantRoundInfo?: { projectName: string; roundName: string; state: string } | null }).applicantRoundInfo
|
||||
if (!info) return <span className="text-sm text-muted-foreground">-</span>
|
||||
const stateColor = info.state === 'REJECTED' ? 'destructive' as const
|
||||
: info.state === 'WITHDRAWN' ? 'secondary' as const
|
||||
: info.state === 'PASSED' ? 'success' as const
|
||||
: 'outline' as const
|
||||
const stateLabel = info.state === 'REJECTED' ? 'Rejected'
|
||||
: info.state === 'WITHDRAWN' ? 'Withdrawn'
|
||||
: info.state === 'PASSED' ? 'Passed'
|
||||
: info.roundName
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium truncate max-w-[200px]">{info.projectName}</span>
|
||||
<Badge variant={stateColor} className="w-fit text-[10px] px-1.5 py-0">
|
||||
{stateLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
) : user.role === 'MENTOR' ? (
|
||||
<p>{(user as unknown as { _count: { mentorAssignments: number; assignments: number } })._count.mentorAssignments} mentored</p>
|
||||
) : (
|
||||
<p>{(user as unknown as { _count: { mentorAssignments: number; assignments: number } })._count.assignments} assigned</p>
|
||||
@@ -520,9 +542,32 @@ export function MembersContent() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Assignments</span>
|
||||
<span className="text-muted-foreground">
|
||||
{user.role === 'APPLICANT' ? 'Project' : 'Assignments'}
|
||||
</span>
|
||||
<span>
|
||||
{user.role === 'MENTOR'
|
||||
{user.role === 'APPLICANT' ? (
|
||||
(() => {
|
||||
const info = (user as unknown as { applicantRoundInfo?: { projectName: string; roundName: string; state: string } | null }).applicantRoundInfo
|
||||
if (!info) return <span className="text-muted-foreground">-</span>
|
||||
const stateColor = info.state === 'REJECTED' ? 'destructive' as const
|
||||
: info.state === 'WITHDRAWN' ? 'secondary' as const
|
||||
: info.state === 'PASSED' ? 'success' as const
|
||||
: 'outline' as const
|
||||
const stateLabel = info.state === 'REJECTED' ? 'Rejected'
|
||||
: info.state === 'WITHDRAWN' ? 'Withdrawn'
|
||||
: info.state === 'PASSED' ? 'Passed'
|
||||
: info.roundName
|
||||
return (
|
||||
<span className="flex flex-col items-end gap-0.5">
|
||||
<span className="truncate max-w-[160px]">{info.projectName}</span>
|
||||
<Badge variant={stateColor} className="text-[10px] px-1.5 py-0">
|
||||
{stateLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
)
|
||||
})()
|
||||
) : user.role === 'MENTOR'
|
||||
? `${(user as unknown as { _count: { mentorAssignments: number; assignments: number } })._count.mentorAssignments} mentored`
|
||||
: `${(user as unknown as { _count: { mentorAssignments: number; assignments: number } })._count.assignments} assigned`}
|
||||
</span>
|
||||
@@ -761,7 +806,7 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search:
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{user.nationality || <span className="text-muted-foreground">-</span>}</span>
|
||||
<span className="text-sm">{user.nationality ? <CountryDisplay country={user.nationality} /> : <span className="text-muted-foreground">-</span>}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{user.institution || <span className="text-muted-foreground">-</span>}</span>
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
AlertTriangle,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
|
||||
type AwardShortlistProps = {
|
||||
awardId: string
|
||||
@@ -342,7 +343,13 @@ export function AwardShortlist({
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[e.project.teamName, e.project.country, e.project.competitionCategory].filter(Boolean).join(', ') || '—'}
|
||||
{[e.project.teamName, e.project.competitionCategory].filter(Boolean).length > 0 || e.project.country ? (
|
||||
<>
|
||||
{[e.project.teamName, e.project.competitionCategory].filter(Boolean).join(', ')}
|
||||
{(e.project.teamName || e.project.competitionCategory) && e.project.country ? ', ' : ''}
|
||||
{e.project.country && <CountryDisplay country={e.project.country} />}
|
||||
</>
|
||||
) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -74,6 +74,7 @@ import { motion, AnimatePresence } from 'motion/react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { AwardShortlist } from './award-shortlist'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
|
||||
type FilteringDashboardProps = {
|
||||
competitionId: string
|
||||
@@ -924,7 +925,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{result.project?.teamName}
|
||||
{result.project?.country && ` \u00b7 ${result.project.country}`}
|
||||
{result.project?.country && <> · <CountryDisplay country={result.project.country} /></>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { cn } from '@/lib/utils'
|
||||
import { projectStateConfig } from '@/lib/round-config'
|
||||
import { EmailPreviewDialog } from './email-preview-dialog'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -233,7 +234,7 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
||||
{project.category === 'STARTUP' ? 'Startup' : project.category === 'BUSINESS_CONCEPT' ? 'Concept' : project.category ?? '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 hidden md:table-cell text-muted-foreground">
|
||||
{project.country ?? '-'}
|
||||
{project.country ? <CountryDisplay country={project.country} /> : '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<Badge variant="secondary" className={cn('text-xs', stateLabelColors[project.currentState] ?? '')}>
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
|
||||
const PROJECT_STATES = ['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'] as const
|
||||
type ProjectState = (typeof PROJECT_STATES)[number]
|
||||
@@ -448,7 +449,7 @@ export function ProjectStatesTable({ competitionId, roundId, roundStatus, compet
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{ps.project?.country || '—'}
|
||||
{ps.project?.country ? <CountryDisplay country={ps.project.country} /> : '—'}
|
||||
</div>
|
||||
<div>
|
||||
<Badge variant="outline" className={`text-xs ${cfg.color}`}>
|
||||
@@ -1087,7 +1088,7 @@ function AddProjectDialog({
|
||||
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{project.teamName}
|
||||
{project.country && <> · {project.country}</>}
|
||||
{project.country && <> · <CountryDisplay country={project.country} /></>}
|
||||
</p>
|
||||
</div>
|
||||
{project.competitionCategory && (
|
||||
@@ -1237,7 +1238,7 @@ function AddProjectDialog({
|
||||
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{project.teamName}
|
||||
{project.country && <> · {project.country}</>}
|
||||
{project.country && <> · <CountryDisplay country={project.country} /></>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-2 shrink-0">
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
Download,
|
||||
} from 'lucide-react'
|
||||
import type { RankedProjectEntry } from '@/server/services/ai-ranking'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -163,7 +164,7 @@ function SortableProjectRow({
|
||||
{projectInfo?.teamName && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{projectInfo.teamName}
|
||||
{projectInfo.country ? ` · ${projectInfo.country}` : ''}
|
||||
{projectInfo.country ? <> · <CountryDisplay country={projectInfo.country} /></> : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -55,6 +57,7 @@ type SemiFinalistsContentProps = {
|
||||
}
|
||||
|
||||
export function SemiFinalistsContent({ editionId }: SemiFinalistsContentProps) {
|
||||
const router = useRouter()
|
||||
const { data, isLoading } = trpc.dashboard.getSemiFinalistDetail.useQuery(
|
||||
{ editionId },
|
||||
{ enabled: !!editionId }
|
||||
@@ -116,11 +119,9 @@ export function SemiFinalistsContent({ editionId }: SemiFinalistsContentProps) {
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={'/admin' as Route}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight md:text-2xl">
|
||||
Semi-Finalists
|
||||
@@ -214,7 +215,7 @@ export function SemiFinalistsContent({ editionId }: SemiFinalistsContentProps) {
|
||||
{categoryLabels[project.category ?? ''] ?? project.category}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{project.country || '—'}</TableCell>
|
||||
<TableCell className="text-sm">{project.country ? <CountryDisplay country={project.country} /> : '—'}</TableCell>
|
||||
<TableCell className="text-sm">{project.currentRound}</TableCell>
|
||||
<TableCell>
|
||||
<TooltipProvider>
|
||||
|
||||
@@ -202,13 +202,35 @@ export function CompetitionTimelineSidebar() {
|
||||
// Is this entry after the elimination point?
|
||||
const isAfterElimination = eliminationIndex >= 0 && index > eliminationIndex
|
||||
|
||||
// Is this the current round the project is in (regardless of round status)?
|
||||
const isCurrent = !!entry.projectState && entry.projectState !== 'PASSED' && entry.projectState !== 'COMPLETED' && entry.projectState !== 'REJECTED'
|
||||
// Is this the current round? Either has an active project state,
|
||||
// or is the first round the project hasn't passed yet (for seed data
|
||||
// where project states may be missing).
|
||||
const hasActiveProjectState = !!entry.projectState && entry.projectState !== 'PASSED' && entry.projectState !== 'COMPLETED' && entry.projectState !== 'REJECTED'
|
||||
const isCurrent = !isAfterElimination && (hasActiveProjectState || (
|
||||
!isPassed && !isRejected && !isCompleted &&
|
||||
data.entries.slice(0, index).every((prev) =>
|
||||
prev.projectState === 'PASSED' || prev.projectState === 'COMPLETED' ||
|
||||
prev.status === 'ROUND_CLOSED' || prev.status === 'ROUND_ARCHIVED'
|
||||
) && index > 0
|
||||
))
|
||||
|
||||
// Determine connector segment color (no icons, just colored lines)
|
||||
// Connector color: green up to and including the current round,
|
||||
// red leading into the rejected round, neutral after.
|
||||
let connectorColor = 'bg-border'
|
||||
if ((isPassed || isCompleted) && !isAfterElimination) connectorColor = 'bg-emerald-400'
|
||||
else if (isRejected) connectorColor = 'bg-destructive/30'
|
||||
const nextEntry = data.entries[index + 1]
|
||||
const nextIsRejected = nextEntry?.projectState === 'REJECTED'
|
||||
if (isAfterElimination) {
|
||||
connectorColor = 'bg-border'
|
||||
} else if (isRejected) {
|
||||
// From rejected round onward = neutral
|
||||
connectorColor = 'bg-border'
|
||||
} else if (nextIsRejected) {
|
||||
// Connector leading INTO the rejected round = red
|
||||
connectorColor = 'bg-destructive/40'
|
||||
} else if (isCompleted || isPassed) {
|
||||
// Rounds the project has passed through = green
|
||||
connectorColor = 'bg-emerald-400'
|
||||
}
|
||||
|
||||
// Dot inner content
|
||||
let dotInner: React.ReactNode = null
|
||||
@@ -222,7 +244,7 @@ export function CompetitionTimelineSidebar() {
|
||||
} else if (isGrandFinale && (isCompleted || isPassed)) {
|
||||
dotClasses = 'bg-yellow-500 border-2 border-yellow-500'
|
||||
dotInner = <Trophy className="h-3.5 w-3.5 text-white" />
|
||||
} else if (isCompleted || isPassed) {
|
||||
} else if (isPassed || (isCompleted && !isCurrent)) {
|
||||
dotClasses = 'bg-emerald-500 border-2 border-emerald-500'
|
||||
dotInner = <Check className="h-3.5 w-3.5 text-white" />
|
||||
} else if (isCurrent) {
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { BarChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { scoreGradient } from './chart-theme'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
interface StageComparison {
|
||||
roundId: string
|
||||
@@ -30,99 +41,115 @@ export function CrossStageComparisonChart({
|
||||
)
|
||||
}
|
||||
|
||||
const baseData = data.map((round) => ({
|
||||
name: round.roundName,
|
||||
Projects: round.projectCount,
|
||||
Evaluations: round.evaluationCount,
|
||||
'Completion Rate': round.completionRate,
|
||||
'Avg Score': round.averageScore
|
||||
? parseFloat(round.averageScore.toFixed(2))
|
||||
: 0,
|
||||
}))
|
||||
const maxProjects = Math.max(...data.map((d) => d.projectCount), 1)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Round Metrics Comparison</CardTitle>
|
||||
<CardTitle className="text-base">Round Progression</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<BarChart
|
||||
data={baseData}
|
||||
index="name"
|
||||
categories={['Projects']}
|
||||
colors={['blue']}
|
||||
showLegend={false}
|
||||
yAxisWidth={40}
|
||||
className="h-[200px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Pipeline funnel visualization */}
|
||||
<div className="flex items-center gap-2 mb-6 overflow-x-auto pb-2">
|
||||
{data.map((round, idx) => (
|
||||
<div key={round.roundId} className="flex items-center gap-2">
|
||||
<div className="flex flex-col items-center min-w-[100px]">
|
||||
<div
|
||||
className="rounded-lg bg-[#053d57] flex items-center justify-center text-white font-bold text-lg tabular-nums transition-all"
|
||||
style={{
|
||||
width: `${Math.max(60, (round.projectCount / maxProjects) * 120)}px`,
|
||||
height: `${Math.max(40, (round.projectCount / maxProjects) * 60)}px`,
|
||||
}}
|
||||
>
|
||||
{round.projectCount}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1.5 text-center leading-tight max-w-[100px] truncate">
|
||||
{round.roundName}
|
||||
</p>
|
||||
</div>
|
||||
{idx < data.length - 1 && (
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
{data[idx + 1].projectCount < round.projectCount && (
|
||||
<span className="text-[10px] text-rose-500 tabular-nums font-medium">
|
||||
-{round.projectCount - data[idx + 1].projectCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Evaluations
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<BarChart
|
||||
data={baseData}
|
||||
index="name"
|
||||
categories={['Evaluations']}
|
||||
colors={['violet']}
|
||||
showLegend={false}
|
||||
yAxisWidth={40}
|
||||
className="h-[200px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Detailed metrics table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Round</TableHead>
|
||||
<TableHead className="text-right tabular-nums">Projects</TableHead>
|
||||
<TableHead className="text-right tabular-nums">Evaluations</TableHead>
|
||||
<TableHead>Completion</TableHead>
|
||||
<TableHead className="text-right">Avg Score</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((round, idx) => {
|
||||
const prevCount = idx > 0 ? data[idx - 1].projectCount : null
|
||||
const attrition = prevCount !== null && prevCount > 0
|
||||
? Math.round(((prevCount - round.projectCount) / prevCount) * 100)
|
||||
: null
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Completion Rate
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<BarChart
|
||||
data={baseData}
|
||||
index="name"
|
||||
categories={['Completion Rate']}
|
||||
colors={['emerald']}
|
||||
showLegend={false}
|
||||
maxValue={100}
|
||||
yAxisWidth={40}
|
||||
valueFormatter={(v) => `${v}%`}
|
||||
className="h-[200px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Average Score
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<BarChart
|
||||
data={baseData}
|
||||
index="name"
|
||||
categories={['Avg Score']}
|
||||
colors={['amber']}
|
||||
showLegend={false}
|
||||
maxValue={10}
|
||||
yAxisWidth={40}
|
||||
className="h-[200px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
return (
|
||||
<TableRow key={round.roundId}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{round.roundName}</span>
|
||||
{attrition !== null && attrition > 0 && (
|
||||
<Badge variant="outline" className="text-[10px] text-rose-600 border-rose-200">
|
||||
-{attrition}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums font-medium">
|
||||
{round.projectCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{round.evaluationCount > 0 ? round.evaluationCount : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{round.evaluationCount > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={round.completionRate} className="w-16 h-2" />
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{round.completionRate}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{round.averageScore !== null ? (
|
||||
<span
|
||||
className="inline-flex items-center justify-center rounded-md px-2 py-0.5 text-xs font-semibold tabular-nums min-w-[36px]"
|
||||
style={{
|
||||
backgroundColor: scoreGradient(round.averageScore),
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
{round.averageScore.toFixed(1)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -20,7 +20,7 @@ function getScoreColor(score: number | null): string {
|
||||
|
||||
function getTextColor(score: number | null): string {
|
||||
if (score === null) return 'inherit'
|
||||
return score >= 6 ? '#ffffff' : '#1a1a1a'
|
||||
return '#ffffff'
|
||||
}
|
||||
|
||||
function ScoreBadge({ score }: { score: number }) {
|
||||
@@ -73,7 +73,6 @@ function JurorSummaryRow({
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center tabular-nums text-sm">
|
||||
{scored.length}
|
||||
<span className="text-muted-foreground">/{projectCount}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
{averageScore !== null ? (
|
||||
|
||||
@@ -36,14 +36,16 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DonutChart
|
||||
data={chartData}
|
||||
category="value"
|
||||
index="name"
|
||||
colors={colors}
|
||||
showLabel={true}
|
||||
className="h-[300px]"
|
||||
/>
|
||||
<div className="flex items-center justify-center">
|
||||
<DonutChart
|
||||
data={chartData}
|
||||
category="value"
|
||||
index="name"
|
||||
colors={colors}
|
||||
showLabel={true}
|
||||
className="h-[250px] w-[250px]"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -23,8 +23,8 @@ type ActivityFeedProps = {
|
||||
|
||||
export function ActivityFeed({ activity }: ActivityFeedProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<Card className="flex flex-col overflow-hidden" style={{ maxHeight: 420 }}>
|
||||
<CardHeader className="pb-3 shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-blue/10">
|
||||
<Activity className="h-4 w-4 text-brand-blue" />
|
||||
@@ -32,7 +32,7 @@ export function ActivityFeed({ activity }: ActivityFeedProps) {
|
||||
<CardTitle className="text-base">Activity</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="overflow-y-auto min-h-0">
|
||||
{activity.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<Activity className="h-8 w-8 text-muted-foreground/30" />
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@/components/ui/card'
|
||||
import { StatusBadge } from '@/components/shared/status-badge'
|
||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||
import { getCountryName } from '@/lib/countries'
|
||||
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
|
||||
import { formatDateOnly, truncate, formatRelativeTime } from '@/lib/utils'
|
||||
|
||||
type BaseProject = {
|
||||
@@ -133,13 +133,18 @@ export function ProjectListCompact({
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
[
|
||||
project.teamName,
|
||||
project.country ? getCountryName(project.country) : null,
|
||||
formatDateOnly(project.submittedAt || project.createdAt),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' \u00b7 ')
|
||||
<>
|
||||
{[
|
||||
project.teamName,
|
||||
formatDateOnly(project.submittedAt || project.createdAt),
|
||||
].filter(Boolean).join(' \u00b7 ')}
|
||||
{project.country && (() => {
|
||||
const code = normalizeCountryToCode(project.country)
|
||||
const flag = code ? getCountryFlag(code) : null
|
||||
const name = code ? getCountryName(code) : project.country
|
||||
return <> · {flag && <span className="mr-0.5">{flag}</span>}{name}</>
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -57,23 +57,21 @@ export function RoundUserTracker({ editionId }: RoundUserTrackerProps) {
|
||||
const { rounds, byCategory } = data
|
||||
const effectiveRoundId = data.selectedRoundId
|
||||
|
||||
// Don't render if no rounds or no data
|
||||
if (!effectiveRoundId || rounds.length === 0) return null
|
||||
// Don't render if no rounds at all
|
||||
if (rounds.length === 0) return null
|
||||
|
||||
const totalProjects = byCategory.reduce((sum, c) => sum + c.total, 0)
|
||||
const totalActivated = byCategory.reduce((sum, c) => sum + c.accountsSet, 0)
|
||||
const totalPending = byCategory.reduce((sum, c) => sum + c.accountsNotSet, 0)
|
||||
|
||||
if (totalProjects === 0) return null
|
||||
|
||||
const selectedRound = rounds.find(r => r.id === effectiveRoundId)
|
||||
const selectedRound = effectiveRoundId ? rounds.find(r => r.id === effectiveRoundId) : undefined
|
||||
|
||||
const handleSendReminder = async (target: string, opts: { category?: 'STARTUP' | 'BUSINESS_CONCEPT' }) => {
|
||||
setSendingTarget(target)
|
||||
try {
|
||||
await sendReminders.mutateAsync({
|
||||
editionId,
|
||||
roundId: effectiveRoundId,
|
||||
roundId: effectiveRoundId!,
|
||||
category: opts.category,
|
||||
})
|
||||
} finally {
|
||||
@@ -89,13 +87,15 @@ export function RoundUserTracker({ editionId }: RoundUserTrackerProps) {
|
||||
<Users className="h-4 w-4 text-brand-blue" />
|
||||
Round User Tracker
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{totalActivated}/{totalProjects} activated
|
||||
</Badge>
|
||||
{totalProjects > 0 && (
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{totalActivated}/{totalProjects} activated
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Round selector */}
|
||||
<Select
|
||||
value={effectiveRoundId}
|
||||
value={effectiveRoundId ?? ''}
|
||||
onValueChange={(val) => setSelectedRoundId(val)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs mt-2">
|
||||
@@ -114,6 +114,15 @@ export function RoundUserTracker({ editionId }: RoundUserTrackerProps) {
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{totalProjects === 0 ? (
|
||||
<div className="text-center py-4">
|
||||
<Users className="h-8 w-8 text-muted-foreground/30 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No projects have passed {selectedRound?.name ?? 'this round'} yet
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Subtitle showing round context */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Projects that passed <span className="font-medium">{selectedRound?.name ?? 'this round'}</span> — account activation status
|
||||
@@ -192,6 +201,8 @@ export function RoundUserTracker({ editionId }: RoundUserTrackerProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
@@ -315,26 +318,6 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Role Switcher — visible above user section */}
|
||||
{switchableRoles.length > 0 && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<p className="mb-1.5 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
<ArrowRightLeft className="h-3 w-3" />
|
||||
Switch View
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{switchableRoles.map(([, opt]) => (
|
||||
<Link key={opt.path} href={opt.path as Route} onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1.5 px-2.5 text-xs">
|
||||
<opt.icon className="h-3 w-3" />
|
||||
{opt.label}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Profile Section */}
|
||||
<div className="border-t p-3">
|
||||
<DropdownMenu>
|
||||
@@ -393,23 +376,41 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||
{switchableRoles.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="my-1" />
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60">
|
||||
<ArrowRightLeft className="h-3 w-3" />
|
||||
Switch View
|
||||
</p>
|
||||
</div>
|
||||
{switchableRoles.map(([, opt]) => (
|
||||
<DropdownMenuItem key={opt.path} asChild>
|
||||
<Link
|
||||
href={opt.path as Route}
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
|
||||
>
|
||||
<opt.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{opt.label}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{switchableRoles.length <= 2 ? (
|
||||
// Flat list for 1-2 roles
|
||||
switchableRoles.map(([, opt]) => (
|
||||
<DropdownMenuItem key={opt.path} asChild>
|
||||
<Link
|
||||
href={opt.path as Route}
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
|
||||
>
|
||||
<opt.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{opt.label}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
) : (
|
||||
// Submenu for 3+ roles
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="flex items-center gap-2.5 rounded-md px-2 py-2">
|
||||
<ArrowRightLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Switch View</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[160px]">
|
||||
{switchableRoles.map(([, opt]) => (
|
||||
<DropdownMenuItem key={opt.path} asChild>
|
||||
<Link
|
||||
href={opt.path as Route}
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
|
||||
>
|
||||
<opt.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{opt.label}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -142,8 +142,6 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={className}
|
||||
onClick={() => logNavClick.mutate({ url: item.href })}
|
||||
>
|
||||
@@ -291,8 +289,6 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => { logNavClick.mutate({ url: item.href }); setIsMobileMenuOpen(false) }}
|
||||
className={className}
|
||||
>
|
||||
|
||||
@@ -231,7 +231,7 @@ export function EvaluationPanel({ roundId, programId }: { roundId: string; progr
|
||||
{projects
|
||||
.filter((p) => {
|
||||
const s = p.observerStatus ?? p.status
|
||||
return s !== 'NOT_REVIEWED' && s !== 'SUBMITTED'
|
||||
return s !== 'PENDING'
|
||||
})
|
||||
.slice(0, 6)
|
||||
.map((p) => (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -17,7 +18,30 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Filter, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cn, formatCategory } from '@/lib/utils'
|
||||
|
||||
type AIScreeningData = {
|
||||
meetsCriteria?: boolean
|
||||
confidence?: number
|
||||
reasoning?: string
|
||||
qualityScore?: number
|
||||
spamRisk?: boolean
|
||||
}
|
||||
|
||||
function parseAIData(json: unknown): AIScreeningData | null {
|
||||
if (!json || typeof json !== 'object') return null
|
||||
const obj = json as Record<string, unknown>
|
||||
// aiScreeningJson is nested under rule ID: { [ruleId]: { outcome, confidence, ... } }
|
||||
if (!('outcome' in obj) && !('reasoning' in obj)) {
|
||||
const keys = Object.keys(obj)
|
||||
if (keys.length > 0) {
|
||||
const inner = obj[keys[0]]
|
||||
if (inner && typeof inner === 'object') return inner as AIScreeningData
|
||||
}
|
||||
return null
|
||||
}
|
||||
return obj as unknown as AIScreeningData
|
||||
}
|
||||
|
||||
export function FilteringPanel({ roundId }: { roundId: string }) {
|
||||
const [outcomeFilter, setOutcomeFilter] = useState<string>('ALL')
|
||||
@@ -177,7 +201,7 @@ export function FilteringPanel({ roundId }: { roundId: string }) {
|
||||
{r.project?.title ?? 'Unknown'}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{r.project?.competitionCategory ?? ''} · {r.project?.country ?? ''}
|
||||
{formatCategory(r.project?.competitionCategory)} · {r.project?.country ? <CountryDisplay country={r.project.country} /> : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
@@ -199,17 +223,39 @@ export function FilteringPanel({ roundId }: { roundId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{expandedId === r.id && (
|
||||
<div className="px-4 pb-3 pt-0">
|
||||
<div className="rounded bg-muted/50 p-3 text-xs leading-relaxed text-muted-foreground">
|
||||
{(() => {
|
||||
const screening = r.aiScreeningJson as Record<string, unknown> | null
|
||||
const reasoning = (screening?.reasoning ?? screening?.explanation ?? r.overrideReason ?? 'No details available') as string
|
||||
return reasoning
|
||||
})()}
|
||||
{expandedId === r.id && (() => {
|
||||
const ai = parseAIData(r.aiScreeningJson)
|
||||
return (
|
||||
<div className="px-4 pb-3 pt-0 space-y-2">
|
||||
{ai?.confidence != null && (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{ai.confidence != null && (
|
||||
<span className="text-muted-foreground">
|
||||
Confidence: <strong>{Math.round(ai.confidence * 100)}%</strong>
|
||||
</span>
|
||||
)}
|
||||
{ai.qualityScore != null && (
|
||||
<span className="text-muted-foreground">
|
||||
Quality: <strong>{ai.qualityScore}/10</strong>
|
||||
</span>
|
||||
)}
|
||||
{ai.spamRisk && (
|
||||
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">Spam Risk</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded bg-muted/50 border p-3 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap">
|
||||
{ai?.reasoning || 'No AI reasoning available'}
|
||||
</div>
|
||||
{r.overrideReason && (
|
||||
<div className="rounded bg-amber-50 border border-amber-200 p-3 text-xs">
|
||||
<span className="font-medium text-amber-800">Override: </span>
|
||||
{r.overrideReason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
import { Inbox, Globe, FolderOpen } from 'lucide-react'
|
||||
|
||||
function relativeTime(date: Date | string): string {
|
||||
@@ -87,11 +88,11 @@ export function IntakePanel({ roundId, programId }: { roundId: string; programId
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{p.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{p.teamName ?? 'No team'} · {p.country ?? ''}
|
||||
{p.teamName ?? 'No team'} · {p.country ? <CountryDisplay country={p.country} /> : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground shrink-0">
|
||||
{p.country ?? ''}
|
||||
{p.country ? <CountryDisplay country={p.country} /> : ''}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -6,8 +6,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
import { ArrowDown, ChevronDown, ChevronUp, TrendingDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cn, formatCategory } from '@/lib/utils'
|
||||
|
||||
export function PreviousRoundSection({ currentRoundId }: { currentRoundId: string }) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
@@ -76,7 +77,7 @@ export function PreviousRoundSection({ currentRoundId }: { currentRoundId: strin
|
||||
return (
|
||||
<div key={cat.category} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium truncate">{cat.category}</span>
|
||||
<span className="font-medium truncate">{formatCategory(cat.category)}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{cat.previous} → {cat.current}
|
||||
<span className="text-rose-500 ml-1">(-{cat.eliminated})</span>
|
||||
@@ -107,7 +108,7 @@ export function PreviousRoundSection({ currentRoundId }: { currentRoundId: strin
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
{countryAttrition.map((c: any) => (
|
||||
<div key={c.country} className="flex items-center justify-between text-sm py-0.5">
|
||||
<span className="truncate">{c.country}</span>
|
||||
<span className="truncate"><CountryDisplay country={c.country} /></span>
|
||||
<Badge variant="destructive" className="tabular-nums text-xs">
|
||||
-{c.lost}
|
||||
</Badge>
|
||||
|
||||
@@ -7,7 +7,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { FileText, Upload, Users } from 'lucide-react'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||
import { AlertTriangle, FileText, Upload, Users } from 'lucide-react'
|
||||
|
||||
function relativeTime(date: Date | string): string {
|
||||
const now = Date.now()
|
||||
@@ -19,22 +21,6 @@ function relativeTime(date: Date | string): string {
|
||||
return `${Math.floor(diff / 86400)}d ago`
|
||||
}
|
||||
|
||||
const FILE_TYPE_ICONS: Record<string, string> = {
|
||||
pdf: '📄',
|
||||
image: '🖼️',
|
||||
video: '🎥',
|
||||
default: '📎',
|
||||
}
|
||||
|
||||
function fileIcon(fileType: string | null | undefined): string {
|
||||
if (!fileType) return FILE_TYPE_ICONS.default
|
||||
const ft = fileType.toLowerCase()
|
||||
if (ft.includes('pdf')) return FILE_TYPE_ICONS.pdf
|
||||
if (ft.includes('image') || ft.includes('png') || ft.includes('jpg') || ft.includes('jpeg')) return FILE_TYPE_ICONS.image
|
||||
if (ft.includes('video') || ft.includes('mp4')) return FILE_TYPE_ICONS.video
|
||||
return FILE_TYPE_ICONS.default
|
||||
}
|
||||
|
||||
export function SubmissionPanel({ roundId, programId }: { roundId: string; programId: string }) {
|
||||
const { data: roundStats, isLoading: statsLoading } = trpc.analytics.getRoundTypeStats.useQuery(
|
||||
{ roundId },
|
||||
@@ -81,7 +67,7 @@ export function SubmissionPanel({ roundId, programId }: { roundId: string; progr
|
||||
<Users className="h-4 w-4 text-blue-500" />
|
||||
<p className="text-2xl font-semibold tabular-nums">{stats.teamsSubmitted}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Teams Submitted</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Teams with Uploads</p>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -99,25 +85,47 @@ export function SubmissionPanel({ roundId, programId }: { roundId: string; progr
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{files.map((f: any) => (
|
||||
<div key={f.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<span className="text-lg shrink-0">
|
||||
{fileIcon(f.fileType)}
|
||||
</span>
|
||||
<Link
|
||||
key={f.id}
|
||||
href={`/observer/projects/${f.project?.id}` as Route}
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{/* Project avatar */}
|
||||
<ProjectLogoWithUrl
|
||||
project={{ id: f.project?.id ?? '', title: f.project?.title ?? '', logoKey: f.project?.logoKey }}
|
||||
size="sm"
|
||||
fallback="initials"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{f.fileName}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
<Link
|
||||
href={`/observer/projects/${f.project?.id}` as Route}
|
||||
className="hover:underline"
|
||||
>
|
||||
{f.project?.title ?? 'Unknown project'}
|
||||
</Link>
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="truncate">{f.project?.title ?? 'Unknown project'}</span>
|
||||
{/* Page count */}
|
||||
{f.pageCount != null && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="shrink-0">{f.pageCount} pg{f.pageCount !== 1 ? 's' : ''}</span>
|
||||
</>
|
||||
)}
|
||||
{/* Language badge */}
|
||||
{f.detectedLang && f.detectedLang !== 'und' && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className={`shrink-0 font-mono uppercase ${f.detectedLang !== 'eng' ? 'text-amber-600 font-semibold' : ''}`}>
|
||||
{f.detectedLang}
|
||||
</span>
|
||||
{f.detectedLang !== 'eng' && (
|
||||
<AlertTriangle className="h-3 w-3 text-amber-500 shrink-0" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground shrink-0">
|
||||
{f.createdAt ? relativeTime(f.createdAt) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -130,10 +138,18 @@ export function SubmissionPanel({ roundId, programId }: { roundId: string; progr
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Users className="h-4 w-4 text-emerald-500" />
|
||||
Project Teams
|
||||
</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Users className="h-4 w-4 text-emerald-500" />
|
||||
Projects
|
||||
</CardTitle>
|
||||
<Link
|
||||
href={'/observer/projects' as Route}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
See all
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
@@ -141,17 +157,21 @@ export function SubmissionPanel({ roundId, programId }: { roundId: string; progr
|
||||
<Link
|
||||
key={p.id}
|
||||
href={`/observer/projects/${p.id}` as Route}
|
||||
className="flex items-center justify-between gap-2 px-4 py-2.5 hover:bg-muted/50 transition-colors"
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<ProjectLogoWithUrl
|
||||
project={{ id: p.id, title: p.title, logoKey: (p as any).logoKey }}
|
||||
size="sm"
|
||||
fallback="initials"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{p.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{p.teamName ?? 'No team'} · {p.country ?? ''}
|
||||
</p>
|
||||
{p.country && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<CountryDisplay country={p.country} />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{p.country ?? '—'}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ClipboardList,
|
||||
Upload,
|
||||
Users,
|
||||
Trophy,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -103,6 +104,158 @@ function RoundPanel({ roundType, roundId, programId }: { roundType: string; roun
|
||||
}
|
||||
}
|
||||
|
||||
type RoundOverviewItem = {
|
||||
roundId: string
|
||||
roundName: string
|
||||
roundType: string
|
||||
roundStatus: string
|
||||
totalProjects: number
|
||||
completionRate: number
|
||||
specialAwardId?: string | null
|
||||
specialAwardName?: string | null
|
||||
}
|
||||
|
||||
function RoundNode({
|
||||
round,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
round: RoundOverviewItem
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const isActive = round.roundStatus === 'ROUND_ACTIVE'
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="text-left focus:outline-none">
|
||||
<Card className={cn(
|
||||
'w-44 shrink-0 border-2 border-border/60 shadow-sm transition-all cursor-pointer hover:shadow-md',
|
||||
isSelected && 'ring-2 ring-brand-teal shadow-md',
|
||||
)}>
|
||||
<CardContent className="p-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-xs font-semibold leading-tight truncate flex-1" title={round.roundName}>
|
||||
{round.roundName}
|
||||
</p>
|
||||
{isActive && (
|
||||
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{round.roundType.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={STATUS_BADGE_VARIANT[round.roundStatus] ?? 'outline'}
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{round.roundStatus === 'ROUND_ACTIVE'
|
||||
? 'Active'
|
||||
: round.roundStatus === 'ROUND_CLOSED'
|
||||
? 'Closed'
|
||||
: round.roundStatus === 'ROUND_DRAFT'
|
||||
? 'Draft'
|
||||
: round.roundStatus === 'ROUND_ARCHIVED'
|
||||
? 'Archived'
|
||||
: round.roundStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{round.totalProjects} project{round.totalProjects !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<Progress value={round.completionRate} className="h-1.5" />
|
||||
<p className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{round.completionRate}% complete
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PipelineView({
|
||||
rounds,
|
||||
selectedRoundId,
|
||||
onSelectRound,
|
||||
}: {
|
||||
rounds: RoundOverviewItem[]
|
||||
selectedRoundId: string
|
||||
onSelectRound: (id: string) => void
|
||||
}) {
|
||||
// Split main pipeline from award tracks
|
||||
const mainRounds = rounds.filter((r) => !r.specialAwardId)
|
||||
const awardGroups = new Map<string, { name: string; rounds: RoundOverviewItem[] }>()
|
||||
for (const r of rounds) {
|
||||
if (!r.specialAwardId) continue
|
||||
if (!awardGroups.has(r.specialAwardId)) {
|
||||
awardGroups.set(r.specialAwardId, { name: r.specialAwardName ?? 'Special Award', rounds: [] })
|
||||
}
|
||||
awardGroups.get(r.specialAwardId)!.rounds.push(r)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Main Competition Pipeline */}
|
||||
{mainRounds.length > 0 && (
|
||||
<div className="flex items-stretch gap-0 overflow-x-auto py-1 -my-1">
|
||||
{mainRounds.map((round, idx) => (
|
||||
<div key={round.roundId} className="flex items-center">
|
||||
<RoundNode
|
||||
round={round}
|
||||
isSelected={selectedRoundId === round.roundId}
|
||||
onClick={() => onSelectRound(round.roundId)}
|
||||
/>
|
||||
{idx < mainRounds.length - 1 && (
|
||||
<div className="h-px w-6 shrink-0 border-t-2 border-brand-teal" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Award Tracks */}
|
||||
{awardGroups.size > 0 && (
|
||||
<div className="space-y-3 pt-4">
|
||||
{Array.from(awardGroups.entries()).map(([awardId, group]) => (
|
||||
<div
|
||||
key={awardId}
|
||||
className="rounded-lg border border-amber-200/80 bg-amber-50/30 p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="flex items-center justify-center h-6 w-6 rounded-full bg-amber-100 shrink-0">
|
||||
<Trophy className="h-3.5 w-3.5 text-amber-600" />
|
||||
</div>
|
||||
<p className="text-xs font-semibold text-amber-800">{group.name}</p>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-amber-300 text-amber-700">
|
||||
Award Track
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-stretch gap-0 overflow-x-auto">
|
||||
{group.rounds.map((round, idx) => (
|
||||
<div key={round.roundId} className="flex items-center">
|
||||
<RoundNode
|
||||
round={round}
|
||||
isSelected={selectedRoundId === round.roundId}
|
||||
onClick={() => onSelectRound(round.roundId)}
|
||||
/>
|
||||
{idx < group.rounds.length - 1 && (
|
||||
<div className="h-px w-6 shrink-0 border-t-2 border-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
||||
const {
|
||||
programs,
|
||||
@@ -160,7 +313,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 divide-x divide-border">
|
||||
{[
|
||||
{ value: stats.projectCount, label: 'Projects' },
|
||||
{ value: stats.activeRoundName ?? `${stats.activeRoundCount} Active`, label: 'Active Round', isText: !!stats.activeRoundName },
|
||||
{ value: stats.activeRoundName ?? `${stats.activeRoundCount} Active`, label: 'Active Rounds', isText: !!stats.activeRoundName },
|
||||
{ value: avgScore, label: 'Avg Score' },
|
||||
{ value: `${stats.completionRate}%`, label: 'Completion' },
|
||||
{ value: stats.jurorCount, label: 'Jurors' },
|
||||
@@ -197,71 +350,11 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
||||
))}
|
||||
</div>
|
||||
) : roundOverview && roundOverview.rounds.length > 0 ? (
|
||||
<div className="flex items-stretch gap-0 overflow-x-auto py-1 -my-1">
|
||||
{roundOverview.rounds.map((round, idx) => {
|
||||
const isSelected = selectedRoundId === round.roundId
|
||||
const isActive = round.roundStatus === 'ROUND_ACTIVE'
|
||||
return (
|
||||
<div key={round.roundId ?? round.roundName + idx} className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedRoundId(round.roundId)}
|
||||
className="text-left focus:outline-none"
|
||||
>
|
||||
<Card className={cn(
|
||||
'w-44 shrink-0 border shadow-sm transition-all cursor-pointer hover:shadow-md',
|
||||
isSelected && 'ring-2 ring-brand-teal shadow-md',
|
||||
)}>
|
||||
<CardContent className="p-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-xs font-semibold leading-tight truncate flex-1" title={round.roundName}>
|
||||
{round.roundName}
|
||||
</p>
|
||||
{isActive && (
|
||||
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{round.roundType.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={STATUS_BADGE_VARIANT[round.roundStatus] ?? 'outline'}
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{round.roundStatus === 'ROUND_ACTIVE'
|
||||
? 'Active'
|
||||
: round.roundStatus === 'ROUND_CLOSED'
|
||||
? 'Closed'
|
||||
: round.roundStatus === 'ROUND_DRAFT'
|
||||
? 'Draft'
|
||||
: round.roundStatus === 'ROUND_ARCHIVED'
|
||||
? 'Archived'
|
||||
: round.roundStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{round.totalProjects} project{round.totalProjects !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<Progress value={round.completionRate} className="h-1.5" />
|
||||
<p className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{round.completionRate}% complete
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
{idx < roundOverview.rounds.length - 1 && (
|
||||
<div className="h-px w-6 shrink-0 border-t-2 border-brand-teal" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<PipelineView
|
||||
rounds={roundOverview.rounds}
|
||||
selectedRoundId={selectedRoundId}
|
||||
onSelectRound={setSelectedRoundId}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No round data available for this competition.</p>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -21,6 +22,7 @@ import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { StatusBadge } from '@/components/shared/status-badge'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
import {
|
||||
AlertCircle,
|
||||
Users,
|
||||
@@ -43,10 +45,12 @@ import {
|
||||
import { cn, formatDate, formatDateOnly } from '@/lib/utils'
|
||||
|
||||
export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
||||
const router = useRouter()
|
||||
const { data, isLoading } = trpc.analytics.getProjectDetail.useQuery(
|
||||
{ id: projectId },
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
const { data: flags } = trpc.settings.getFeatureFlags.useQuery()
|
||||
|
||||
const roundId = data?.assignments?.[0]?.roundId as string | undefined
|
||||
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||
@@ -77,8 +81,8 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium">Project Not Found</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href={'/observer' as Route}>Back to Dashboard</Link>
|
||||
<Button className="mt-4" onClick={() => router.back()}>
|
||||
Back
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -151,11 +155,9 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back button */}
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 -ml-2 text-muted-foreground" asChild>
|
||||
<Link href={'/observer/projects' as Route}>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 -ml-2 text-muted-foreground" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{/* Project Header */}
|
||||
@@ -173,7 +175,7 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
||||
{(project.country || project.geographicZone) && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{project.country || project.geographicZone}
|
||||
{project.country ? <CountryDisplay country={project.country} /> : project.geographicZone}
|
||||
</Badge>
|
||||
)}
|
||||
{project.competitionCategory && (
|
||||
@@ -242,6 +244,14 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
{flags?.observerShowTeamTab && project.teamMembers.length > 0 && (
|
||||
<TabsTrigger value="team">
|
||||
Team
|
||||
<Badge variant="secondary" className="ml-1.5 h-4 px-1 text-xs">
|
||||
{project.teamMembers.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{/* ── Overview Tab ── */}
|
||||
@@ -383,7 +393,7 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
||||
<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.country}</p>
|
||||
<p className="text-sm">{project.geographicZone}{project.geographicZone && project.country ? ', ' : ''}{project.country ? <CountryDisplay country={project.country} /> : null}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -567,9 +577,10 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="space-y-4">
|
||||
<ol className="relative">
|
||||
{competitionRounds.map((round, idx) => {
|
||||
const effectiveState = effectiveStates[idx]
|
||||
const isLast = idx === competitionRounds.length - 1
|
||||
|
||||
const roundAssignments = assignments.filter(
|
||||
(a) => a.roundId === round.id,
|
||||
@@ -580,15 +591,15 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
||||
let labelClass = 'text-muted-foreground'
|
||||
|
||||
if (effectiveState === 'PASSED' || effectiveState === 'COMPLETED') {
|
||||
icon = <CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" />
|
||||
icon = <CheckCircle2 className="h-5 w-5 shrink-0 text-emerald-500" />
|
||||
statusLabel = 'Passed'
|
||||
} else if (effectiveState === 'REJECTED') {
|
||||
icon = <XCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-500" />
|
||||
icon = <XCircle className="h-5 w-5 shrink-0 text-red-500" />
|
||||
statusLabel = 'Rejected at this round'
|
||||
labelClass = 'text-red-600 font-medium'
|
||||
} else if (effectiveState === 'IN_PROGRESS') {
|
||||
icon = (
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex h-3 w-3 rounded-full bg-blue-500" />
|
||||
@@ -597,22 +608,32 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
||||
)
|
||||
statusLabel = 'Active'
|
||||
} else if (effectiveState === 'NOT_REACHED') {
|
||||
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/15" />
|
||||
icon = <Circle className="h-5 w-5 shrink-0 text-muted-foreground/15" />
|
||||
statusLabel = 'Not reached'
|
||||
labelClass = 'text-muted-foreground/50 italic'
|
||||
} else if (effectiveState === 'PENDING') {
|
||||
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/40" />
|
||||
icon = <Circle className="h-5 w-5 shrink-0 text-muted-foreground/40" />
|
||||
statusLabel = 'Pending'
|
||||
} else {
|
||||
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/20" />
|
||||
icon = <Circle className="h-5 w-5 shrink-0 text-muted-foreground/20" />
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={round.id} className={cn(
|
||||
'flex items-start gap-3',
|
||||
'relative flex items-start gap-3 pb-6',
|
||||
isLast && 'pb-0',
|
||||
effectiveState === 'NOT_REACHED' && 'opacity-50',
|
||||
)}>
|
||||
{icon}
|
||||
{/* Connector line */}
|
||||
{!isLast && (
|
||||
<span
|
||||
className="absolute left-[9px] top-6 h-[calc(100%-8px)] w-px bg-border"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10 flex items-center justify-center">
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn(
|
||||
'text-sm font-medium',
|
||||
@@ -854,6 +875,48 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Team Tab ── */}
|
||||
{flags?.observerShowTeamTab && project.teamMembers.length > 0 && (
|
||||
<TabsContent value="team" className="mt-6">
|
||||
<AnimatedCard index={0}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-indigo-500/10 p-1.5">
|
||||
<Users className="h-4 w-4 text-indigo-500" />
|
||||
</div>
|
||||
Team Members
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{project.teamMembers.map((member) => (
|
||||
<div key={member.userId} className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<UserAvatar
|
||||
user={member.user}
|
||||
avatarUrl={(member.user as { avatarUrl?: string | null }).avatarUrl}
|
||||
size="md"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold truncate">
|
||||
{member.user.name || 'Unnamed'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{member.user.email}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0 text-xs">
|
||||
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* ── Files Tab ── */}
|
||||
<TabsContent value="files" className="mt-6">
|
||||
<Card>
|
||||
@@ -866,41 +929,68 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{project.files && project.files.length > 0 ? (
|
||||
<FileViewer
|
||||
projectId={projectId}
|
||||
files={project.files.map((f) => ({
|
||||
id: f.id,
|
||||
fileName: f.fileName,
|
||||
fileType: f.fileType as
|
||||
| 'EXEC_SUMMARY'
|
||||
| 'PRESENTATION'
|
||||
| 'VIDEO'
|
||||
| 'OTHER'
|
||||
| 'BUSINESS_PLAN'
|
||||
| 'VIDEO_PITCH'
|
||||
| 'SUPPORTING_DOC',
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
bucket: f.bucket,
|
||||
objectKey: f.objectKey,
|
||||
pageCount: f.pageCount,
|
||||
textPreview: f.textPreview,
|
||||
detectedLang: f.detectedLang,
|
||||
langConfidence: f.langConfidence,
|
||||
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
|
||||
requirementId: f.requirementId,
|
||||
requirement: f.requirement
|
||||
? {
|
||||
id: f.requirement.id,
|
||||
name: f.requirement.name,
|
||||
description: f.requirement.description,
|
||||
isRequired: f.requirement.isRequired,
|
||||
}
|
||||
: null,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
{project.files && project.files.length > 0 ? (() => {
|
||||
// Group files by round
|
||||
type FileItem = (typeof project.files)[number]
|
||||
const roundMap = new Map<string, { roundId: string | null; roundName: string; sortOrder: number; files: FileItem[] }>()
|
||||
// Build roundId→round lookup from competitionRounds
|
||||
const roundLookup = new Map(competitionRounds.map((r, idx) => [r.id, { name: r.name, sortOrder: idx }]))
|
||||
for (const f of project.files) {
|
||||
const key = f.roundId ?? '__none__'
|
||||
if (!roundMap.has(key)) {
|
||||
const round = f.roundId ? roundLookup.get(f.roundId) : null
|
||||
roundMap.set(key, {
|
||||
roundId: f.roundId ?? null,
|
||||
roundName: round?.name ?? 'Other Files',
|
||||
sortOrder: round?.sortOrder ?? 999,
|
||||
files: [],
|
||||
})
|
||||
}
|
||||
roundMap.get(key)!.files.push(f)
|
||||
}
|
||||
const groups = Array.from(roundMap.values()).sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
|
||||
return (
|
||||
<FileViewer
|
||||
projectId={projectId}
|
||||
groupedFiles={groups.map((g) => ({
|
||||
roundId: g.roundId,
|
||||
roundName: g.roundName,
|
||||
sortOrder: g.sortOrder,
|
||||
files: g.files.map((f) => ({
|
||||
id: f.id,
|
||||
fileName: f.fileName,
|
||||
fileType: f.fileType as
|
||||
| 'EXEC_SUMMARY'
|
||||
| 'PRESENTATION'
|
||||
| 'VIDEO'
|
||||
| 'OTHER'
|
||||
| 'BUSINESS_PLAN'
|
||||
| 'VIDEO_PITCH'
|
||||
| 'SUPPORTING_DOC',
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
bucket: f.bucket,
|
||||
objectKey: f.objectKey,
|
||||
pageCount: f.pageCount,
|
||||
textPreview: f.textPreview,
|
||||
detectedLang: f.detectedLang,
|
||||
langConfidence: f.langConfidence,
|
||||
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
|
||||
requirementId: f.requirementId,
|
||||
requirement: f.requirement
|
||||
? {
|
||||
id: f.requirement.id,
|
||||
name: f.requirement.name,
|
||||
description: f.requirement.description,
|
||||
isRequired: f.requirement.isRequired,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
})() : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CardDescription,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
@@ -56,7 +57,7 @@ export function ObserverProjectsContent() {
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [roundFilter, setRoundFilter] = useState('all')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [sortBy, setSortBy] = useState<'title' | 'score' | 'evaluations'>('title')
|
||||
const [sortBy, setSortBy] = useState<'title' | 'score' | 'evaluations' | 'status'>('status')
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||
const [page, setPage] = useState(1)
|
||||
const [perPage] = useState(20)
|
||||
@@ -86,7 +87,7 @@ export function ObserverProjectsContent() {
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleSort = (column: 'title' | 'score' | 'evaluations') => {
|
||||
const handleSort = (column: 'title' | 'score' | 'evaluations' | 'status') => {
|
||||
if (sortBy === column) {
|
||||
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
@@ -141,11 +142,19 @@ export function ObserverProjectsContent() {
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const handleRequestCsvData = useCallback(async () => {
|
||||
setCsvLoading(true)
|
||||
try {
|
||||
const allData = await new Promise<typeof projectsData>((resolve) => {
|
||||
resolve(projectsData)
|
||||
const allData = await utils.analytics.getAllProjects.fetch({
|
||||
roundId: roundFilter !== 'all' ? roundFilter : undefined,
|
||||
search: debouncedSearch || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
sortBy,
|
||||
sortDir,
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
exportAll: true,
|
||||
})
|
||||
|
||||
if (!allData?.projects) {
|
||||
@@ -158,7 +167,7 @@ export function ObserverProjectsContent() {
|
||||
teamName: p.teamName ?? '',
|
||||
country: p.country ?? '',
|
||||
roundName: p.roundName ?? '',
|
||||
status: p.status,
|
||||
status: p.observerStatus ?? p.status,
|
||||
averageScore: p.averageScore !== null ? p.averageScore.toFixed(2) : '',
|
||||
evaluationCount: p.evaluationCount,
|
||||
}))
|
||||
@@ -174,9 +183,9 @@ export function ObserverProjectsContent() {
|
||||
setCsvLoading(false)
|
||||
return undefined
|
||||
}
|
||||
}, [projectsData])
|
||||
}, [utils, roundFilter, debouncedSearch, statusFilter, sortBy, sortDir])
|
||||
|
||||
const SortIcon = ({ column }: { column: 'title' | 'score' | 'evaluations' }) => {
|
||||
const SortIcon = ({ column }: { column: 'title' | 'score' | 'evaluations' | 'status' }) => {
|
||||
if (sortBy !== column)
|
||||
return <ArrowUpDown className="ml-1 inline h-3 w-3 text-muted-foreground/50" />
|
||||
return sortDir === 'asc' ? (
|
||||
@@ -225,7 +234,7 @@ export function ObserverProjectsContent() {
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by title or team..."
|
||||
placeholder="Search by title, team, country, institution..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
@@ -251,13 +260,12 @@ export function ObserverProjectsContent() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="SUBMITTED">Submitted</SelectItem>
|
||||
<SelectItem value="NOT_REVIEWED">Not Reviewed</SelectItem>
|
||||
<SelectItem value="UNDER_REVIEW">Under Review</SelectItem>
|
||||
<SelectItem value="REVIEWED">Reviewed</SelectItem>
|
||||
<SelectItem value="SEMIFINALIST">Semi-finalist</SelectItem>
|
||||
<SelectItem value="FINALIST">Finalist</SelectItem>
|
||||
<SelectItem value="PENDING">Pending</SelectItem>
|
||||
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
|
||||
<SelectItem value="COMPLETED">Completed</SelectItem>
|
||||
<SelectItem value="PASSED">Passed</SelectItem>
|
||||
<SelectItem value="REJECTED">Rejected</SelectItem>
|
||||
<SelectItem value="WITHDRAWN">Withdrawn</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -322,7 +330,16 @@ export function ObserverProjectsContent() {
|
||||
</TableHead>
|
||||
<TableHead>Country</TableHead>
|
||||
<TableHead>Round</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('status')}
|
||||
className="inline-flex items-center hover:text-foreground transition-colors"
|
||||
>
|
||||
Status
|
||||
<SortIcon column="status" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<button
|
||||
type="button"
|
||||
@@ -378,7 +395,7 @@ export function ObserverProjectsContent() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{project.country ?? '-'}
|
||||
{project.country ? <CountryDisplay country={project.country} /> : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs whitespace-nowrap">
|
||||
|
||||
@@ -38,7 +38,6 @@ import { BarChart } from '@tremor/react'
|
||||
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
|
||||
import { ExpandableJurorTable } from './expandable-juror-table'
|
||||
|
||||
const ROUND_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -139,11 +138,7 @@ function ProgressSubTab({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Progress Overview</h2>
|
||||
<p className="text-sm text-muted-foreground">Evaluation progress across rounds</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedValue && !selectedValue.startsWith('all:') && (
|
||||
<ExportPdfButton
|
||||
@@ -214,7 +209,7 @@ function ProgressSubTab({
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Assignments</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">Juror Assignments</p>
|
||||
<p className="text-2xl font-bold mt-1">{overviewStats.assignmentCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{overviewStats.projectCount > 0
|
||||
@@ -309,7 +304,7 @@ function ProgressSubTab({
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Projects</TableHead>
|
||||
<TableHead className="text-right">Assignments</TableHead>
|
||||
<TableHead className="text-right">Juror Assignments</TableHead>
|
||||
<TableHead className="min-w-[140px]">Completion</TableHead>
|
||||
<TableHead className="text-right">Avg Days</TableHead>
|
||||
</TableRow>
|
||||
@@ -398,7 +393,7 @@ function ProgressSubTab({
|
||||
<p className="font-medium">{projects}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">Assignments</p>
|
||||
<p className="text-muted-foreground text-xs">Juror Assignments</p>
|
||||
<p className="font-medium">{assignments}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -749,42 +744,44 @@ export function EvaluationReportTabs({ roundId, programId, stages, selectedValue
|
||||
const stagesLoading = false // stages passed from parent already loaded
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<RoundTypeStatsCards roundId={roundId} />
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Evaluation Overview</h2>
|
||||
<p className="text-sm text-muted-foreground">Evaluation progress and juror performance</p>
|
||||
</div>
|
||||
<Tabs defaultValue="progress" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="progress" className="gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Progress
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="jurors" className="gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Jurors
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="scores" className="gap-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Scores
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Tabs defaultValue="progress" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="progress" className="gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Progress
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="jurors" className="gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Jurors
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="scores" className="gap-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Scores
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="progress">
|
||||
<ProgressSubTab
|
||||
selectedValue={selectedValue}
|
||||
stages={stages}
|
||||
stagesLoading={stagesLoading}
|
||||
selectedRound={selectedRound}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="progress">
|
||||
<ProgressSubTab
|
||||
selectedValue={selectedValue}
|
||||
stages={stages}
|
||||
stagesLoading={stagesLoading}
|
||||
selectedRound={selectedRound}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="jurors">
|
||||
<JurorsSubTab roundId={roundId} selectedValue={selectedValue} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="jurors">
|
||||
<JurorsSubTab roundId={roundId} selectedValue={selectedValue} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="scores">
|
||||
<ScoresSubTab selectedValue={selectedValue} programId={programId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="scores">
|
||||
<ScoresSubTab selectedValue={selectedValue} programId={programId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
@@ -98,9 +98,8 @@ export function ExpandableJurorTable({ jurors }: ExpandableJurorTableProps) {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jurors.map((j) => (
|
||||
<>
|
||||
<Fragment key={j.userId}>
|
||||
<TableRow
|
||||
key={j.userId}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => toggle(j.userId)}
|
||||
>
|
||||
@@ -179,7 +178,7 @@ export function ExpandableJurorTable({ jurors }: ExpandableJurorTableProps) {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { cn, formatCategory } from '@/lib/utils'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -25,6 +26,7 @@ import { ChevronLeft, ChevronRight, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
|
||||
import { FilteringScreeningBar } from './filtering-screening-bar'
|
||||
import { ProjectPreviewDialog } from './project-preview-dialog'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
|
||||
interface FilteringReportTabsProps {
|
||||
roundId: string
|
||||
@@ -33,6 +35,15 @@ interface FilteringReportTabsProps {
|
||||
|
||||
type OutcomeFilter = 'ALL' | 'PASSED' | 'FILTERED_OUT' | 'FLAGGED'
|
||||
|
||||
function outcomeTextColor(outcome: string): string {
|
||||
switch (outcome) {
|
||||
case 'PASSED': return 'text-emerald-700 dark:text-emerald-400'
|
||||
case 'FILTERED_OUT': return 'text-rose-700 dark:text-rose-400'
|
||||
case 'FLAGGED': return 'text-amber-700 dark:text-amber-400'
|
||||
default: return 'text-primary'
|
||||
}
|
||||
}
|
||||
|
||||
function outcomeBadge(outcome: string) {
|
||||
switch (outcome) {
|
||||
case 'PASSED':
|
||||
@@ -141,9 +152,8 @@ export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) {
|
||||
const reasoning = extractReasoning(r.aiScreeningJson)
|
||||
const isExpanded = expandedId === r.id
|
||||
return (
|
||||
<>
|
||||
<Fragment key={r.id}>
|
||||
<TableRow
|
||||
key={r.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => toggleExpand(r.id)}
|
||||
>
|
||||
@@ -156,7 +166,7 @@ export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
className="font-medium text-primary hover:underline text-left"
|
||||
className={cn('font-medium hover:underline text-left', outcomeTextColor(effectiveOutcome))}
|
||||
onClick={(e) => openPreview(r.project.id, e)}
|
||||
>
|
||||
{r.project.title}
|
||||
@@ -164,10 +174,10 @@ export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) {
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{r.project.teamName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{r.project.competitionCategory ?? '—'}
|
||||
{formatCategory(r.project.competitionCategory) || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{r.project.country ?? '—'}
|
||||
{r.project.country ? <CountryDisplay country={r.project.country} /> : '—'}
|
||||
</TableCell>
|
||||
<TableCell>{outcomeBadge(effectiveOutcome)}</TableCell>
|
||||
</TableRow>
|
||||
@@ -205,7 +215,7 @@ export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
@@ -221,14 +231,17 @@ export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) {
|
||||
return (
|
||||
<Card key={r.id}>
|
||||
<CardContent className="p-4">
|
||||
<button
|
||||
className="w-full text-left"
|
||||
<div
|
||||
className="w-full text-left cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => toggleExpand(r.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') toggleExpand(r.id) }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
className="font-medium text-sm text-primary hover:underline text-left truncate block max-w-full"
|
||||
className={cn('font-medium text-sm hover:underline text-left truncate block max-w-full', outcomeTextColor(effectiveOutcome))}
|
||||
onClick={(e) => openPreview(r.project.id, e)}
|
||||
>
|
||||
{r.project.title}
|
||||
@@ -245,10 +258,10 @@ export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-muted-foreground mt-1">
|
||||
{r.project.competitionCategory && <span>{r.project.competitionCategory}</span>}
|
||||
{r.project.country && <span>{r.project.country}</span>}
|
||||
{r.project.competitionCategory && <span>{formatCategory(r.project.competitionCategory)}</span>}
|
||||
{r.project.country && <span><CountryDisplay country={r.project.country} /></span>}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 pt-3 border-t space-y-2">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { formatCategory } from '@/lib/utils'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -12,6 +13,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { StatusBadge } from '@/components/shared/status-badge'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
import { ExternalLink, MapPin, Waves, Users } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
@@ -77,11 +79,11 @@ export function ProjectPreviewDialog({ projectId, open, onOpenChange }: ProjectP
|
||||
{data.project.country && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{data.project.country}
|
||||
<CountryDisplay country={data.project.country} />
|
||||
</Badge>
|
||||
)}
|
||||
{data.project.competitionCategory && (
|
||||
<Badge variant="secondary">{data.project.competitionCategory}</Badge>
|
||||
<Badge variant="secondary">{formatCategory(data.project.competitionCategory)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
FileText,
|
||||
MessageSquare,
|
||||
Lock,
|
||||
Globe,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
@@ -80,8 +81,9 @@ export function RoundTypeStatsCards({ roundId }: RoundTypeStatsCardsProps) {
|
||||
case 'INTAKE':
|
||||
return [
|
||||
{ label: 'Total Projects', value: (stats.totalProjects as number) ?? 0, icon: Inbox, color: '#053d57' },
|
||||
{ label: 'States', value: ((stats.byState as Array<unknown>)?.length ?? 0), icon: BarChart3, color: '#557f8c' },
|
||||
{ label: 'Categories', value: ((stats.byCategory as Array<unknown>)?.length ?? 0), icon: Filter, color: '#1e7a8a' },
|
||||
{ label: 'Start-ups', value: (stats.startupCount as number) ?? 0, icon: BarChart3, color: '#1e7a8a' },
|
||||
{ label: 'Business Concepts', value: (stats.conceptCount as number) ?? 0, icon: FileText, color: '#557f8c' },
|
||||
{ label: 'Countries', value: (stats.countryCount as number) ?? 0, icon: Globe, color: '#2d8659' },
|
||||
]
|
||||
|
||||
case 'FILTERING':
|
||||
@@ -103,7 +105,7 @@ export function RoundTypeStatsCards({ roundId }: RoundTypeStatsCardsProps) {
|
||||
case 'SUBMISSION':
|
||||
return [
|
||||
{ label: 'Total Files', value: (stats.totalFiles as number) ?? 0, icon: Upload, color: '#053d57' },
|
||||
{ label: 'Teams Submitted', value: (stats.teamsSubmitted as number) ?? 0, icon: FileText, color: '#557f8c' },
|
||||
{ label: 'Teams with Uploads', value: (stats.teamsSubmitted as number) ?? 0, icon: FileText, color: '#557f8c' },
|
||||
]
|
||||
|
||||
case 'MENTORING':
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
@@ -18,15 +17,6 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
const formSchema = z.object({
|
||||
smtp_host: z.string().min(1, 'SMTP host is required'),
|
||||
@@ -51,8 +41,6 @@ interface EmailSettingsFormProps {
|
||||
}
|
||||
|
||||
export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
|
||||
const [testDialogOpen, setTestDialogOpen] = useState(false)
|
||||
const [testEmail, setTestEmail] = useState('')
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
@@ -77,17 +65,16 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
|
||||
},
|
||||
})
|
||||
|
||||
const sendTestEmail = trpc.settings.testEmailConnection.useMutation({
|
||||
const verifyConnection = trpc.settings.testEmailConnection.useMutation({
|
||||
onSuccess: (result) => {
|
||||
setTestDialogOpen(false)
|
||||
if (result.success) {
|
||||
toast.success('Test email sent successfully')
|
||||
toast.success('SMTP connection verified successfully')
|
||||
} else {
|
||||
toast.error(`Failed to send test email: ${result.error}`)
|
||||
toast.error(`SMTP verification failed: ${result.error}`)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Test failed: ${error.message}`)
|
||||
toast.error(`Verification failed: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -107,12 +94,8 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
|
||||
updateSettings.mutate({ settings: settingsToUpdate })
|
||||
}
|
||||
|
||||
const handleSendTest = () => {
|
||||
if (!testEmail) {
|
||||
toast.error('Please enter an email address')
|
||||
return
|
||||
}
|
||||
sendTestEmail.mutate({ testEmail })
|
||||
const handleVerifyConnection = () => {
|
||||
verifyConnection.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -243,49 +226,24 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Dialog open={testDialogOpen} onOpenChange={setTestDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleVerifyConnection}
|
||||
disabled={verifyConnection.isPending}
|
||||
>
|
||||
{verifyConnection.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Send Test Email
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send Test Email</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter an email address to receive a test email
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="test@example.com"
|
||||
value={testEmail}
|
||||
onChange={(e) => setTestEmail(e.target.value)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setTestDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSendTest}
|
||||
disabled={sendTestEmail.isPending}
|
||||
>
|
||||
{sendTestEmail.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
'Send Test'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
Verify Connection
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -145,6 +145,16 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
'analytics_observer_comparison_tab',
|
||||
'analytics_pdf_enabled',
|
||||
'analytics_pdf_sections',
|
||||
'observer_show_team_tab',
|
||||
'applicant_show_evaluation_feedback',
|
||||
'applicant_show_evaluation_scores',
|
||||
'applicant_show_evaluation_criteria',
|
||||
'applicant_show_evaluation_text',
|
||||
'applicant_show_livefinal_feedback',
|
||||
'applicant_show_livefinal_scores',
|
||||
'applicant_show_deliberation_feedback',
|
||||
'applicant_hide_feedback_from_rejected',
|
||||
'applicant_allow_description_edit',
|
||||
])
|
||||
|
||||
const auditSecuritySettings = getSettingsByKeys([
|
||||
@@ -785,6 +795,75 @@ function AnalyticsSettingsSection({ settings }: { settings: Record<string, strin
|
||||
settingKey="analytics_observer_comparison_tab"
|
||||
value={settings.analytics_observer_comparison_tab || 'true'}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="Team Tab"
|
||||
description="Show team members on observer project detail page"
|
||||
settingKey="observer_show_team_tab"
|
||||
value={settings.observer_show_team_tab || 'true'}
|
||||
/>
|
||||
<div className="border-t pt-4 space-y-3">
|
||||
<Label className="text-sm font-medium">Applicant Feedback Visibility</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Control what anonymous jury feedback applicants can see on their dashboard
|
||||
</p>
|
||||
<SettingToggle
|
||||
label="Show Evaluation Feedback"
|
||||
description="Enable anonymous jury evaluation reviews for applicants"
|
||||
settingKey="applicant_show_evaluation_feedback"
|
||||
value={settings.applicant_show_evaluation_feedback || 'false'}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="Show Global Scores"
|
||||
description="Show overall score in evaluation feedback"
|
||||
settingKey="applicant_show_evaluation_scores"
|
||||
value={settings.applicant_show_evaluation_scores || 'false'}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="Show Criterion Scores"
|
||||
description="Show per-criterion breakdown in evaluation feedback"
|
||||
settingKey="applicant_show_evaluation_criteria"
|
||||
value={settings.applicant_show_evaluation_criteria || 'false'}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="Show Written Feedback"
|
||||
description="Show jury's written comments to applicants"
|
||||
settingKey="applicant_show_evaluation_text"
|
||||
value={settings.applicant_show_evaluation_text || 'false'}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="Show Live Final Feedback"
|
||||
description="Show live final jury scores to applicants"
|
||||
settingKey="applicant_show_livefinal_feedback"
|
||||
value={settings.applicant_show_livefinal_feedback || 'false'}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="Show Live Final Individual Scores"
|
||||
description="Show individual jury member scores from live finals"
|
||||
settingKey="applicant_show_livefinal_scores"
|
||||
value={settings.applicant_show_livefinal_scores || 'false'}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="Show Deliberation Results"
|
||||
description="Show deliberation voting results to applicants"
|
||||
settingKey="applicant_show_deliberation_feedback"
|
||||
value={settings.applicant_show_deliberation_feedback || 'false'}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="Hide from Rejected Projects"
|
||||
description="Hide all feedback from projects that have been rejected"
|
||||
settingKey="applicant_hide_feedback_from_rejected"
|
||||
value={settings.applicant_hide_feedback_from_rejected || 'false'}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t pt-4 space-y-3">
|
||||
<Label className="text-sm font-medium">Applicant Editing</Label>
|
||||
<SettingToggle
|
||||
label="Allow Description Editing"
|
||||
description="Let applicants edit their project description from the dashboard"
|
||||
settingKey="applicant_allow_description_edit"
|
||||
value={settings.applicant_allow_description_edit || 'false'}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t pt-4 space-y-3">
|
||||
<Label className="text-sm font-medium">PDF Reports</Label>
|
||||
<SettingToggle
|
||||
@@ -848,13 +927,13 @@ function PlatformFeaturesSection({ settings }: { settings: Record<string, string
|
||||
<Label className="text-sm font-medium">Learning Hub</Label>
|
||||
<SettingToggle
|
||||
label="Use External Learning Hub"
|
||||
description="When enabled, jury and mentor navigation links will open the external URL instead of the built-in Learning Hub"
|
||||
description="When enabled, all user navigation links (jury, mentor, applicant) will redirect to the external URL instead of the built-in Learning Hub"
|
||||
settingKey="learning_hub_external"
|
||||
value={settings.learning_hub_external || 'false'}
|
||||
/>
|
||||
<SettingInput
|
||||
label="External URL"
|
||||
description="The URL to redirect jury and mentor users to (e.g. Google Drive, Notion, etc.)"
|
||||
description="The URL to redirect users to (e.g. Google Drive, Notion, etc.)"
|
||||
settingKey="learning_hub_external_url"
|
||||
value={settings.learning_hub_external_url || ''}
|
||||
/>
|
||||
|
||||
30
src/components/shared/country-display.tsx
Normal file
30
src/components/shared/country-display.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { getCountryFlag, getCountryName, normalizeCountryToCode } from '@/lib/countries'
|
||||
|
||||
/**
|
||||
* Displays a country flag emoji followed by the country name.
|
||||
* Accepts either an ISO-2 code or a full country name.
|
||||
*/
|
||||
export function CountryDisplay({
|
||||
country,
|
||||
showName = true,
|
||||
className,
|
||||
}: {
|
||||
country: string | null | undefined
|
||||
showName?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
if (!country) return null
|
||||
|
||||
const code = normalizeCountryToCode(country)
|
||||
const flag = code ? getCountryFlag(code) : null
|
||||
const name = code ? getCountryName(code) : country
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{flag && <span className="mr-1">{flag}</span>}
|
||||
{showName && name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -28,9 +28,11 @@ const STATUS_STYLES: Record<string, { variant: BadgeProps['variant']; className?
|
||||
NOT_REVIEWED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200 dark:text-slate-400' },
|
||||
REVIEWED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
|
||||
|
||||
// Evaluation statuses
|
||||
// Round state statuses
|
||||
PENDING: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200 dark:text-slate-400' },
|
||||
IN_PROGRESS: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
|
||||
COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
|
||||
PASSED: { variant: 'default', className: 'bg-green-500/10 text-green-700 border-green-200 dark:text-green-400' },
|
||||
|
||||
// User statuses
|
||||
NONE: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-500 border-slate-200 dark:text-slate-400' },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user