From 90e3adfab250ae7965ee6df926b77b722a4e850c Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 2 Feb 2026 16:58:29 +0100 Subject: [PATCH] Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards - Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination - Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence - Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility - Founding Date Field: add foundedAt to Project model with CSV import support - Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate - Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility - Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures - Reusable pagination component extracted to src/components/shared/pagination.tsx - Old /admin/users and /admin/mentors routes redirect to /admin/members - Prisma migration for all schema additions (additive, no data loss) Co-Authored-By: Claude Opus 4.5 --- .../migration.sql | 221 +++++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 211 +++++ src/app/(admin)/admin/audit/page.tsx | 22 + src/app/(admin)/admin/awards/[id]/page.tsx | 511 ++++++++++++ src/app/(admin)/admin/awards/new/page.tsx | 206 +++++ src/app/(admin)/admin/awards/page.tsx | 140 ++++ src/app/(admin)/admin/members/[id]/page.tsx | 377 +++++++++ src/app/(admin)/admin/members/invite/page.tsx | 296 +++++++ src/app/(admin)/admin/members/page.tsx | 7 + src/app/(admin)/admin/mentors/[id]/page.tsx | 395 +-------- src/app/(admin)/admin/mentors/page.tsx | 251 +----- src/app/(admin)/admin/page.tsx | 15 + src/app/(admin)/admin/projects/[id]/page.tsx | 9 + src/app/(admin)/admin/projects/page.tsx | 630 ++++++++------ .../admin/projects/project-filters.tsx | 336 ++++++++ .../admin/rounds/[id]/filtering/page.tsx | 283 +++++++ .../rounds/[id]/filtering/results/page.tsx | 472 +++++++++++ .../rounds/[id]/filtering/rules/page.tsx | 518 ++++++++++++ src/app/(admin)/admin/rounds/[id]/page.tsx | 7 + src/app/(admin)/admin/users/[id]/page.tsx | 322 +------- src/app/(admin)/admin/users/invite/page.tsx | 675 +-------------- src/app/(admin)/admin/users/page.tsx | 280 +------ src/app/(jury)/jury/awards/[id]/page.tsx | 322 ++++++++ src/app/(jury)/jury/awards/page.tsx | 90 ++ src/components/admin/members-content.tsx | 353 ++++++++ src/components/admin/user-actions.tsx | 4 +- src/components/forms/csv-import-form.tsx | 1 + src/components/layouts/admin-sidebar.tsx | 16 +- src/components/shared/pagination.tsx | 56 ++ src/components/shared/user-activity-log.tsx | 111 +++ src/lib/auth.ts | 45 + src/server/routers/_app.ts | 4 + src/server/routers/audit.ts | 2 +- src/server/routers/evaluation.ts | 8 +- src/server/routers/file.ts | 13 + src/server/routers/filtering.ts | 522 ++++++++++++ src/server/routers/project.ts | 95 ++- src/server/routers/round.ts | 18 +- src/server/routers/specialAward.ts | 775 ++++++++++++++++++ src/server/routers/user.ts | 32 +- src/server/services/ai-award-eligibility.ts | 226 +++++ src/server/services/ai-filtering.ts | 509 ++++++++++++ src/server/utils/audit.ts | 33 + 44 files changed, 7268 insertions(+), 2154 deletions(-) create mode 100644 prisma/migrations/20260202000000_prototype1_improvements/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 src/app/(admin)/admin/awards/[id]/page.tsx create mode 100644 src/app/(admin)/admin/awards/new/page.tsx create mode 100644 src/app/(admin)/admin/awards/page.tsx create mode 100644 src/app/(admin)/admin/members/[id]/page.tsx create mode 100644 src/app/(admin)/admin/members/invite/page.tsx create mode 100644 src/app/(admin)/admin/members/page.tsx create mode 100644 src/app/(admin)/admin/projects/project-filters.tsx create mode 100644 src/app/(admin)/admin/rounds/[id]/filtering/page.tsx create mode 100644 src/app/(admin)/admin/rounds/[id]/filtering/results/page.tsx create mode 100644 src/app/(admin)/admin/rounds/[id]/filtering/rules/page.tsx create mode 100644 src/app/(jury)/jury/awards/[id]/page.tsx create mode 100644 src/app/(jury)/jury/awards/page.tsx create mode 100644 src/components/admin/members-content.tsx create mode 100644 src/components/shared/pagination.tsx create mode 100644 src/components/shared/user-activity-log.tsx create mode 100644 src/server/routers/filtering.ts create mode 100644 src/server/routers/specialAward.ts create mode 100644 src/server/services/ai-award-eligibility.ts create mode 100644 src/server/services/ai-filtering.ts create mode 100644 src/server/utils/audit.ts diff --git a/prisma/migrations/20260202000000_prototype1_improvements/migration.sql b/prisma/migrations/20260202000000_prototype1_improvements/migration.sql new file mode 100644 index 0000000..388ed86 --- /dev/null +++ b/prisma/migrations/20260202000000_prototype1_improvements/migration.sql @@ -0,0 +1,221 @@ +-- CreateEnum +CREATE TYPE "FilteringOutcome" AS ENUM ('PASSED', 'FILTERED_OUT', 'FLAGGED'); + +-- CreateEnum +CREATE TYPE "FilteringRuleType" AS ENUM ('FIELD_BASED', 'DOCUMENT_CHECK', 'AI_SCREENING'); + +-- CreateEnum +CREATE TYPE "AwardScoringMode" AS ENUM ('PICK_WINNER', 'RANKED', 'SCORED'); + +-- CreateEnum +CREATE TYPE "AwardStatus" AS ENUM ('DRAFT', 'NOMINATIONS_OPEN', 'VOTING_OPEN', 'CLOSED', 'ARCHIVED'); + +-- CreateEnum +CREATE TYPE "EligibilityMethod" AS ENUM ('AUTO', 'MANUAL'); + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "foundedAt" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "inviteToken" TEXT, +ADD COLUMN "inviteTokenExpiresAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "FilteringRule" ( + "id" TEXT NOT NULL, + "roundId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "ruleType" "FilteringRuleType" NOT NULL, + "configJson" JSONB NOT NULL, + "priority" INTEGER NOT NULL DEFAULT 0, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FilteringRule_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FilteringResult" ( + "id" TEXT NOT NULL, + "roundId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "outcome" "FilteringOutcome" NOT NULL, + "ruleResultsJson" JSONB, + "aiScreeningJson" JSONB, + "overriddenBy" TEXT, + "overriddenAt" TIMESTAMP(3), + "overrideReason" TEXT, + "finalOutcome" "FilteringOutcome", + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FilteringResult_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SpecialAward" ( + "id" TEXT NOT NULL, + "programId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "status" "AwardStatus" NOT NULL DEFAULT 'DRAFT', + "criteriaText" TEXT, + "autoTagRulesJson" JSONB, + "scoringMode" "AwardScoringMode" NOT NULL DEFAULT 'PICK_WINNER', + "maxRankedPicks" INTEGER, + "votingStartAt" TIMESTAMP(3), + "votingEndAt" TIMESTAMP(3), + "evaluationFormId" TEXT, + "winnerProjectId" TEXT, + "winnerOverridden" BOOLEAN NOT NULL DEFAULT false, + "winnerOverriddenBy" TEXT, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SpecialAward_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AwardEligibility" ( + "id" TEXT NOT NULL, + "awardId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "method" "EligibilityMethod" NOT NULL DEFAULT 'AUTO', + "eligible" BOOLEAN NOT NULL DEFAULT false, + "aiReasoningJson" JSONB, + "overriddenBy" TEXT, + "overriddenAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AwardEligibility_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AwardJuror" ( + "id" TEXT NOT NULL, + "awardId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AwardJuror_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AwardVote" ( + "id" TEXT NOT NULL, + "awardId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "rank" INTEGER, + "votedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AwardVote_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "FilteringRule_roundId_idx" ON "FilteringRule"("roundId"); + +-- CreateIndex +CREATE INDEX "FilteringRule_priority_idx" ON "FilteringRule"("priority"); + +-- CreateIndex +CREATE INDEX "FilteringResult_roundId_idx" ON "FilteringResult"("roundId"); + +-- CreateIndex +CREATE INDEX "FilteringResult_projectId_idx" ON "FilteringResult"("projectId"); + +-- CreateIndex +CREATE INDEX "FilteringResult_outcome_idx" ON "FilteringResult"("outcome"); + +-- CreateIndex +CREATE UNIQUE INDEX "FilteringResult_roundId_projectId_key" ON "FilteringResult"("roundId", "projectId"); + +-- CreateIndex +CREATE INDEX "SpecialAward_programId_idx" ON "SpecialAward"("programId"); + +-- CreateIndex +CREATE INDEX "SpecialAward_status_idx" ON "SpecialAward"("status"); + +-- CreateIndex +CREATE INDEX "SpecialAward_sortOrder_idx" ON "SpecialAward"("sortOrder"); + +-- CreateIndex +CREATE INDEX "AwardEligibility_awardId_idx" ON "AwardEligibility"("awardId"); + +-- CreateIndex +CREATE INDEX "AwardEligibility_projectId_idx" ON "AwardEligibility"("projectId"); + +-- CreateIndex +CREATE INDEX "AwardEligibility_eligible_idx" ON "AwardEligibility"("eligible"); + +-- CreateIndex +CREATE UNIQUE INDEX "AwardEligibility_awardId_projectId_key" ON "AwardEligibility"("awardId", "projectId"); + +-- CreateIndex +CREATE INDEX "AwardJuror_awardId_idx" ON "AwardJuror"("awardId"); + +-- CreateIndex +CREATE INDEX "AwardJuror_userId_idx" ON "AwardJuror"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AwardJuror_awardId_userId_key" ON "AwardJuror"("awardId", "userId"); + +-- CreateIndex +CREATE INDEX "AwardVote_awardId_idx" ON "AwardVote"("awardId"); + +-- CreateIndex +CREATE INDEX "AwardVote_userId_idx" ON "AwardVote"("userId"); + +-- CreateIndex +CREATE INDEX "AwardVote_projectId_idx" ON "AwardVote"("projectId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AwardVote_awardId_userId_projectId_key" ON "AwardVote"("awardId", "userId", "projectId"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_inviteToken_key" ON "User"("inviteToken"); + +-- AddForeignKey +ALTER TABLE "FilteringRule" ADD CONSTRAINT "FilteringRule_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FilteringResult" ADD CONSTRAINT "FilteringResult_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FilteringResult" ADD CONSTRAINT "FilteringResult_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FilteringResult" ADD CONSTRAINT "FilteringResult_overriddenBy_fkey" FOREIGN KEY ("overriddenBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_winnerProjectId_fkey" FOREIGN KEY ("winnerProjectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AwardEligibility" ADD CONSTRAINT "AwardEligibility_awardId_fkey" FOREIGN KEY ("awardId") REFERENCES "SpecialAward"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AwardEligibility" ADD CONSTRAINT "AwardEligibility_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AwardEligibility" ADD CONSTRAINT "AwardEligibility_overriddenBy_fkey" FOREIGN KEY ("overriddenBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AwardJuror" ADD CONSTRAINT "AwardJuror_awardId_fkey" FOREIGN KEY ("awardId") REFERENCES "SpecialAward"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AwardJuror" ADD CONSTRAINT "AwardJuror_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AwardVote" ADD CONSTRAINT "AwardVote_awardId_fkey" FOREIGN KEY ("awardId") REFERENCES "SpecialAward"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AwardVote" ADD CONSTRAINT "AwardVote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AwardVote" ADD CONSTRAINT "AwardVote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..99e4f20 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a42ce87..fbe856f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -259,6 +259,16 @@ model User { teamMemberships TeamMember[] mentorAssignments MentorAssignment[] @relation("MentorAssignments") + // Awards + awardJurorships AwardJuror[] + awardVotes AwardVote[] + + // Filtering overrides + filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy") + + // Award overrides + awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy") + // NextAuth relations accounts Account[] sessions Session[] @@ -328,6 +338,7 @@ model Program { learningResources LearningResource[] partners Partner[] applicationForms ApplicationForm[] + specialAwards SpecialAward[] @@unique([name, year]) @@index([status]) @@ -369,6 +380,8 @@ model Round { evaluationForms EvaluationForm[] gracePeriods GracePeriod[] liveVotingSession LiveVotingSession? + filteringRules FilteringRule[] + filteringResults FilteringResult[] @@index([programId]) @@index([status]) @@ -428,6 +441,9 @@ model Project { // Mentorship wantsMentorship Boolean @default(false) + // Founding date + foundedAt DateTime? // When the project/company was founded + // Submission links (external, from CSV) phase1SubmissionUrl String? phase2SubmissionUrl String? @@ -464,6 +480,10 @@ model Project { submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull) teamMembers TeamMember[] mentorAssignment MentorAssignment? + filteringResults FilteringResult[] + awardEligibilities AwardEligibility[] + awardVotes AwardVote[] + wonAwards SpecialAward[] @relation("AwardWinner") @@index([roundId]) @@index([status]) @@ -975,3 +995,194 @@ model MentorAssignment { @@index([mentorId]) @@index([method]) } + +// ============================================================================= +// FILTERING ROUND SYSTEM +// ============================================================================= + +enum FilteringOutcome { + PASSED + FILTERED_OUT + FLAGGED +} + +enum FilteringRuleType { + FIELD_BASED + DOCUMENT_CHECK + AI_SCREENING +} + +model FilteringRule { + id String @id @default(cuid()) + roundId String + name String + ruleType FilteringRuleType + configJson Json @db.JsonB // Conditions, logic, action per rule type + priority Int @default(0) + isActive Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + + @@index([roundId]) + @@index([priority]) +} + +model FilteringResult { + id String @id @default(cuid()) + roundId String + projectId String + outcome FilteringOutcome + ruleResultsJson Json? @db.JsonB // Per-rule results + aiScreeningJson Json? @db.JsonB // AI screening details + + // Admin override + overriddenBy String? + overriddenAt DateTime? + overrideReason String? @db.Text + finalOutcome FilteringOutcome? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + overriddenByUser User? @relation("FilteringOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull) + + @@unique([roundId, projectId]) + @@index([roundId]) + @@index([projectId]) + @@index([outcome]) +} + +// ============================================================================= +// SPECIAL AWARDS SYSTEM +// ============================================================================= + +enum AwardScoringMode { + PICK_WINNER + RANKED + SCORED +} + +enum AwardStatus { + DRAFT + NOMINATIONS_OPEN + VOTING_OPEN + CLOSED + ARCHIVED +} + +enum EligibilityMethod { + AUTO + MANUAL +} + +model SpecialAward { + id String @id @default(cuid()) + programId String + name String + description String? @db.Text + status AwardStatus @default(DRAFT) + + // Criteria + criteriaText String? @db.Text // Plain-language criteria for AI + autoTagRulesJson Json? @db.JsonB // Deterministic eligibility rules + + // Scoring + scoringMode AwardScoringMode @default(PICK_WINNER) + maxRankedPicks Int? // For RANKED mode + + // Voting window + votingStartAt DateTime? + votingEndAt DateTime? + + // Evaluation form (for SCORED mode) + evaluationFormId String? + + // Winner + winnerProjectId String? + winnerOverridden Boolean @default(false) + winnerOverriddenBy String? + + sortOrder Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + program Program @relation(fields: [programId], references: [id], onDelete: Cascade) + winnerProject Project? @relation("AwardWinner", fields: [winnerProjectId], references: [id], onDelete: SetNull) + eligibilities AwardEligibility[] + jurors AwardJuror[] + votes AwardVote[] + + @@index([programId]) + @@index([status]) + @@index([sortOrder]) +} + +model AwardEligibility { + id String @id @default(cuid()) + awardId String + projectId String + method EligibilityMethod @default(AUTO) + eligible Boolean @default(false) + aiReasoningJson Json? @db.JsonB + + // Admin override + overriddenBy String? + overriddenAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + overriddenByUser User? @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull) + + @@unique([awardId, projectId]) + @@index([awardId]) + @@index([projectId]) + @@index([eligible]) +} + +model AwardJuror { + id String @id @default(cuid()) + awardId String + userId String + + createdAt DateTime @default(now()) + + // Relations + award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([awardId, userId]) + @@index([awardId]) + @@index([userId]) +} + +model AwardVote { + id String @id @default(cuid()) + awardId String + userId String + projectId String + rank Int? // For RANKED mode + votedAt DateTime @default(now()) + + // Relations + award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([awardId, userId, projectId]) + @@index([awardId]) + @@index([userId]) + @@index([projectId]) +} diff --git a/src/app/(admin)/admin/audit/page.tsx b/src/app/(admin)/admin/audit/page.tsx index f8e9c8d..747791b 100644 --- a/src/app/(admin)/admin/audit/page.tsx +++ b/src/app/(admin)/admin/audit/page.tsx @@ -60,13 +60,24 @@ const ACTION_TYPES = [ 'IMPORT', 'EXPORT', 'LOGIN', + 'LOGIN_SUCCESS', + 'LOGIN_FAILED', + 'INVITATION_ACCEPTED', 'SUBMIT_EVALUATION', + 'EVALUATION_SUBMITTED', 'UPDATE_STATUS', + 'ROUND_ACTIVATED', + 'ROUND_CLOSED', + 'ROUND_ARCHIVED', 'UPLOAD_FILE', 'DELETE_FILE', + 'FILE_DOWNLOADED', 'BULK_CREATE', 'BULK_UPDATE_STATUS', 'UPDATE_EVALUATION_FORM', + 'ROLE_CHANGED', + 'PASSWORD_SET', + 'PASSWORD_CHANGED', ] // Entity type options @@ -90,7 +101,18 @@ const actionColors: Record = { + DRAFT: 'secondary', + NOMINATIONS_OPEN: 'default', + VOTING_OPEN: 'default', + CLOSED: 'outline', + ARCHIVED: 'secondary', +} + +export default function AwardDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id: awardId } = use(params) + + const { data: award, isLoading, refetch } = + trpc.specialAward.get.useQuery({ id: awardId }) + const { data: eligibilityData, refetch: refetchEligibility } = + trpc.specialAward.listEligible.useQuery({ + awardId, + page: 1, + perPage: 50, + }) + const { data: jurors, refetch: refetchJurors } = + trpc.specialAward.listJurors.useQuery({ awardId }) + const { data: voteResults } = + trpc.specialAward.getVoteResults.useQuery({ awardId }) + const { data: allUsers } = trpc.user.list.useQuery({ page: 1, perPage: 200 }) + + const updateStatus = trpc.specialAward.updateStatus.useMutation() + const runEligibility = trpc.specialAward.runEligibility.useMutation() + const setEligibility = trpc.specialAward.setEligibility.useMutation() + const addJuror = trpc.specialAward.addJuror.useMutation() + const removeJuror = trpc.specialAward.removeJuror.useMutation() + const setWinner = trpc.specialAward.setWinner.useMutation() + + const [selectedJurorId, setSelectedJurorId] = useState('') + + const handleStatusChange = async ( + status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED' + ) => { + try { + await updateStatus.mutateAsync({ id: awardId, status }) + toast.success(`Status updated to ${status.replace('_', ' ')}`) + refetch() + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to update status' + ) + } + } + + const handleRunEligibility = async () => { + try { + const result = await runEligibility.mutateAsync({ awardId }) + toast.success( + `Eligibility run: ${result.eligible} eligible, ${result.ineligible} ineligible` + ) + refetchEligibility() + refetch() + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to run eligibility' + ) + } + } + + const handleToggleEligibility = async ( + projectId: string, + eligible: boolean + ) => { + try { + await setEligibility.mutateAsync({ awardId, projectId, eligible }) + refetchEligibility() + } catch { + toast.error('Failed to update eligibility') + } + } + + const handleAddJuror = async () => { + if (!selectedJurorId) return + try { + await addJuror.mutateAsync({ awardId, userId: selectedJurorId }) + toast.success('Juror added') + setSelectedJurorId('') + refetchJurors() + } catch { + toast.error('Failed to add juror') + } + } + + const handleRemoveJuror = async (userId: string) => { + try { + await removeJuror.mutateAsync({ awardId, userId }) + refetchJurors() + } catch { + toast.error('Failed to remove juror') + } + } + + const handleSetWinner = async (projectId: string) => { + try { + await setWinner.mutateAsync({ + awardId, + projectId, + overridden: true, + }) + toast.success('Winner set') + refetch() + } catch { + toast.error('Failed to set winner') + } + } + + if (isLoading) { + return ( +
+ + +
+ ) + } + + if (!award) return null + + const jurorUserIds = new Set(jurors?.map((j) => j.userId) || []) + const availableUsers = + allUsers?.users.filter((u) => !jurorUserIds.has(u.id)) || [] + + return ( +
+ {/* Header */} +
+ +
+ +
+
+

+ + {award.name} +

+
+ + {award.status.replace('_', ' ')} + + + {award.program.name} + +
+
+
+ {award.status === 'DRAFT' && ( + + )} + {award.status === 'NOMINATIONS_OPEN' && ( + + )} + {award.status === 'VOTING_OPEN' && ( + + )} +
+
+ + {/* Description */} + {award.description && ( +

{award.description}

+ )} + + {/* Tabs */} + + + + + Eligibility ({award.eligibleCount}) + + + + Jurors ({award._count.jurors}) + + + + Results + + + + {/* Eligibility Tab */} + +
+

+ {award.eligibleCount} of {award._count.eligibilities} projects + eligible +

+ +
+ + {eligibilityData && eligibilityData.eligibilities.length > 0 ? ( + + + + + Project + Category + Country + Eligible + + + + {eligibilityData.eligibilities.map((e) => ( + + +
+

{e.project.title}

+

+ {e.project.teamName} +

+
+
+ + {e.project.competitionCategory ? ( + + {e.project.competitionCategory.replace('_', ' ')} + + ) : ( + '-' + )} + + {e.project.country || '-'} + + + handleToggleEligibility(e.projectId, checked) + } + /> + +
+ ))} +
+
+
+ ) : ( + + + +

No eligibility data

+

+ Run AI eligibility to evaluate projects against criteria +

+
+
+ )} +
+ + {/* Jurors Tab */} + +
+ + +
+ + {jurors && jurors.length > 0 ? ( + + + + + Member + Role + Actions + + + + {jurors.map((j) => ( + + +
+ +
+

+ {j.user.name || 'Unnamed'} +

+

+ {j.user.email} +

+
+
+
+ + + {j.user.role.replace('_', ' ')} + + + + + +
+ ))} +
+
+
+ ) : ( + + + +

No jurors assigned

+

+ Add members as jurors for this award +

+
+
+ )} +
+ + {/* Results Tab */} + + {voteResults && voteResults.results.length > 0 ? ( + <> +
+ + {voteResults.votedJurorCount} of {voteResults.jurorCount}{' '} + jurors voted + + + {voteResults.scoringMode.replace('_', ' ')} + +
+ + + + + + # + Project + Votes + Points + Actions + + + + {voteResults.results.map((r, i) => ( + + {i + 1} + +
+ {r.project.id === voteResults.winnerId && ( + + )} +
+

{r.project.title}

+

+ {r.project.teamName} +

+
+
+
+ {r.votes} + + {r.points} + + + {r.project.id !== voteResults.winnerId && ( + + )} + +
+ ))} +
+
+
+ + ) : ( + + + +

No votes yet

+

+ Votes will appear here once jurors submit their selections +

+
+
+ )} +
+
+
+ ) +} diff --git a/src/app/(admin)/admin/awards/new/page.tsx b/src/app/(admin)/admin/awards/new/page.tsx new file mode 100644 index 0000000..2927f95 --- /dev/null +++ b/src/app/(admin)/admin/awards/new/page.tsx @@ -0,0 +1,206 @@ +'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 { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { toast } from 'sonner' +import { ArrowLeft, Save, Loader2 } from 'lucide-react' + +export default function CreateAwardPage() { + const router = useRouter() + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [criteriaText, setCriteriaText] = useState('') + const [scoringMode, setScoringMode] = useState< + 'PICK_WINNER' | 'RANKED' | 'SCORED' + >('PICK_WINNER') + const [maxRankedPicks, setMaxRankedPicks] = useState('3') + const [programId, setProgramId] = useState('') + + const { data: programs } = trpc.program.list.useQuery() + const createAward = trpc.specialAward.create.useMutation() + + const handleSubmit = async () => { + if (!name.trim() || !programId) return + try { + const award = await createAward.mutateAsync({ + programId, + name: name.trim(), + description: description.trim() || undefined, + criteriaText: criteriaText.trim() || undefined, + scoringMode, + maxRankedPicks: + scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined, + }) + toast.success('Award created') + router.push(`/admin/awards/${award.id}`) + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to create award' + ) + } + } + + return ( +
+
+ +
+ +
+

+ Create Special Award +

+

+ Define a new award with eligibility criteria and voting rules +

+
+ + + + Award Details + + Configure the award name, criteria, and scoring mode + + + +
+ + +
+ +
+ + setName(e.target.value)} + placeholder="e.g., Mediterranean Entrepreneurship Award" + /> +
+ +
+ +