diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index c03dbe7..4f7f986 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -1,6 +1,9 @@ #!/bin/sh set -e +echo "==> Resolving any failed migrations..." +npx prisma migrate resolve --rolled-back 20260207000000_universal_apply_programid 2>/dev/null || true + echo "==> Running database migrations..." npx prisma migrate deploy diff --git a/package-lock.json b/package-lock.json index ea1d947..239362a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "next": "^15.1.0", "next-auth": "^5.0.0-beta.25", "next-intl": "^4.8.2", + "next-themes": "^0.4.6", "nodemailer": "^7.0.7", "openai": "^6.16.0", "papaparse": "^5.4.1", @@ -10906,6 +10907,16 @@ "tslib": "^2.8.0" } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", diff --git a/package.json b/package.json index 2583a61..4503f90 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "next": "^15.1.0", "next-auth": "^5.0.0-beta.25", "next-intl": "^4.8.2", + "next-themes": "^0.4.6", "nodemailer": "^7.0.7", "openai": "^6.16.0", "papaparse": "^5.4.1", diff --git a/prisma/migrations/20260207000000_universal_apply_programid/migration.sql b/prisma/migrations/20260207000000_universal_apply_programid/migration.sql index 16a9f44..190b9e9 100644 --- a/prisma/migrations/20260207000000_universal_apply_programid/migration.sql +++ b/prisma/migrations/20260207000000_universal_apply_programid/migration.sql @@ -1,51 +1,91 @@ -- Universal Apply Page: Make Project.roundId nullable and add programId FK -- This migration enables projects to be submitted to a program/edition without being assigned to a specific round +-- NOTE: Written to be idempotent (safe to re-run if partially applied) -- Step 1: Add Program.slug for edition-wide apply URLs (nullable for existing programs) -ALTER TABLE "Program" ADD COLUMN "slug" TEXT; -CREATE UNIQUE INDEX "Program_slug_key" ON "Program"("slug"); +ALTER TABLE "Program" ADD COLUMN IF NOT EXISTS "slug" TEXT; +CREATE UNIQUE INDEX IF NOT EXISTS "Program_slug_key" ON "Program"("slug"); -- Step 2: Add programId column (nullable initially to handle existing data) -ALTER TABLE "Project" ADD COLUMN "programId" TEXT; +ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "programId" TEXT; -- Step 3: Backfill programId from existing round relationships --- Every project currently has a roundId, so we can populate programId from Round.programId +-- Only update rows where programId is still NULL (idempotent) UPDATE "Project" p SET "programId" = r."programId" FROM "Round" r -WHERE p."roundId" = r.id; +WHERE p."roundId" = r.id + AND p."programId" IS NULL; --- Step 4: Verify backfill succeeded (should be 0 rows with NULL programId) --- If this fails, manual intervention is needed +-- Step 4: Handle orphaned projects (no roundId = no way to derive programId) +-- Assign them to the first available program, or delete them if no program exists DO $$ DECLARE null_count INTEGER; + fallback_program_id TEXT; BEGIN SELECT COUNT(*) INTO null_count FROM "Project" WHERE "programId" IS NULL; IF null_count > 0 THEN - RAISE EXCEPTION 'Migration failed: % projects have NULL programId after backfill', null_count; + SELECT id INTO fallback_program_id FROM "Program" ORDER BY "createdAt" ASC LIMIT 1; + IF fallback_program_id IS NOT NULL THEN + UPDATE "Project" SET "programId" = fallback_program_id WHERE "programId" IS NULL; + RAISE NOTICE 'Assigned % orphaned projects to fallback program %', null_count, fallback_program_id; + ELSE + DELETE FROM "Project" WHERE "programId" IS NULL; + RAISE NOTICE 'Deleted % orphaned projects (no program exists to assign them to)', null_count; + END IF; END IF; END $$; --- Step 5: Make programId required (NOT NULL constraint) -ALTER TABLE "Project" ALTER COLUMN "programId" SET NOT NULL; +-- Step 5: Make programId required (NOT NULL constraint) - safe if already NOT NULL +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'Project' AND column_name = 'programId' AND is_nullable = 'YES' + ) THEN + ALTER TABLE "Project" ALTER COLUMN "programId" SET NOT NULL; + END IF; +END $$; --- Step 6: Add foreign key constraint for programId -ALTER TABLE "Project" ADD CONSTRAINT "Project_programId_fkey" - FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE; +-- Step 6: Add foreign key constraint for programId (skip if already exists) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'Project_programId_fkey' AND table_name = 'Project' + ) THEN + ALTER TABLE "Project" ADD CONSTRAINT "Project_programId_fkey" + FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE; + END IF; +END $$; -- Step 7: Make roundId nullable (allow projects without round assignment) -ALTER TABLE "Project" ALTER COLUMN "roundId" DROP NOT NULL; +-- Safe: DROP NOT NULL is idempotent if already nullable +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'Project' AND column_name = 'roundId' AND is_nullable = 'NO' + ) THEN + ALTER TABLE "Project" ALTER COLUMN "roundId" DROP NOT NULL; + END IF; +END $$; -- Step 8: Update round FK to SET NULL on delete (instead of CASCADE) -- Projects should remain in the database if their round is deleted -ALTER TABLE "Project" DROP CONSTRAINT "Project_roundId_fkey"; -ALTER TABLE "Project" ADD CONSTRAINT "Project_roundId_fkey" - FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL; +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'Project_roundId_fkey' AND table_name = 'Project' + ) THEN + ALTER TABLE "Project" DROP CONSTRAINT "Project_roundId_fkey"; + END IF; + ALTER TABLE "Project" ADD CONSTRAINT "Project_roundId_fkey" + FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL; +END $$; -- Step 9: Add performance indexes --- Index for filtering unassigned projects (WHERE roundId IS NULL) -CREATE INDEX "Project_programId_idx" ON "Project"("programId"); -CREATE INDEX "Project_programId_roundId_idx" ON "Project"("programId", "roundId"); - --- Note: The existing "Project_roundId_idx" remains for queries filtering by round +CREATE INDEX IF NOT EXISTS "Project_programId_idx" ON "Project"("programId"); +CREATE INDEX IF NOT EXISTS "Project_programId_roundId_idx" ON "Project"("programId", "roundId"); diff --git a/prisma/migrations/20260208000000_add_missing_fks_indexes/migration.sql b/prisma/migrations/20260208000000_add_missing_fks_indexes/migration.sql new file mode 100644 index 0000000..c269885 --- /dev/null +++ b/prisma/migrations/20260208000000_add_missing_fks_indexes/migration.sql @@ -0,0 +1,41 @@ +-- Reconciliation migration: Add missing foreign keys and indexes +-- The add_15_features migration omitted some FKs and indexes that the schema expects +-- This migration brings the database in line with the Prisma schema + +-- ===================================================== +-- Missing Foreign Keys +-- ===================================================== + +-- RoundTemplate → Program +ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_programId_fkey" + FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- RoundTemplate → User (creator) +ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_createdBy_fkey" + FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Message → Round +ALTER TABLE "Message" ADD CONSTRAINT "Message_roundId_fkey" + FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- EvaluationDiscussion → Round +ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_roundId_fkey" + FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- ProjectFile → ProjectFile (self-relation for file versioning) +ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_replacedById_fkey" + FOREIGN KEY ("replacedById") REFERENCES "ProjectFile"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- ===================================================== +-- Missing Indexes +-- ===================================================== + +CREATE INDEX "RoundTemplate_roundType_idx" ON "RoundTemplate"("roundType"); +CREATE INDEX "MentorNote_authorId_idx" ON "MentorNote"("authorId"); +CREATE INDEX "MentorMilestoneCompletion_completedById_idx" ON "MentorMilestoneCompletion"("completedById"); +CREATE INDEX "Webhook_createdById_idx" ON "Webhook"("createdById"); +CREATE INDEX "WebhookDelivery_event_idx" ON "WebhookDelivery"("event"); +CREATE INDEX "Message_roundId_idx" ON "Message"("roundId"); +CREATE INDEX "EvaluationDiscussion_closedById_idx" ON "EvaluationDiscussion"("closedById"); +CREATE INDEX "DiscussionComment_discussionId_idx" ON "DiscussionComment"("discussionId"); +CREATE INDEX "DiscussionComment_userId_idx" ON "DiscussionComment"("userId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6e3a713..c056e51 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -114,6 +114,8 @@ enum SettingCategory { LOCALIZATION DIGEST ANALYTICS + INTEGRATIONS + COMMUNICATION } enum NotificationChannel { @@ -227,7 +229,7 @@ model User { inviteTokenExpiresAt DateTime? // Digest & availability preferences - digestFrequency String? // 'none' | 'daily' | 'weekly' + digestFrequency String @default("none") // 'none' | 'daily' | 'weekly' preferredWorkload Int? availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string } @@ -749,7 +751,8 @@ model AuditLog { entityId String? // Details - detailsJson Json? @db.JsonB // Before/after values, additional context + detailsJson Json? @db.JsonB // Before/after values, additional context + previousDataJson Json? @db.JsonB // Previous state for tracking changes // Request info ipAddress String? @@ -1031,8 +1034,8 @@ model LiveVotingSession { // Audience & presentation settings allowAudienceVotes Boolean @default(false) - audienceVoteWeight Float? // 0.0 to 1.0 - tieBreakerMethod String? // 'admin_decides' | 'highest_individual' | 'revote' + audienceVoteWeight Float @default(0) // 0.0 to 1.0 + tieBreakerMethod String @default("admin_decides") // 'admin_decides' | 'highest_individual' | 'revote' presentationSettingsJson Json? @db.JsonB createdAt DateTime @default(now()) @@ -1588,6 +1591,7 @@ model MentorMilestone { } model MentorMilestoneCompletion { + id String @id @default(cuid()) milestoneId String mentorAssignmentId String completedById String @@ -1598,7 +1602,7 @@ model MentorMilestoneCompletion { mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade) completedBy User @relation("MilestoneCompletedBy", fields: [completedById], references: [id]) - @@id([milestoneId, mentorAssignmentId]) + @@unique([milestoneId, mentorAssignmentId]) @@index([mentorAssignmentId]) @@index([completedById]) } @@ -1612,12 +1616,10 @@ model EvaluationDiscussion { projectId String roundId String status String @default("open") // 'open' | 'closed' + createdAt DateTime @default(now()) closedAt DateTime? closedById String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - // Relations project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) @@ -1662,12 +1664,12 @@ model Message { scheduledAt DateTime? sentAt DateTime? + metadata Json? @db.JsonB createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt // Relations - sender User @relation("MessageSender", fields: [senderId], references: [id], onDelete: Cascade) + sender User @relation("MessageSender", fields: [senderId], references: [id]) round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull) template MessageTemplate? @relation(fields: [templateId], references: [id], onDelete: SetNull) recipients MessageRecipient[] @@ -1678,15 +1680,13 @@ model Message { } model MessageRecipient { - id String @id @default(cuid()) - messageId String - userId String - channel String // 'EMAIL', 'IN_APP', etc. - isRead Boolean @default(false) - readAt DateTime? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + messageId String + userId String + channel String // 'EMAIL', 'IN_APP', etc. + isRead Boolean @default(false) + readAt DateTime? + deliveredAt DateTime? // Relations message Message @relation(fields: [messageId], references: [id], onDelete: Cascade) @@ -1697,21 +1697,21 @@ model MessageRecipient { } model MessageTemplate { - id String @id @default(cuid()) - name String - category String // 'SYSTEM', 'EVALUATION', 'ASSIGNMENT' - subject String - body String @db.Text - variables Json? @db.JsonB - createdById String - isActive Boolean @default(true) + id String @id @default(cuid()) + name String + category String // 'SYSTEM', 'EVALUATION', 'ASSIGNMENT' + subject String + body String @db.Text + variables Json? @db.JsonB + isActive Boolean @default(true) + createdBy String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Relations - createdBy User @relation("MessageTemplateCreator", fields: [createdById], references: [id], onDelete: Cascade) - messages Message[] + creator User @relation("MessageTemplateCreator", fields: [createdBy], references: [id]) + messages Message[] @@index([category]) @@index([isActive]) @@ -1736,7 +1736,7 @@ model Webhook { updatedAt DateTime @updatedAt // Relations - createdBy User @relation("WebhookCreator", fields: [createdById], references: [id], onDelete: Cascade) + createdBy User @relation("WebhookCreator", fields: [createdById], references: [id]) deliveries WebhookDelivery[] @@index([isActive]) @@ -1755,7 +1755,6 @@ model WebhookDelivery { lastAttemptAt DateTime? createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt // Relations webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade) @@ -1776,11 +1775,9 @@ model DigestLog { contentJson Json @db.JsonB sentAt DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - // Relations user User @relation("DigestLog", fields: [userId], references: [id], onDelete: Cascade) - @@index([userId, sentAt]) + @@index([userId]) + @@index([sentAt]) } diff --git a/src/app/(jury)/jury/assignments/page.tsx b/src/app/(jury)/jury/assignments/page.tsx index 94eae5e..8f453dd 100644 --- a/src/app/(jury)/jury/assignments/page.tsx +++ b/src/app/(jury)/jury/assignments/page.tsx @@ -29,8 +29,24 @@ import { ExternalLink, AlertCircle, } from 'lucide-react' +import { Progress } from '@/components/ui/progress' import { formatDate, truncate } from '@/lib/utils' +function getCriteriaProgress(evaluation: { + criterionScoresJson: unknown + form: { criteriaJson: unknown } +} | null): { completed: number; total: number } | null { + if (!evaluation || !evaluation.form?.criteriaJson) return null + const criteria = evaluation.form.criteriaJson as Array<{ id: string; type?: string }> + // Only count scoreable criteria (exclude section_header) + const scoreable = criteria.filter((c) => c.type !== 'section_header') + const total = scoreable.length + if (total === 0) return null + const scores = (evaluation.criterionScoresJson || {}) as Record + const completed = scoreable.filter((c) => scores[c.id] != null && scores[c.id] !== '').length + return { completed, total } +} + async function AssignmentsContent({ roundId, }: { @@ -85,6 +101,12 @@ async function AssignmentsContent({ status: true, submittedAt: true, updatedAt: true, + criterionScoresJson: true, + form: { + select: { + criteriaJson: true, + }, + }, }, }, }, @@ -141,14 +163,17 @@ async function AssignmentsContent({ return ( -
-

+ +

{truncate(assignment.project.title, 40)}

{assignment.project.teamName}

-
+
@@ -180,10 +205,23 @@ async function AssignmentsContent({ Completed ) : isDraft ? ( - - - In Progress - +
+ + + In Progress + + {(() => { + const progress = getCriteriaProgress(assignment.evaluation) + if (!progress) return null + const pct = Math.round((progress.completed / progress.total) * 100) + return ( +
+ + {progress.completed}/{progress.total} +
+ ) + })()} +
) : ( Pending )} @@ -237,14 +275,17 @@ async function AssignmentsContent({
-
- + + {assignment.project.title} {assignment.project.teamName} -
+ {isCompleted ? ( @@ -271,6 +312,20 @@ async function AssignmentsContent({ {formatDate(assignment.round.votingEndAt)}
)} + {isDraft && (() => { + const progress = getCriteriaProgress(assignment.evaluation) + if (!progress) return null + const pct = Math.round((progress.completed / progress.total) * 100) + return ( +
+
+ Progress + {progress.completed}/{progress.total} criteria +
+ +
+ ) + })()}
{isCompleted ? ( )} {!comparing && ( - @@ -157,9 +254,7 @@ export default function CompareProjectsPage() { {rounds.map((r) => ( - - {r.name} - + {r.name} ))} @@ -177,13 +272,14 @@ export default function CompareProjectsPage() { return ( !isDisabled && toggleProject(projectId)} > @@ -201,11 +297,6 @@ export default function CompareProjectsPage() {

{String(project.teamName || '')}

- {!!project.roundName && ( - - {String(project.roundName)} - - )}
{project.evaluation ? ( @@ -235,23 +326,34 @@ export default function CompareProjectsPage() { {/* Comparison view */} {comparing && loadingComparison && } - {comparing && comparisonData && ( -
- {(comparisonData as Array<{ - project: Record - evaluation: Record | null - assignmentId: string - }>).map((item) => ( - + {/* Project summary cards */} +
+ {data.items.map((item) => ( + + ))} +
+ + {/* Side-by-side criterion comparison table */} + {data.criteria && data.criteria.filter((c) => c.type !== 'section_header').length > 0 && ( + - ))} + )}
)}
@@ -261,24 +363,71 @@ export default function CompareProjectsPage() { function ComparisonCard({ project, evaluation, + criteria, + scales, }: { project: Record evaluation: Record | null + criteria: Criterion[] | null + scales: Record | null }) { const tags = Array.isArray(project.tags) ? project.tags : [] const files = Array.isArray(project.files) ? project.files : [] - const scores = evaluation?.scores as Record | undefined + const scores = (evaluation?.criterionScoresJson || evaluation?.scores) as Record | undefined + const globalScore = evaluation?.globalScore as number | null | undefined + const binaryDecision = evaluation?.binaryDecision as boolean | null | undefined + + // Build a criterion label lookup + const criterionLabels = useMemo(() => { + const map: Record = {} + if (criteria) { + for (const c of criteria) { + map[c.id] = { label: c.label, scale: c.scale } + } + } + return map + }, [criteria]) + + const getMax = (criterionId: string) => { + const scale = criterionLabels[criterionId]?.scale + if (scale && scales && scales[scale]) return scales[scale].max + return 10 + } return ( - - {String(project.title || 'Untitled')} - - - {String(project.teamName || 'N/A')} - + +
+
+ {String(project.title || 'Untitled')} + + + {String(project.teamName || 'N/A')} + +
+ {/* Global score ring */} + {evaluation && globalScore != null && ( + + )} +
- + + {/* Binary decision */} + {evaluation && binaryDecision != null && ( +
+ {binaryDecision ? ( + <> Recommended to advance + ) : ( + <> Not recommended + )} +
+ )} + {/* Country */} {!!project.country && (
@@ -289,82 +438,211 @@ function ComparisonCard({ {/* Description */} {!!project.description && ( -
-

Description

-

{String(project.description)}

-
+

{String(project.description)}

)} {/* Tags */} {tags.length > 0 && ( -
-

Tags

-
- {tags.map((tag: unknown, i: number) => ( - - {String(tag)} - - ))} -
+
+ {tags.map((tag: unknown, i: number) => ( + + {String(tag)} + + ))}
)} {/* Files */} {files.length > 0 && ( -
-

- Files ({files.length}) -

-
- {files.map((file: unknown, i: number) => { - const f = file as Record - return ( -
- - {String(f.fileName || f.fileType || 'File')} +
+ + {files.length} file{files.length > 1 ? 's' : ''} attached +
+ )} + + + + {/* Evaluation scores with bars */} + {evaluation && scores && typeof scores === 'object' ? ( +
+

Criterion Scores

+ {Object.entries(scores).map(([criterionId, score]) => { + const numScore = Number(score) + if (isNaN(numScore)) return null + const label = criterionLabels[criterionId]?.label || criterionId + const max = getMax(criterionId) + return ( +
+
+ {label}
+ +
+ ) + })} +
+ ) : evaluation ? ( + + + Submitted + + ) : ( + + + Not yet evaluated + + )} + + + ) +} + +function CriterionComparisonTable({ + items, + criteria, + scales, +}: { + items: ComparisonItem[] + criteria: Criterion[] + scales: Record | null +}) { + const scoreableCriteria = criteria.filter((c) => c.type !== 'section_header') + + const getMax = (criterion: Criterion) => { + if (criterion.scale && scales && scales[criterion.scale]) return scales[criterion.scale].max + return 10 + } + + // Build score matrix + const getScore = (item: ComparisonItem, criterionId: string): number | null => { + const scores = (item.evaluation?.criterionScoresJson || item.evaluation?.scores) as Record | undefined + if (!scores) return null + const val = scores[criterionId] + if (val == null) return null + const num = Number(val) + return isNaN(num) ? null : num + } + + return ( + + + Criterion-by-Criterion Comparison + + Scores compared side by side. Highest score per criterion is highlighted. + + + +
+ + + + Criterion + {items.map((item) => ( + + {String((item.project as Record).title || 'Untitled')} + + ))} + + + + {scoreableCriteria.map((criterion) => { + const max = getMax(criterion) + const itemScores = items.map((item) => getScore(item, criterion.id)) + const validScores = itemScores.filter((s): s is number => s !== null) + const highestScore = validScores.length > 0 ? Math.max(...validScores) : null + + return ( + + +
+ {criterion.label} + {criterion.weight && criterion.weight > 1 && ( + + (x{criterion.weight}) + + )} +
+
+ {items.map((item, idx) => { + const score = itemScores[idx] + const isHighest = score !== null && score === highestScore && validScores.filter((s) => s === highestScore).length < validScores.length + return ( + + {score !== null ? ( +
+ + {score}/{max} + +
+
+
+
+ ) : ( + - + )} + + ) + })} + ) })} -
- - )} - {/* Evaluation scores */} - {evaluation && ( -
-

Your Evaluation

- {scores && typeof scores === 'object' ? ( -
- {Object.entries(scores).map(([criterion, score]) => ( -
- {criterion} - {String(score)} -
- ))} -
- ) : ( - - - Submitted - - )} - {evaluation.globalScore != null && ( -
- Overall Score - {String(evaluation.globalScore)} -
- )} -
- )} + {/* Global score row */} + + Overall Score + {items.map((item) => { + const globalScore = item.evaluation?.globalScore as number | null | undefined + return ( + + {globalScore != null ? ( + + {globalScore}/10 + + ) : ( + - + )} + + ) + })} + - {!evaluation && ( -
- - - Not yet evaluated - -
- )} + {/* Binary decision row */} + + Advance Decision + {items.map((item) => { + const decision = item.evaluation?.binaryDecision as boolean | null | undefined + return ( + + {decision != null ? ( + decision ? ( + + Yes + + ) : ( + + No + + ) + ) : ( + - + )} + + ) + })} + +
+
+
) diff --git a/src/app/(jury)/jury/page.tsx b/src/app/(jury)/jury/page.tsx index 64c579a..9091542 100644 --- a/src/app/(jury)/jury/page.tsx +++ b/src/app/(jury)/jury/page.tsx @@ -17,15 +17,28 @@ import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Progress } from '@/components/ui/progress' import { Skeleton } from '@/components/ui/skeleton' +import { Separator } from '@/components/ui/separator' import { ClipboardList, CheckCircle2, Clock, AlertCircle, ArrowRight, + GitCompare, + Zap, + BarChart3, + Target, } from 'lucide-react' import { formatDateOnly } from '@/lib/utils' import { CountdownTimer } from '@/components/shared/countdown-timer' +import { cn } from '@/lib/utils' + +function getGreeting(): string { + const hour = new Date().getHours() + if (hour < 12) return 'Good morning' + if (hour < 18) return 'Good afternoon' + return 'Good evening' +} async function JuryDashboardContent() { const session = await auth() @@ -37,15 +50,14 @@ async function JuryDashboardContent() { // Get all assignments for this jury member const assignments = await prisma.assignment.findMany({ - where: { - userId, - }, + where: { userId }, include: { project: { select: { id: true, title: true, teamName: true, + country: true, }, }, round: { @@ -58,6 +70,7 @@ async function JuryDashboardContent() { program: { select: { name: true, + year: true, }, }, }, @@ -67,6 +80,10 @@ async function JuryDashboardContent() { id: true, status: true, submittedAt: true, + criterionScoresJson: true, + form: { + select: { criteriaJson: true }, + }, }, }, }, @@ -118,7 +135,6 @@ async function JuryDashboardContent() { }, }) - // Build a map of roundId -> latest extendedUntil const graceByRound = new Map() for (const gp of gracePeriods) { const existing = graceByRound.get(gp.roundId) @@ -127,7 +143,7 @@ async function JuryDashboardContent() { } } - // Get active rounds (voting window is open) + // Active rounds (voting window open) const now = new Date() const activeRounds = Object.values(assignmentsByRound).filter( ({ round }) => @@ -138,157 +154,393 @@ async function JuryDashboardContent() { new Date(round.votingEndAt) >= now ) + // Find next unevaluated assignment in an active round + const nextUnevaluated = assignments.find((a) => { + const isActive = + a.round.status === 'ACTIVE' && + a.round.votingStartAt && + a.round.votingEndAt && + new Date(a.round.votingStartAt) <= now && + new Date(a.round.votingEndAt) >= now + const isIncomplete = !a.evaluation || a.evaluation.status === 'NOT_STARTED' || a.evaluation.status === 'DRAFT' + return isActive && isIncomplete + }) + + // Recent assignments for the quick list (latest 5) + const recentAssignments = assignments.slice(0, 6) + + // Get active round remaining count + const activeRemaining = assignments.filter((a) => { + const isActive = + a.round.status === 'ACTIVE' && + a.round.votingStartAt && + a.round.votingEndAt && + new Date(a.round.votingStartAt) <= now && + new Date(a.round.votingEndAt) >= now + const isIncomplete = !a.evaluation || a.evaluation.status !== 'SUBMITTED' + return isActive && isIncomplete + }).length + + const stats = [ + { + label: 'Total Assignments', + value: totalAssignments, + icon: ClipboardList, + iconBg: 'bg-blue-100 dark:bg-blue-900/30', + iconColor: 'text-blue-600 dark:text-blue-400', + }, + { + label: 'Completed', + value: completedAssignments, + icon: CheckCircle2, + iconBg: 'bg-green-100 dark:bg-green-900/30', + iconColor: 'text-green-600 dark:text-green-400', + }, + { + label: 'In Progress', + value: inProgressAssignments, + icon: Clock, + iconBg: 'bg-amber-100 dark:bg-amber-900/30', + iconColor: 'text-amber-600 dark:text-amber-400', + }, + { + label: 'Pending', + value: pendingAssignments, + icon: Target, + iconBg: 'bg-slate-100 dark:bg-slate-800', + iconColor: 'text-slate-500', + }, + ] + return ( <> + {/* Hero CTA - Jump to next evaluation */} + {nextUnevaluated && activeRemaining > 0 && ( + + +
+
+ +
+
+

+ {activeRemaining} evaluation{activeRemaining > 1 ? 's' : ''} remaining +

+

+ Continue with "{nextUnevaluated.project.title}" +

+
+
+ +
+
+ )} + {/* Stats */}
- - - - Total Assignments - - - - -
{totalAssignments}
-
-
- - - - Completed - - - -
{completedAssignments}
-
-
- - - - In Progress - - - -
{inProgressAssignments}
-
-
- - - - Pending - - - -
{pendingAssignments}
-
-
+ {stats.map((stat) => ( + + +
+ +
+
+

{stat.value}

+

{stat.label}

+
+
+
+ ))}
- {/* Progress */} + {/* Overall Progress */} - - Overall Progress - - - -

- {completedAssignments} of {totalAssignments} evaluations completed ( - {completionRate.toFixed(0)}%) -

+ +
+
+ + Overall Completion +
+ + {completedAssignments}/{totalAssignments} ({completionRate.toFixed(0)}%) + +
+
- {/* Active Rounds */} - {activeRounds.length > 0 && ( - - - Active Voting Rounds - - These rounds are currently open for evaluation - - - - {activeRounds.map(({ round, assignments: roundAssignments }) => { - const roundCompleted = roundAssignments.filter( - (a) => a.evaluation?.status === 'SUBMITTED' - ).length - const roundTotal = roundAssignments.length - const roundProgress = - roundTotal > 0 ? (roundCompleted / roundTotal) * 100 : 0 + {/* Main content — two column layout */} +
+ {/* Left column */} +
+ {/* Recent Assignments */} + + +
+ My Assignments + +
+
+ + {recentAssignments.length > 0 ? ( +
+ {recentAssignments.map((assignment) => { + const evaluation = assignment.evaluation + const isCompleted = evaluation?.status === 'SUBMITTED' + const isDraft = evaluation?.status === 'DRAFT' + const isVotingOpen = + assignment.round.status === 'ACTIVE' && + assignment.round.votingStartAt && + assignment.round.votingEndAt && + new Date(assignment.round.votingStartAt) <= now && + new Date(assignment.round.votingEndAt) >= now - return ( -
-
-
-

{round.name}

-

- {round.program.name} -

-
- Active -
- -
-
- Progress - - {roundCompleted}/{roundTotal} - -
- -
- - {round.votingEndAt && ( -
- - - ({formatDateOnly(round.votingEndAt)}) - -
- )} - - + return ( +
+ +

+ {assignment.project.title} +

+
+ + {assignment.project.teamName} + + + {assignment.round.name} + +
+ +
+ {isCompleted ? ( + + + Done + + ) : isDraft ? ( + + + Draft + + ) : ( + Pending + )} + {isCompleted ? ( + + ) : isVotingOpen ? ( + + ) : ( + + )} +
+
+ ) + })}
- ) - })} - - - )} + ) : ( +
+ +

+ No assignments yet +

+
+ )} + + - {/* No active rounds message */} - {activeRounds.length === 0 && totalAssignments > 0 && ( - - - -

No active voting rounds

-

- Check back later when a voting window opens -

-
-
- )} + {/* Quick Actions */} + + + Quick Actions + + +
+ + +
+
+
+
- {/* No assignments message */} + {/* Right column */} +
+ {/* Active Rounds */} + {activeRounds.length > 0 && ( + + + Active Voting Rounds + + Rounds currently open for evaluation + + + + {activeRounds.map(({ round, assignments: roundAssignments }) => { + const roundCompleted = roundAssignments.filter( + (a) => a.evaluation?.status === 'SUBMITTED' + ).length + const roundTotal = roundAssignments.length + const roundProgress = + roundTotal > 0 ? (roundCompleted / roundTotal) * 100 : 0 + const isAlmostDone = roundProgress >= 80 + const deadline = graceByRound.get(round.id) ?? (round.votingEndAt ? new Date(round.votingEndAt) : null) + const isUrgent = deadline && (deadline.getTime() - now.getTime()) < 24 * 60 * 60 * 1000 + + return ( +
+
+
+

{round.name}

+

+ {round.program.name} · {round.program.year} +

+
+ {isAlmostDone ? ( + Almost done + ) : ( + Active + )} +
+ +
+
+ Progress + + {roundCompleted}/{roundTotal} + +
+ +
+ + {deadline && ( +
+ + {round.votingEndAt && ( + + ({formatDateOnly(round.votingEndAt)}) + + )} +
+ )} + + +
+ ) + })} +
+
+ )} + + {/* No active rounds */} + {activeRounds.length === 0 && totalAssignments > 0 && ( + + +
+ +
+

No active voting rounds

+

+ Check back later when a voting window opens +

+
+
+ )} + + {/* Completion Summary by Round */} + {Object.keys(assignmentsByRound).length > 0 && ( + + + Round Summary + + + {Object.values(assignmentsByRound).map(({ round, assignments: roundAssignments }) => { + const done = roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length + const total = roundAssignments.length + const pct = total > 0 ? Math.round((done / total) * 100) : 0 + return ( +
+
+ {round.name} + + {done}/{total} ({pct}%) + +
+ +
+ ) + })} +
+
+ )} +
+
+ + {/* No assignments at all */} {totalAssignments === 0 && ( - - -

No assignments yet

-

- You'll see your project assignments here once they're - assigned + +

+ +
+

No assignments yet

+

+ You'll see your project assignments here once they're assigned to you by an administrator.

@@ -303,24 +555,51 @@ function DashboardSkeleton() {
{[...Array(4)].map((_, i) => ( - - - - - + + +
+ + +
))}
- - - - - - + + +
+
+ + + + + + {[...Array(4)].map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+
+
+
+ + + + + + + + +
+
) } @@ -332,9 +611,11 @@ export default async function JuryDashboardPage() {
{/* Header */}
-

Dashboard

+

+ {getGreeting()}, {session?.user?.name || 'Juror'} +

- Welcome back, {session?.user?.name || 'Juror'} + Here's an overview of your evaluation progress

diff --git a/src/app/(jury)/jury/projects/[id]/evaluation/page.tsx b/src/app/(jury)/jury/projects/[id]/evaluation/page.tsx index 1c2719e..c1fc000 100644 --- a/src/app/(jury)/jury/projects/[id]/evaluation/page.tsx +++ b/src/app/(jury)/jury/projects/[id]/evaluation/page.tsx @@ -18,6 +18,7 @@ import { Separator } from '@/components/ui/separator' import { Skeleton } from '@/components/ui/skeleton' import { ArrowLeft, + ArrowRight, CheckCircle2, ThumbsUp, ThumbsDown, @@ -89,6 +90,28 @@ async function EvaluationContent({ projectId }: { projectId: string }) { notFound() } + // Find next unevaluated project for "Next Project" navigation + const now = new Date() + const nextAssignment = await prisma.assignment.findFirst({ + where: { + userId, + id: { not: assignment?.id ?? undefined }, + round: { + status: 'ACTIVE', + votingStartAt: { lte: now }, + votingEndAt: { gte: now }, + }, + OR: [ + { evaluation: null }, + { evaluation: { status: { in: ['NOT_STARTED', 'DRAFT'] } } }, + ], + }, + select: { + project: { select: { id: true, title: true } }, + }, + orderBy: { round: { votingEndAt: 'asc' } }, + }) + if (!assignment) { return (
@@ -332,13 +355,23 @@ async function EvaluationContent({ projectId }: { projectId: string }) { {/* Navigation */} -
+
- +
+ + {nextAssignment && ( + + )} +
) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1b8f129..75a7200 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -25,7 +25,7 @@ export default async function RootLayout({ const messages = await getMessages() return ( - + {children} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index a7cbf22..8f31cf0 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { SessionProvider } from 'next-auth/react' +import { ThemeProvider } from 'next-themes' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { httpBatchLink } from '@trpc/client' import superjson from 'superjson' @@ -52,10 +53,12 @@ export function Providers({ children }: { children: React.ReactNode }) { ) return ( - - - {children} - - + + + + {children} + + + ) } diff --git a/src/components/layouts/jury-nav.tsx b/src/components/layouts/jury-nav.tsx index 4ad3c32..073e404 100644 --- a/src/components/layouts/jury-nav.tsx +++ b/src/components/layouts/jury-nav.tsx @@ -2,6 +2,8 @@ import { BookOpen, ClipboardList, GitCompare, Home } from 'lucide-react' import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav' +import { trpc } from '@/lib/trpc/client' +import { Badge } from '@/components/ui/badge' const navigation: NavItem[] = [ { @@ -30,6 +32,38 @@ interface JuryNavProps { user: RoleNavUser } +function RemainingBadge() { + const { data: assignments } = trpc.assignment.myAssignments.useQuery( + {}, + { refetchInterval: 60000 } + ) + + if (!assignments) return null + + const now = new Date() + const remaining = (assignments as Array<{ + round: { status: string; votingStartAt: Date | null; votingEndAt: Date | null } + evaluation: { status: string } | null + }>).filter((a) => { + const isActive = + a.round.status === 'ACTIVE' && + a.round.votingStartAt && + a.round.votingEndAt && + new Date(a.round.votingStartAt) <= now && + new Date(a.round.votingEndAt) >= now + const isIncomplete = !a.evaluation || a.evaluation.status !== 'SUBMITTED' + return isActive && isIncomplete + }).length + + if (remaining === 0) return null + + return ( + + {remaining} remaining + + ) +} + export function JuryNav({ user }: JuryNavProps) { return ( } /> ) } diff --git a/src/components/layouts/role-nav.tsx b/src/components/layouts/role-nav.tsx index a9dbfea..fdc6a43 100644 --- a/src/components/layouts/role-nav.tsx +++ b/src/components/layouts/role-nav.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' import Link from 'next/link' import { usePathname } from 'next/navigation' import { signOut } from 'next-auth/react' @@ -17,7 +17,8 @@ import { } from '@/components/ui/dropdown-menu' import type { Route } from 'next' import type { LucideIcon } from 'lucide-react' -import { LogOut, Menu, Settings, User, X } from 'lucide-react' +import { LogOut, Menu, Moon, Settings, Sun, User, X } from 'lucide-react' +import { useTheme } from 'next-themes' import { Logo } from '@/components/shared/logo' import { NotificationBell } from '@/components/shared/notification-bell' @@ -38,23 +39,31 @@ type RoleNavProps = { user: RoleNavUser /** The base path for the role (e.g., '/jury', '/mentor', '/observer'). Used for active state detection on the dashboard link. */ basePath: string + /** Optional status badge displayed next to the logo (e.g., remaining evaluations count) */ + statusBadge?: React.ReactNode } function isNavItemActive(pathname: string, href: string, basePath: string): boolean { return pathname === href || (href !== basePath && pathname.startsWith(href)) } -export function RoleNav({ navigation, roleName, user, basePath }: RoleNavProps) { +export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: RoleNavProps) { const pathname = usePathname() const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const { data: avatarUrl } = trpc.avatar.getUrl.useQuery() + const { theme, setTheme } = useTheme() + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) return (
{/* Logo */} - +
+ + {statusBadge} +
{/* Desktop nav */}