Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
Build and Push Docker Image / build (push) Successful in 9m9s
Details
Build and Push Docker Image / build (push) Successful in 9m9s
Details
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
8fda8deded
commit
90e3adfab2
|
|
@ -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;
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -259,6 +259,16 @@ model User {
|
||||||
teamMemberships TeamMember[]
|
teamMemberships TeamMember[]
|
||||||
mentorAssignments MentorAssignment[] @relation("MentorAssignments")
|
mentorAssignments MentorAssignment[] @relation("MentorAssignments")
|
||||||
|
|
||||||
|
// Awards
|
||||||
|
awardJurorships AwardJuror[]
|
||||||
|
awardVotes AwardVote[]
|
||||||
|
|
||||||
|
// Filtering overrides
|
||||||
|
filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy")
|
||||||
|
|
||||||
|
// Award overrides
|
||||||
|
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
|
||||||
|
|
||||||
// NextAuth relations
|
// NextAuth relations
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
|
@ -328,6 +338,7 @@ model Program {
|
||||||
learningResources LearningResource[]
|
learningResources LearningResource[]
|
||||||
partners Partner[]
|
partners Partner[]
|
||||||
applicationForms ApplicationForm[]
|
applicationForms ApplicationForm[]
|
||||||
|
specialAwards SpecialAward[]
|
||||||
|
|
||||||
@@unique([name, year])
|
@@unique([name, year])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
|
@ -369,6 +380,8 @@ model Round {
|
||||||
evaluationForms EvaluationForm[]
|
evaluationForms EvaluationForm[]
|
||||||
gracePeriods GracePeriod[]
|
gracePeriods GracePeriod[]
|
||||||
liveVotingSession LiveVotingSession?
|
liveVotingSession LiveVotingSession?
|
||||||
|
filteringRules FilteringRule[]
|
||||||
|
filteringResults FilteringResult[]
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
|
@ -428,6 +441,9 @@ model Project {
|
||||||
// Mentorship
|
// Mentorship
|
||||||
wantsMentorship Boolean @default(false)
|
wantsMentorship Boolean @default(false)
|
||||||
|
|
||||||
|
// Founding date
|
||||||
|
foundedAt DateTime? // When the project/company was founded
|
||||||
|
|
||||||
// Submission links (external, from CSV)
|
// Submission links (external, from CSV)
|
||||||
phase1SubmissionUrl String?
|
phase1SubmissionUrl String?
|
||||||
phase2SubmissionUrl String?
|
phase2SubmissionUrl String?
|
||||||
|
|
@ -464,6 +480,10 @@ model Project {
|
||||||
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
|
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
|
||||||
teamMembers TeamMember[]
|
teamMembers TeamMember[]
|
||||||
mentorAssignment MentorAssignment?
|
mentorAssignment MentorAssignment?
|
||||||
|
filteringResults FilteringResult[]
|
||||||
|
awardEligibilities AwardEligibility[]
|
||||||
|
awardVotes AwardVote[]
|
||||||
|
wonAwards SpecialAward[] @relation("AwardWinner")
|
||||||
|
|
||||||
@@index([roundId])
|
@@index([roundId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
|
@ -975,3 +995,194 @@ model MentorAssignment {
|
||||||
@@index([mentorId])
|
@@index([mentorId])
|
||||||
@@index([method])
|
@@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])
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,13 +60,24 @@ const ACTION_TYPES = [
|
||||||
'IMPORT',
|
'IMPORT',
|
||||||
'EXPORT',
|
'EXPORT',
|
||||||
'LOGIN',
|
'LOGIN',
|
||||||
|
'LOGIN_SUCCESS',
|
||||||
|
'LOGIN_FAILED',
|
||||||
|
'INVITATION_ACCEPTED',
|
||||||
'SUBMIT_EVALUATION',
|
'SUBMIT_EVALUATION',
|
||||||
|
'EVALUATION_SUBMITTED',
|
||||||
'UPDATE_STATUS',
|
'UPDATE_STATUS',
|
||||||
|
'ROUND_ACTIVATED',
|
||||||
|
'ROUND_CLOSED',
|
||||||
|
'ROUND_ARCHIVED',
|
||||||
'UPLOAD_FILE',
|
'UPLOAD_FILE',
|
||||||
'DELETE_FILE',
|
'DELETE_FILE',
|
||||||
|
'FILE_DOWNLOADED',
|
||||||
'BULK_CREATE',
|
'BULK_CREATE',
|
||||||
'BULK_UPDATE_STATUS',
|
'BULK_UPDATE_STATUS',
|
||||||
'UPDATE_EVALUATION_FORM',
|
'UPDATE_EVALUATION_FORM',
|
||||||
|
'ROLE_CHANGED',
|
||||||
|
'PASSWORD_SET',
|
||||||
|
'PASSWORD_CHANGED',
|
||||||
]
|
]
|
||||||
|
|
||||||
// Entity type options
|
// Entity type options
|
||||||
|
|
@ -90,7 +101,18 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
|
||||||
IMPORT: 'default',
|
IMPORT: 'default',
|
||||||
EXPORT: 'outline',
|
EXPORT: 'outline',
|
||||||
LOGIN: 'outline',
|
LOGIN: 'outline',
|
||||||
|
LOGIN_SUCCESS: 'outline',
|
||||||
|
LOGIN_FAILED: 'destructive',
|
||||||
|
INVITATION_ACCEPTED: 'default',
|
||||||
SUBMIT_EVALUATION: 'default',
|
SUBMIT_EVALUATION: 'default',
|
||||||
|
EVALUATION_SUBMITTED: 'default',
|
||||||
|
ROUND_ACTIVATED: 'default',
|
||||||
|
ROUND_CLOSED: 'secondary',
|
||||||
|
ROUND_ARCHIVED: 'secondary',
|
||||||
|
FILE_DOWNLOADED: 'outline',
|
||||||
|
ROLE_CHANGED: 'secondary',
|
||||||
|
PASSWORD_SET: 'outline',
|
||||||
|
PASSWORD_CHANGED: 'outline',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuditLogPage() {
|
export default function AuditLogPage() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,511 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { use, useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
|
import { Pagination } from '@/components/shared/pagination'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Trophy,
|
||||||
|
Users,
|
||||||
|
CheckCircle2,
|
||||||
|
Brain,
|
||||||
|
BarChart3,
|
||||||
|
Loader2,
|
||||||
|
Crown,
|
||||||
|
UserPlus,
|
||||||
|
X,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Lock,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-9 w-48" />
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!award) return null
|
||||||
|
|
||||||
|
const jurorUserIds = new Set(jurors?.map((j) => j.userId) || [])
|
||||||
|
const availableUsers =
|
||||||
|
allUsers?.users.filter((u) => !jurorUserIds.has(u.id)) || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||||
|
<Trophy className="h-6 w-6 text-amber-500" />
|
||||||
|
{award.name}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
||||||
|
{award.status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{award.program.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{award.status === 'DRAFT' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleStatusChange('NOMINATIONS_OPEN')}
|
||||||
|
disabled={updateStatus.isPending}
|
||||||
|
>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Open Nominations
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{award.status === 'NOMINATIONS_OPEN' && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleStatusChange('VOTING_OPEN')}
|
||||||
|
disabled={updateStatus.isPending}
|
||||||
|
>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Open Voting
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{award.status === 'VOTING_OPEN' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleStatusChange('CLOSED')}
|
||||||
|
disabled={updateStatus.isPending}
|
||||||
|
>
|
||||||
|
<Lock className="mr-2 h-4 w-4" />
|
||||||
|
Close Voting
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{award.description && (
|
||||||
|
<p className="text-muted-foreground">{award.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs defaultValue="eligibility">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="eligibility">
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
Eligibility ({award.eligibleCount})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="jurors">
|
||||||
|
<Users className="mr-2 h-4 w-4" />
|
||||||
|
Jurors ({award._count.jurors})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="results">
|
||||||
|
<BarChart3 className="mr-2 h-4 w-4" />
|
||||||
|
Results
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Eligibility Tab */}
|
||||||
|
<TabsContent value="eligibility" className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{award.eligibleCount} of {award._count.eligibilities} projects
|
||||||
|
eligible
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleRunEligibility}
|
||||||
|
disabled={runEligibility.isPending}
|
||||||
|
>
|
||||||
|
{runEligibility.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Brain className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Run AI Eligibility
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{eligibilityData && eligibilityData.eligibilities.length > 0 ? (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Project</TableHead>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
|
<TableHead>Country</TableHead>
|
||||||
|
<TableHead>Eligible</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{eligibilityData.eligibilities.map((e) => (
|
||||||
|
<TableRow key={e.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{e.project.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{e.project.teamName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{e.project.competitionCategory ? (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{e.project.competitionCategory.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{e.project.country || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Switch
|
||||||
|
checked={e.eligible}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleToggleEligibility(e.projectId, checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Brain className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 font-medium">No eligibility data</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Run AI eligibility to evaluate projects against criteria
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Jurors Tab */}
|
||||||
|
<TabsContent value="jurors" className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={selectedJurorId} onValueChange={setSelectedJurorId}>
|
||||||
|
<SelectTrigger className="w-64">
|
||||||
|
<SelectValue placeholder="Select a member..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableUsers.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={u.id}>
|
||||||
|
{u.name || u.email}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddJuror}
|
||||||
|
disabled={!selectedJurorId || addJuror.isPending}
|
||||||
|
>
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
Add Juror
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{jurors && jurors.length > 0 ? (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Member</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{jurors.map((j) => (
|
||||||
|
<TableRow key={j.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserAvatar user={j.user} size="sm" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{j.user.name || 'Unnamed'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{j.user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{j.user.role.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveJuror(j.userId)}
|
||||||
|
disabled={removeJuror.isPending}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Users className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 font-medium">No jurors assigned</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Add members as jurors for this award
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Results Tab */}
|
||||||
|
<TabsContent value="results" className="space-y-4">
|
||||||
|
{voteResults && voteResults.results.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{voteResults.votedJurorCount} of {voteResults.jurorCount}{' '}
|
||||||
|
jurors voted
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{voteResults.scoringMode.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-12">#</TableHead>
|
||||||
|
<TableHead>Project</TableHead>
|
||||||
|
<TableHead>Votes</TableHead>
|
||||||
|
<TableHead>Points</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{voteResults.results.map((r, i) => (
|
||||||
|
<TableRow
|
||||||
|
key={r.project.id}
|
||||||
|
className={
|
||||||
|
r.project.id === voteResults.winnerId
|
||||||
|
? 'bg-amber-50 dark:bg-amber-950/20'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableCell className="font-bold">{i + 1}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{r.project.id === voteResults.winnerId && (
|
||||||
|
<Crown className="h-4 w-4 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{r.project.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{r.project.teamName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{r.votes}</TableCell>
|
||||||
|
<TableCell className="font-semibold">
|
||||||
|
{r.points}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{r.project.id !== voteResults.winnerId && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSetWinner(r.project.id)}
|
||||||
|
disabled={setWinner.isPending}
|
||||||
|
>
|
||||||
|
<Crown className="mr-1 h-3 w-3" />
|
||||||
|
Set Winner
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<BarChart3 className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 font-medium">No votes yet</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Votes will appear here once jurors submit their selections
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Create Special Award
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Define a new award with eligibility criteria and voting rules
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Award Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure the award name, criteria, and scoring mode
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="program">Program</Label>
|
||||||
|
<Select value={programId} onValueChange={setProgramId}>
|
||||||
|
<SelectTrigger id="program">
|
||||||
|
<SelectValue placeholder="Select a program" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{programs?.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Award Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g., Mediterranean Entrepreneurship Award"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Brief description of this award"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="criteria">Eligibility Criteria</Label>
|
||||||
|
<Textarea
|
||||||
|
id="criteria"
|
||||||
|
value={criteriaText}
|
||||||
|
onChange={(e) => setCriteriaText(e.target.value)}
|
||||||
|
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This text will be used by AI to determine which projects are
|
||||||
|
eligible for this award.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="scoring">Scoring Mode</Label>
|
||||||
|
<Select
|
||||||
|
value={scoringMode}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setScoringMode(
|
||||||
|
v as 'PICK_WINNER' | 'RANKED' | 'SCORED'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="scoring">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="PICK_WINNER">
|
||||||
|
Pick Winner — Each juror picks 1
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="RANKED">
|
||||||
|
Ranked — Each juror ranks top N
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="SCORED">
|
||||||
|
Scored — Use evaluation form
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scoringMode === 'RANKED' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
||||||
|
<Input
|
||||||
|
id="maxPicks"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
value={maxRankedPicks}
|
||||||
|
onChange={(e) => setMaxRankedPicks(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/admin/awards">Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={createAward.isPending || !name.trim() || !programId}
|
||||||
|
>
|
||||||
|
{createAward.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Create Award
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Plus, Trophy, Users, CheckCircle2 } from 'lucide-react'
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
|
DRAFT: 'secondary',
|
||||||
|
NOMINATIONS_OPEN: 'default',
|
||||||
|
VOTING_OPEN: 'default',
|
||||||
|
CLOSED: 'outline',
|
||||||
|
ARCHIVED: 'secondary',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCORING_LABELS: Record<string, string> = {
|
||||||
|
PICK_WINNER: 'Pick Winner',
|
||||||
|
RANKED: 'Ranked',
|
||||||
|
SCORED: 'Scored',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AwardsListPage() {
|
||||||
|
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton className="h-9 w-48" />
|
||||||
|
<Skeleton className="h-9 w-32" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-48" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Special Awards
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage named awards with eligibility criteria and jury voting
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/admin/awards/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Award
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Awards Grid */}
|
||||||
|
{awards && awards.length > 0 ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{awards.map((award) => (
|
||||||
|
<Link key={award.id} href={`/admin/awards/${award.id}`}>
|
||||||
|
<Card className="transition-colors hover:bg-muted/50 cursor-pointer h-full">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Trophy className="h-5 w-5 text-amber-500" />
|
||||||
|
{award.name}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
||||||
|
{award.status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{award.description && (
|
||||||
|
<CardDescription className="line-clamp-2">
|
||||||
|
{award.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
{award._count.eligibilities} eligible
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
{award._count.jurors} jurors
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{SCORING_LABELS[award.scoringMode] || award.scoringMode}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{award.winnerProject && (
|
||||||
|
<div className="mt-3 pt-3 border-t">
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="text-muted-foreground">Winner:</span>{' '}
|
||||||
|
<span className="font-medium">
|
||||||
|
{award.winnerProject.title}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Trophy className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 font-medium">No awards yet</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Create special awards for outstanding projects
|
||||||
|
</p>
|
||||||
|
<Button className="mt-4" asChild>
|
||||||
|
<Link href="/admin/awards/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Award
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,377 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { TagInput } from '@/components/shared/tag-input'
|
||||||
|
import { UserActivityLog } from '@/components/shared/user-activity-log'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Save,
|
||||||
|
Mail,
|
||||||
|
User,
|
||||||
|
Shield,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export default function MemberDetailPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const userId = params.id as string
|
||||||
|
|
||||||
|
const { data: user, isLoading, refetch } = trpc.user.get.useQuery({ id: userId })
|
||||||
|
const updateUser = trpc.user.update.useMutation()
|
||||||
|
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
||||||
|
|
||||||
|
// Mentor assignments (only fetched for mentors)
|
||||||
|
const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery(
|
||||||
|
{ mentorId: userId, page: 1, perPage: 50 },
|
||||||
|
{ enabled: user?.role === 'MENTOR' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [role, setRole] = useState<string>('JURY_MEMBER')
|
||||||
|
const [status, setStatus] = useState<string>('INVITED')
|
||||||
|
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
||||||
|
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setName(user.name || '')
|
||||||
|
setRole(user.role)
|
||||||
|
setStatus(user.status)
|
||||||
|
setExpertiseTags(user.expertiseTags || [])
|
||||||
|
setMaxAssignments(user.maxAssignments?.toString() || '')
|
||||||
|
}
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await updateUser.mutateAsync({
|
||||||
|
id: userId,
|
||||||
|
name: name || null,
|
||||||
|
role: role as 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN',
|
||||||
|
status: status as 'INVITED' | 'ACTIVE' | 'SUSPENDED',
|
||||||
|
expertiseTags,
|
||||||
|
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
|
||||||
|
})
|
||||||
|
toast.success('Member updated successfully')
|
||||||
|
router.push('/admin/members')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to update member')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendInvitation = async () => {
|
||||||
|
try {
|
||||||
|
await sendInvitation.mutateAsync({ userId })
|
||||||
|
toast.success('Invitation email sent successfully')
|
||||||
|
refetch()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-9 w-32" />
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Member not found</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
The member you're looking for does not exist.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/admin/members">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Members
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<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'}>
|
||||||
|
{user.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{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" />
|
||||||
|
)}
|
||||||
|
Send Invitation
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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" value={user.email} disabled />
|
||||||
|
</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={setRole}>
|
||||||
|
<SelectTrigger id="role">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
|
||||||
|
<SelectItem value="MENTOR">Mentor</SelectItem>
|
||||||
|
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||||
|
<SelectItem value="PROGRAM_ADMIN">Program Admin</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="INVITED">Invited</SelectItem>
|
||||||
|
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||||
|
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Expertise & Capacity */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5" />
|
||||||
|
Expertise & Capacity
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Expertise Tags</Label>
|
||||||
|
<TagInput
|
||||||
|
value={expertiseTags}
|
||||||
|
onChange={setExpertiseTags}
|
||||||
|
placeholder="Select expertise tags..."
|
||||||
|
maxTags={15}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="maxAssignments">Max Assignments</Label>
|
||||||
|
<Input
|
||||||
|
id="maxAssignments"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={maxAssignments}
|
||||||
|
onChange={(e) => setMaxAssignments(e.target.value)}
|
||||||
|
placeholder="Unlimited"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{user._count && (
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<h4 className="font-medium mb-2">Statistics</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Jury Assignments</p>
|
||||||
|
<p className="text-2xl font-semibold">{user._count.assignments}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Mentor Assignments</p>
|
||||||
|
<p className="text-2xl font-semibold">{user._count.mentorAssignments}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mentor Assignments Section */}
|
||||||
|
{user.role === 'MENTOR' && mentorAssignments && mentorAssignments.assignments.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Mentored Projects</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Projects this mentor is assigned to
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Project</TableHead>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Assigned</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{mentorAssignments.assignments.map((assignment) => (
|
||||||
|
<TableRow key={assignment.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={`/admin/projects/${assignment.project.id}`}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{assignment.project.title}
|
||||||
|
</Link>
|
||||||
|
{assignment.project.teamName && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{assignment.project.teamName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{assignment.project.competitionCategory ? (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{assignment.project.competitionCategory.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{assignment.project.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{new Date(assignment.assignedAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Activity Log */}
|
||||||
|
<UserActivityLog userId={userId} />
|
||||||
|
|
||||||
|
{/* Status Alert */}
|
||||||
|
{user.status === 'INVITED' && (
|
||||||
|
<Alert>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
<AlertTitle>Invitation Pending</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
This member hasn't accepted their invitation yet. You can resend the
|
||||||
|
invitation email using the button above.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/admin/members">Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={updateUser.isPending}>
|
||||||
|
{updateUser.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
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'
|
||||||
|
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 { Badge } from '@/components/ui/badge'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
Mail,
|
||||||
|
FileSpreadsheet,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type Step = 'input' | 'preview' | 'sending' | 'complete'
|
||||||
|
type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||||
|
|
||||||
|
interface ParsedUser {
|
||||||
|
email: string
|
||||||
|
name?: string
|
||||||
|
isValid: boolean
|
||||||
|
error?: string
|
||||||
|
isDuplicate?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
export default function MemberInvitePage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [step, setStep] = useState<Step>('input')
|
||||||
|
const [inputMethod, setInputMethod] = useState<'textarea' | 'csv'>('textarea')
|
||||||
|
const [emailsText, setEmailsText] = useState('')
|
||||||
|
const [csvFile, setCsvFile] = useState<File | null>(null)
|
||||||
|
const [role, setRole] = useState<Role>('JURY_MEMBER')
|
||||||
|
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
||||||
|
const [tagInput, setTagInput] = useState('')
|
||||||
|
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([])
|
||||||
|
const [sendProgress, setSendProgress] = useState(0)
|
||||||
|
const [result, setResult] = useState<{ created: number; skipped: number } | null>(null)
|
||||||
|
|
||||||
|
const bulkCreate = trpc.user.bulkCreate.useMutation()
|
||||||
|
|
||||||
|
const parseEmailsFromText = useCallback((text: string): ParsedUser[] => {
|
||||||
|
const lines = text.split(/[\n,;]+/).map((line) => line.trim()).filter(Boolean)
|
||||||
|
const seenEmails = new Set<string>()
|
||||||
|
return lines.map((line) => {
|
||||||
|
const matchWithName = line.match(/^(.+?)\s*<(.+?)>$/)
|
||||||
|
const email = matchWithName ? matchWithName[2].trim().toLowerCase() : line.toLowerCase()
|
||||||
|
const name = matchWithName ? matchWithName[1].trim() : undefined
|
||||||
|
const isValidFormat = emailRegex.test(email)
|
||||||
|
const isDuplicate = seenEmails.has(email)
|
||||||
|
if (isValidFormat && !isDuplicate) seenEmails.add(email)
|
||||||
|
return {
|
||||||
|
email, name, isValid: isValidFormat && !isDuplicate, isDuplicate,
|
||||||
|
error: !isValidFormat ? 'Invalid email format' : isDuplicate ? 'Duplicate email' : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCSVUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setCsvFile(file)
|
||||||
|
Papa.parse<Record<string, string>>(file, {
|
||||||
|
header: true, skipEmptyLines: true,
|
||||||
|
complete: (results) => {
|
||||||
|
const seenEmails = new Set<string>()
|
||||||
|
const users: ParsedUser[] = results.data.map((row) => {
|
||||||
|
const emailKey = Object.keys(row).find((key) => key.toLowerCase() === 'email' || key.toLowerCase().includes('email'))
|
||||||
|
const nameKey = Object.keys(row).find((key) => key.toLowerCase() === 'name' || key.toLowerCase().includes('name'))
|
||||||
|
const email = emailKey ? row[emailKey]?.trim().toLowerCase() : ''
|
||||||
|
const name = nameKey ? row[nameKey]?.trim() : undefined
|
||||||
|
const isValidFormat = emailRegex.test(email)
|
||||||
|
const isDuplicate = email ? seenEmails.has(email) : false
|
||||||
|
if (isValidFormat && !isDuplicate && email) seenEmails.add(email)
|
||||||
|
return {
|
||||||
|
email, name, isValid: isValidFormat && !isDuplicate, isDuplicate,
|
||||||
|
error: !email ? 'No email found' : !isValidFormat ? 'Invalid email format' : isDuplicate ? 'Duplicate email' : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setParsedUsers(users.filter((u) => u.email))
|
||||||
|
setStep('preview')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTextProceed = () => { setParsedUsers(parseEmailsFromText(emailsText)); setStep('preview') }
|
||||||
|
const addTag = () => { const tag = tagInput.trim(); if (tag && !expertiseTags.includes(tag)) { setExpertiseTags([...expertiseTags, tag]); setTagInput('') } }
|
||||||
|
const removeTag = (tag: string) => setExpertiseTags(expertiseTags.filter((t) => t !== tag))
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const validUsers = parsedUsers.filter((u) => u.isValid)
|
||||||
|
const invalidUsers = parsedUsers.filter((u) => !u.isValid)
|
||||||
|
const duplicateUsers = parsedUsers.filter((u) => u.isDuplicate)
|
||||||
|
return { total: parsedUsers.length, valid: validUsers.length, invalid: invalidUsers.length, duplicates: duplicateUsers.length, validUsers, invalidUsers, duplicateUsers }
|
||||||
|
}, [parsedUsers])
|
||||||
|
|
||||||
|
const removeInvalidUsers = () => setParsedUsers(parsedUsers.filter((u) => u.isValid))
|
||||||
|
|
||||||
|
const handleSendInvites = async () => {
|
||||||
|
if (summary.valid === 0) return
|
||||||
|
setStep('sending'); setSendProgress(0)
|
||||||
|
try {
|
||||||
|
const result = await bulkCreate.mutateAsync({
|
||||||
|
users: summary.validUsers.map((u) => ({ email: u.email, name: u.name, role, expertiseTags: expertiseTags.length > 0 ? expertiseTags : undefined })),
|
||||||
|
})
|
||||||
|
setSendProgress(100); setResult(result); setStep('complete')
|
||||||
|
} catch { setStep('preview') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => { setStep('input'); setEmailsText(''); setCsvFile(null); setParsedUsers([]); setResult(null); setSendProgress(0) }
|
||||||
|
|
||||||
|
const steps: Array<{ key: Step; label: string }> = [
|
||||||
|
{ key: 'input', label: 'Input' }, { key: 'preview', label: 'Preview' },
|
||||||
|
{ key: 'sending', label: 'Send' }, { key: 'complete', label: 'Done' },
|
||||||
|
]
|
||||||
|
const currentStepIndex = steps.findIndex((s) => s.key === step)
|
||||||
|
|
||||||
|
const renderStep = () => {
|
||||||
|
switch (step) {
|
||||||
|
case 'input':
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Invite Members</CardTitle>
|
||||||
|
<CardDescription>Add email addresses to invite new members to the platform</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant={inputMethod === 'textarea' ? 'default' : 'outline'} size="sm" onClick={() => setInputMethod('textarea')}><Mail className="mr-2 h-4 w-4" />Enter Emails</Button>
|
||||||
|
<Button type="button" variant={inputMethod === 'csv' ? 'default' : 'outline'} size="sm" onClick={() => setInputMethod('csv')}><FileSpreadsheet className="mr-2 h-4 w-4" />Upload CSV</Button>
|
||||||
|
</div>
|
||||||
|
{inputMethod === 'textarea' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="emails">Email Addresses</Label>
|
||||||
|
<Textarea id="emails" value={emailsText} onChange={(e) => setEmailsText(e.target.value)} placeholder="Enter email addresses, one per line or comma-separated." rows={8} maxLength={10000} className="font-mono text-sm" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>CSV File</Label>
|
||||||
|
<div className={cn('border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors', 'hover:border-primary/50')} onClick={() => document.getElementById('csv-input')?.click()}>
|
||||||
|
<FileSpreadsheet className="mx-auto h-10 w-10 text-muted-foreground" />
|
||||||
|
<p className="mt-2 font-medium">{csvFile ? csvFile.name : 'Drop CSV file here or click to browse'}</p>
|
||||||
|
<Input id="csv-input" type="file" accept=".csv" onChange={handleCSVUpload} className="hidden" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="role">Role</Label>
|
||||||
|
<Select value={role} onValueChange={(v) => setRole(v as Role)}>
|
||||||
|
<SelectTrigger id="role"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
|
||||||
|
<SelectItem value="MENTOR">Mentor</SelectItem>
|
||||||
|
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="expertise">Expertise Tags (Optional)</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input id="expertise" value={tagInput} onChange={(e) => setTagInput(e.target.value)} placeholder="e.g., Marine Biology" onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addTag() } }} />
|
||||||
|
<Button type="button" variant="outline" onClick={addTag}>Add</Button>
|
||||||
|
</div>
|
||||||
|
{expertiseTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{expertiseTags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="gap-1">{tag}<button type="button" onClick={() => removeTag(tag)} className="ml-1 hover:text-destructive"><X className="h-3 w-3" /></button></Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between pt-4">
|
||||||
|
<Button variant="outline" asChild><Link href="/admin/members"><ArrowLeft className="mr-2 h-4 w-4" />Cancel</Link></Button>
|
||||||
|
<Button onClick={handleTextProceed} disabled={inputMethod === 'textarea' && !emailsText.trim()}>Preview<ArrowRight className="ml-2 h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
case 'preview':
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Preview Invitations</CardTitle><CardDescription>Review the list of users to invite</CardDescription></CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="rounded-lg bg-muted p-4 text-center"><p className="text-3xl font-bold">{summary.total}</p><p className="text-sm text-muted-foreground">Total</p></div>
|
||||||
|
<div className="rounded-lg bg-green-500/10 p-4 text-center"><p className="text-3xl font-bold text-green-600">{summary.valid}</p><p className="text-sm text-muted-foreground">Valid</p></div>
|
||||||
|
<div className="rounded-lg bg-red-500/10 p-4 text-center"><p className="text-3xl font-bold text-red-600">{summary.invalid}</p><p className="text-sm text-muted-foreground">Invalid</p></div>
|
||||||
|
</div>
|
||||||
|
{summary.invalid > 0 && (
|
||||||
|
<div className="flex items-start gap-3 rounded-lg bg-amber-500/10 p-4 text-amber-700">
|
||||||
|
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" /><div className="flex-1"><p className="font-medium">{summary.invalid} email(s) have issues</p></div>
|
||||||
|
<Button variant="outline" size="sm" onClick={removeInvalidUsers} className="shrink-0">Remove Invalid</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="rounded-lg border max-h-80 overflow-y-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader><TableRow><TableHead>Email</TableHead><TableHead>Name</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{parsedUsers.map((user, index) => (
|
||||||
|
<TableRow key={index} className={cn(!user.isValid && 'bg-red-500/5')}>
|
||||||
|
<TableCell className="font-mono text-sm">{user.email}</TableCell>
|
||||||
|
<TableCell>{user.name || '-'}</TableCell>
|
||||||
|
<TableCell>{user.isValid ? <Badge variant="outline" className="text-green-600"><CheckCircle2 className="mr-1 h-3 w-3" />Valid</Badge> : <Badge variant="destructive">{user.error}</Badge>}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between pt-4">
|
||||||
|
<Button variant="outline" onClick={() => { setParsedUsers([]); setStep('input') }}><ArrowLeft className="mr-2 h-4 w-4" />Back</Button>
|
||||||
|
<Button onClick={handleSendInvites} disabled={summary.valid === 0 || bulkCreate.isPending}>
|
||||||
|
{bulkCreate.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Users className="mr-2 h-4 w-4" />}
|
||||||
|
Create {summary.valid} Member{summary.valid !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{bulkCreate.error && <div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-4 text-destructive"><AlertCircle className="h-5 w-5" /><span>{bulkCreate.error.message}</span></div>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
case 'sending':
|
||||||
|
return (
|
||||||
|
<Card><CardContent className="flex flex-col items-center justify-center py-12"><Loader2 className="h-12 w-12 animate-spin text-primary" /><p className="mt-4 font-medium">Creating members...</p><Progress value={sendProgress} className="mt-4 w-48" /></CardContent></Card>
|
||||||
|
)
|
||||||
|
case 'complete':
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10"><CheckCircle2 className="h-8 w-8 text-green-600" /></div>
|
||||||
|
<p className="mt-4 text-xl font-semibold">Members Created!</p>
|
||||||
|
<p className="text-muted-foreground text-center max-w-sm mt-2">{result?.created} member{result?.created !== 1 ? 's' : ''} created successfully.{result?.skipped ? ` ${result.skipped} skipped (already exist).` : ''}</p>
|
||||||
|
<div className="mt-6 flex gap-3">
|
||||||
|
<Button variant="outline" asChild><Link href="/admin/members">View Members</Link></Button>
|
||||||
|
<Button onClick={resetForm}>Invite More</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Invite Members</h1>
|
||||||
|
<p className="text-muted-foreground">Add new members to the platform</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{steps.map((s, index) => (
|
||||||
|
<div key={s.key} className="flex items-center">
|
||||||
|
{index > 0 && <div className={cn('h-0.5 w-8 mx-1', index <= currentStepIndex ? 'bg-primary' : 'bg-muted')} />}
|
||||||
|
<div className={cn('flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium', index === currentStepIndex ? 'bg-primary text-primary-foreground' : index < currentStepIndex ? 'bg-primary/20 text-primary' : 'bg-muted text-muted-foreground')}>{index + 1}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{renderStep()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { MembersContent } from '@/components/admin/members-content'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default function MembersPage() {
|
||||||
|
return <MembersContent />
|
||||||
|
}
|
||||||
|
|
@ -1,389 +1,10 @@
|
||||||
'use client'
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
export default async function MentorDetailPage({
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
params,
|
||||||
import Link from 'next/link'
|
}: {
|
||||||
import type { Route } from 'next'
|
params: Promise<{ id: string }>
|
||||||
import { trpc } from '@/lib/trpc/client'
|
}) {
|
||||||
import { Button } from '@/components/ui/button'
|
const { id } = await params
|
||||||
import { Input } from '@/components/ui/input'
|
redirect(`/admin/members/${id}`)
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { TagInput } from '@/components/shared/tag-input'
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Save,
|
|
||||||
Mail,
|
|
||||||
GraduationCap,
|
|
||||||
Loader2,
|
|
||||||
AlertCircle,
|
|
||||||
ClipboardList,
|
|
||||||
User,
|
|
||||||
} from 'lucide-react'
|
|
||||||
|
|
||||||
export default function MentorDetailPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const mentorId = params.id as string
|
|
||||||
|
|
||||||
const { data: mentor, isLoading, refetch } = trpc.user.get.useQuery({ id: mentorId })
|
|
||||||
const updateUser = trpc.user.update.useMutation()
|
|
||||||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
|
||||||
const { data: assignmentsData } = trpc.mentor.listAssignments.useQuery({
|
|
||||||
mentorId,
|
|
||||||
perPage: 50,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
|
||||||
const [status, setStatus] = useState<'INVITED' | 'ACTIVE' | 'SUSPENDED'>('INVITED')
|
|
||||||
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
|
||||||
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mentor) {
|
|
||||||
setName(mentor.name || '')
|
|
||||||
setStatus(mentor.status as 'INVITED' | 'ACTIVE' | 'SUSPENDED')
|
|
||||||
setExpertiseTags(mentor.expertiseTags || [])
|
|
||||||
setMaxAssignments(mentor.maxAssignments?.toString() || '')
|
|
||||||
}
|
|
||||||
}, [mentor])
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
await updateUser.mutateAsync({
|
|
||||||
id: mentorId,
|
|
||||||
name: name || null,
|
|
||||||
status,
|
|
||||||
expertiseTags,
|
|
||||||
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
|
|
||||||
})
|
|
||||||
toast.success('Mentor updated successfully')
|
|
||||||
refetch()
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to update mentor')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSendInvitation = async () => {
|
|
||||||
try {
|
|
||||||
await sendInvitation.mutateAsync({ userId: mentorId })
|
|
||||||
toast.success('Invitation email sent successfully')
|
|
||||||
refetch()
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Skeleton className="h-9 w-32" />
|
|
||||||
</div>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<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" />
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mentor) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertTitle>Mentor not found</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
The mentor you're looking for does not exist.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href={"/admin/mentors" as Route}>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back to Mentors
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
|
|
||||||
ACTIVE: 'success',
|
|
||||||
INVITED: 'secondary',
|
|
||||||
SUSPENDED: 'destructive',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
|
||||||
<Link href={"/admin/mentors" as Route}>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back to Mentors
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
|
||||||
{mentor.name || 'Unnamed Mentor'}
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground">{mentor.email}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant={statusColors[mentor.status] || 'secondary'} className="text-sm">
|
|
||||||
{mentor.status}
|
|
||||||
</Badge>
|
|
||||||
{mentor.status === 'INVITED' && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
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" />
|
|
||||||
)}
|
|
||||||
Send Invitation
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
|
||||||
{/* Profile Info */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<User className="h-5 w-5" />
|
|
||||||
Profile Information
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Update the mentor's profile and settings
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<Input id="email" value={mentor.email} disabled />
|
|
||||||
</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="status">Status</Label>
|
|
||||||
<Select value={status} onValueChange={(v) => setStatus(v as typeof status)}>
|
|
||||||
<SelectTrigger id="status">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="INVITED">Invited</SelectItem>
|
|
||||||
<SelectItem value="ACTIVE">Active</SelectItem>
|
|
||||||
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Expertise & Capacity */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<GraduationCap className="h-5 w-5" />
|
|
||||||
Expertise & Capacity
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Configure expertise areas and assignment limits
|
|
||||||
</CardDescription>
|
|
||||||
</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"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Maximum number of projects this mentor can be assigned
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mentor._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">Total Assignments</p>
|
|
||||||
<p className="text-2xl font-semibold">{mentor._count.assignments}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Last Login</p>
|
|
||||||
<p className="text-lg">
|
|
||||||
{mentor.lastLoginAt
|
|
||||||
? new Date(mentor.lastLoginAt).toLocaleDateString()
|
|
||||||
: 'Never'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save Button */}
|
|
||||||
<div className="flex justify-end gap-4">
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link href={"/admin/mentors" as Route}>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>
|
|
||||||
|
|
||||||
{/* Assigned Projects */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<ClipboardList className="h-5 w-5" />
|
|
||||||
Assigned Projects
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Projects currently assigned to this mentor
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{assignmentsData && assignmentsData.assignments.length > 0 ? (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Project</TableHead>
|
|
||||||
<TableHead>Team</TableHead>
|
|
||||||
<TableHead>Category</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Assigned</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{assignmentsData.assignments.map((assignment) => (
|
|
||||||
<TableRow key={assignment.id}>
|
|
||||||
<TableCell>
|
|
||||||
<Link
|
|
||||||
href={`/admin/projects/${assignment.project.id}`}
|
|
||||||
className="font-medium text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{assignment.project.title}
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{assignment.project.teamName || '-'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{assignment.project.competitionCategory ? (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{assignment.project.competitionCategory}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">-</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{assignment.project.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
|
||||||
{new Date(assignment.assignedAt).toLocaleDateString()}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
) : (
|
|
||||||
<p className="py-8 text-center text-muted-foreground">
|
|
||||||
No projects assigned to this mentor yet.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Invitation Status */}
|
|
||||||
{mentor.status === 'INVITED' && (
|
|
||||||
<Alert>
|
|
||||||
<Mail className="h-4 w-4" />
|
|
||||||
<AlertTitle>Invitation Pending</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
This mentor hasn't accepted their invitation yet. You can resend
|
|
||||||
the invitation email using the button above.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,252 +1,5 @@
|
||||||
import { Suspense } from 'react'
|
import { redirect } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
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 { UserAvatar } from '@/components/shared/user-avatar'
|
|
||||||
import { getUserAvatarUrl } from '@/server/utils/avatar-url'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { Plus, GraduationCap, Eye } from 'lucide-react'
|
|
||||||
import { formatDate } from '@/lib/utils'
|
|
||||||
|
|
||||||
async function MentorsContent() {
|
|
||||||
const mentors = await prisma.user.findMany({
|
|
||||||
where: {
|
|
||||||
role: 'MENTOR',
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
mentorAssignments: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: [{ status: 'asc' }, { name: 'asc' }],
|
|
||||||
})
|
|
||||||
|
|
||||||
// Generate avatar URLs
|
|
||||||
const mentorsWithAvatars = await Promise.all(
|
|
||||||
mentors.map(async (mentor) => ({
|
|
||||||
...mentor,
|
|
||||||
avatarUrl: await getUserAvatarUrl(mentor.profileImageKey, mentor.profileImageProvider),
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
if (mentorsWithAvatars.length === 0) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<GraduationCap className="h-12 w-12 text-muted-foreground/50" />
|
|
||||||
<p className="mt-2 font-medium">No mentors yet</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Invite mentors to start matching them with projects
|
|
||||||
</p>
|
|
||||||
<Button asChild className="mt-4">
|
|
||||||
<Link href="/admin/users/invite">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Invite Mentor
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
|
|
||||||
ACTIVE: 'success',
|
|
||||||
INVITED: 'secondary',
|
|
||||||
SUSPENDED: 'destructive',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Desktop table view */}
|
|
||||||
<Card className="hidden md:block">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Mentor</TableHead>
|
|
||||||
<TableHead>Expertise</TableHead>
|
|
||||||
<TableHead>Assigned Projects</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Last Login</TableHead>
|
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{mentorsWithAvatars.map((mentor) => (
|
|
||||||
<TableRow key={mentor.id}>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<UserAvatar user={mentor} avatarUrl={mentor.avatarUrl} size="sm" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{mentor.name || 'Unnamed'}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{mentor.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{mentor.expertiseTags && mentor.expertiseTags.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{mentor.expertiseTags.slice(0, 3).map((tag) => (
|
|
||||||
<Badge key={tag} variant="outline" className="text-xs">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{mentor.expertiseTags.length > 3 && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
+{mentor.expertiseTags.length - 3}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-muted-foreground">-</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="font-medium">{mentor._count.mentorAssignments}</span>
|
|
||||||
<span className="text-muted-foreground"> project{mentor._count.mentorAssignments !== 1 ? 's' : ''}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={statusColors[mentor.status] || 'secondary'}>
|
|
||||||
{mentor.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{mentor.lastLoginAt ? (
|
|
||||||
formatDate(mentor.lastLoginAt)
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">Never</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button variant="ghost" size="sm" asChild>
|
|
||||||
<Link href={`/admin/mentors/${mentor.id}` as Route}>
|
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
|
||||||
View
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Mobile card view */}
|
|
||||||
<div className="space-y-4 md:hidden">
|
|
||||||
{mentorsWithAvatars.map((mentor) => (
|
|
||||||
<Card key={mentor.id}>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<UserAvatar user={mentor} avatarUrl={mentor.avatarUrl} size="md" />
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base">
|
|
||||||
{mentor.name || 'Unnamed'}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
{mentor.email}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant={statusColors[mentor.status] || 'secondary'}>
|
|
||||||
{mentor.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Assigned Projects</span>
|
|
||||||
<span className="font-medium">{mentor._count.mentorAssignments}</span>
|
|
||||||
</div>
|
|
||||||
{mentor.expertiseTags && mentor.expertiseTags.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{mentor.expertiseTags.map((tag) => (
|
|
||||||
<Badge key={tag} variant="outline" className="text-xs">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button variant="outline" size="sm" className="w-full" asChild>
|
|
||||||
<Link href={`/admin/mentors/${mentor.id}` as Route}>
|
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
|
||||||
View Details
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MentorsSkeleton() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-4">
|
|
||||||
<Skeleton className="h-10 w-10 rounded-full" />
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<Skeleton className="h-5 w-32" />
|
|
||||||
<Skeleton className="h-4 w-48" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-9 w-9" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MentorsPage() {
|
export default function MentorsPage() {
|
||||||
return (
|
redirect('/admin/members')
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Mentors</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Manage mentors and their project assignments
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/admin/users/invite">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Invite Mentor
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<Suspense fallback={<MentorsSkeleton />}>
|
|
||||||
<MentorsContent />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const edition = await prisma.program.findUnique({
|
const edition = await prisma.program.findUnique({
|
||||||
where: { id: editionId },
|
where: { id: editionId },
|
||||||
select: { name: true, year: true },
|
select: { name: true, year: true },
|
||||||
|
|
@ -651,6 +652,20 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
|
||||||
<GeographicSummaryCard programId={editionId} />
|
<GeographicSummaryCard programId={editionId} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Dashboard data load failed:', err)
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 font-medium">Dashboard temporarily unavailable</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Could not load dashboard data. Please refresh the page.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function DashboardSkeleton() {
|
function DashboardSkeleton() {
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,15 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{project.foundedAt && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Founded</p>
|
||||||
|
<p className="text-sm">{formatDateOnly(project.foundedAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submission URLs */}
|
{/* Submission URLs */}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { Suspense } from 'react'
|
'use client'
|
||||||
import Link from 'next/link'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useSearchParams, usePathname } from 'next/navigation'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -12,6 +13,7 @@ import {
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
|
|
@ -35,236 +37,182 @@ import {
|
||||||
Pencil,
|
Pencil,
|
||||||
FileUp,
|
FileUp,
|
||||||
Users,
|
Users,
|
||||||
|
Search,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatDateOnly, truncate } from '@/lib/utils'
|
import { truncate } from '@/lib/utils'
|
||||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||||
|
import { Pagination } from '@/components/shared/pagination'
|
||||||
|
import {
|
||||||
|
ProjectFiltersBar,
|
||||||
|
type ProjectFilters,
|
||||||
|
} from './project-filters'
|
||||||
|
|
||||||
async function ProjectsContent() {
|
const statusColors: Record<
|
||||||
const projects = await prisma.project.findMany({
|
string,
|
||||||
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
|
'default' | 'success' | 'secondary' | 'destructive' | 'warning'
|
||||||
select: {
|
> = {
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
teamName: true,
|
|
||||||
status: true,
|
|
||||||
logoKey: true,
|
|
||||||
createdAt: true,
|
|
||||||
round: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
status: true,
|
|
||||||
program: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
assignments: true,
|
|
||||||
files: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
take: 100,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (projects.length === 0) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
|
|
||||||
<p className="mt-2 font-medium">No projects yet</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Import projects via CSV or create them manually
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex gap-2">
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/admin/projects/import">
|
|
||||||
<FileUp className="mr-2 h-4 w-4" />
|
|
||||||
Import CSV
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link href="/admin/projects/new">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Add Project
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
|
||||||
SUBMITTED: 'secondary',
|
SUBMITTED: 'secondary',
|
||||||
UNDER_REVIEW: 'default',
|
ELIGIBLE: 'default',
|
||||||
SHORTLISTED: 'success',
|
ASSIGNED: 'default',
|
||||||
|
SEMIFINALIST: 'success',
|
||||||
FINALIST: 'success',
|
FINALIST: 'success',
|
||||||
WINNER: 'success',
|
WINNER: 'success',
|
||||||
REJECTED: 'destructive',
|
REJECTED: 'destructive',
|
||||||
WITHDRAWN: 'secondary',
|
WITHDRAWN: 'secondary',
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
function parseFiltersFromParams(
|
||||||
<>
|
searchParams: URLSearchParams
|
||||||
{/* Desktop table view */}
|
): ProjectFilters & { page: number } {
|
||||||
<Card className="hidden md:block">
|
return {
|
||||||
<Table>
|
search: searchParams.get('q') || '',
|
||||||
<TableHeader>
|
statuses: searchParams.get('status')
|
||||||
<TableRow>
|
? searchParams.get('status')!.split(',')
|
||||||
<TableHead>Project</TableHead>
|
: [],
|
||||||
<TableHead>Round</TableHead>
|
roundId: searchParams.get('round') || '',
|
||||||
<TableHead>Files</TableHead>
|
competitionCategory: searchParams.get('category') || '',
|
||||||
<TableHead>Assignments</TableHead>
|
oceanIssue: searchParams.get('issue') || '',
|
||||||
<TableHead>Status</TableHead>
|
country: searchParams.get('country') || '',
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
wantsMentorship:
|
||||||
</TableRow>
|
searchParams.get('mentorship') === 'true'
|
||||||
</TableHeader>
|
? true
|
||||||
<TableBody>
|
: searchParams.get('mentorship') === 'false'
|
||||||
{projects.map((project) => (
|
? false
|
||||||
<TableRow key={project.id} className="group relative cursor-pointer hover:bg-muted/50">
|
: undefined,
|
||||||
<TableCell>
|
hasFiles:
|
||||||
<Link href={`/admin/projects/${project.id}`} className="flex items-center gap-3 after:absolute after:inset-0 after:content-['']">
|
searchParams.get('hasFiles') === 'true'
|
||||||
<ProjectLogo
|
? true
|
||||||
project={project}
|
: searchParams.get('hasFiles') === 'false'
|
||||||
size="sm"
|
? false
|
||||||
fallback="initials"
|
: undefined,
|
||||||
/>
|
hasAssignments:
|
||||||
<div>
|
searchParams.get('hasAssign') === 'true'
|
||||||
<p className="font-medium hover:text-primary">
|
? true
|
||||||
{truncate(project.title, 40)}
|
: searchParams.get('hasAssign') === 'false'
|
||||||
</p>
|
? false
|
||||||
<p className="text-sm text-muted-foreground">
|
: undefined,
|
||||||
{project.teamName}
|
page: parseInt(searchParams.get('page') || '1', 10),
|
||||||
</p>
|
}
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div>
|
|
||||||
<p>{project.round.name}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{project.round.program.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{project._count.files}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
|
||||||
{project._count.assignments}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={statusColors[project.status] || 'secondary'}>
|
|
||||||
{project.status.replace('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="relative z-10 text-right">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Actions</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={`/admin/projects/${project.id}`}>
|
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
|
||||||
View Details
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={`/admin/projects/${project.id}/edit`}>
|
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={`/admin/projects/${project.id}/assignments`}>
|
|
||||||
<Users className="mr-2 h-4 w-4" />
|
|
||||||
Manage Assignments
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Mobile card view */}
|
|
||||||
<div className="space-y-4 md:hidden">
|
|
||||||
{projects.map((project) => (
|
|
||||||
<Link key={project.id} href={`/admin/projects/${project.id}`} className="block">
|
|
||||||
<Card className="transition-colors hover:bg-muted/50">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<ProjectLogo
|
|
||||||
project={project}
|
|
||||||
size="md"
|
|
||||||
fallback="initials"
|
|
||||||
/>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<CardTitle className="text-base line-clamp-2">
|
|
||||||
{project.title}
|
|
||||||
</CardTitle>
|
|
||||||
<Badge variant={statusColors[project.status] || 'secondary'} className="shrink-0">
|
|
||||||
{project.status.replace('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<CardDescription>{project.teamName}</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Round</span>
|
|
||||||
<span>{project.round.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Assignments</span>
|
|
||||||
<span>{project._count.assignments} jurors</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectsSkeleton() {
|
function filtersToParams(
|
||||||
return (
|
filters: ProjectFilters & { page: number }
|
||||||
<Card>
|
): URLSearchParams {
|
||||||
<CardContent className="p-6">
|
const params = new URLSearchParams()
|
||||||
<div className="space-y-4">
|
if (filters.search) params.set('q', filters.search)
|
||||||
{[...Array(5)].map((_, i) => (
|
if (filters.statuses.length > 0)
|
||||||
<div key={i} className="flex items-center justify-between">
|
params.set('status', filters.statuses.join(','))
|
||||||
<div className="space-y-2">
|
if (filters.roundId) params.set('round', filters.roundId)
|
||||||
<Skeleton className="h-5 w-64" />
|
if (filters.competitionCategory)
|
||||||
<Skeleton className="h-4 w-32" />
|
params.set('category', filters.competitionCategory)
|
||||||
</div>
|
if (filters.oceanIssue) params.set('issue', filters.oceanIssue)
|
||||||
<Skeleton className="h-9 w-9" />
|
if (filters.country) params.set('country', filters.country)
|
||||||
</div>
|
if (filters.wantsMentorship !== undefined)
|
||||||
))}
|
params.set('mentorship', String(filters.wantsMentorship))
|
||||||
</div>
|
if (filters.hasFiles !== undefined)
|
||||||
</CardContent>
|
params.set('hasFiles', String(filters.hasFiles))
|
||||||
</Card>
|
if (filters.hasAssignments !== undefined)
|
||||||
)
|
params.set('hasAssign', String(filters.hasAssignments))
|
||||||
|
if (filters.page > 1) params.set('page', String(filters.page))
|
||||||
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PER_PAGE = 20
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
|
|
||||||
|
const pathname = usePathname()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
const parsed = parseFiltersFromParams(searchParams)
|
||||||
|
const [filters, setFilters] = useState<ProjectFilters>({
|
||||||
|
search: parsed.search,
|
||||||
|
statuses: parsed.statuses,
|
||||||
|
roundId: parsed.roundId,
|
||||||
|
competitionCategory: parsed.competitionCategory,
|
||||||
|
oceanIssue: parsed.oceanIssue,
|
||||||
|
country: parsed.country,
|
||||||
|
wantsMentorship: parsed.wantsMentorship,
|
||||||
|
hasFiles: parsed.hasFiles,
|
||||||
|
hasAssignments: parsed.hasAssignments,
|
||||||
|
})
|
||||||
|
const [page, setPage] = useState(parsed.page)
|
||||||
|
const [searchInput, setSearchInput] = useState(parsed.search)
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (searchInput !== filters.search) {
|
||||||
|
setFilters((f) => ({ ...f, search: searchInput }))
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [searchInput, filters.search])
|
||||||
|
|
||||||
|
// Sync URL
|
||||||
|
const syncUrl = useCallback(
|
||||||
|
(f: ProjectFilters, p: number) => {
|
||||||
|
const params = filtersToParams({ ...f, page: p })
|
||||||
|
const qs = params.toString()
|
||||||
|
window.history.replaceState(null, '', qs ? `${pathname}?${qs}` : pathname)
|
||||||
|
},
|
||||||
|
[pathname]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
syncUrl(filters, page)
|
||||||
|
}, [filters, page, syncUrl])
|
||||||
|
|
||||||
|
// Reset page when filters change
|
||||||
|
const handleFiltersChange = (newFilters: ProjectFilters) => {
|
||||||
|
setFilters(newFilters)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build tRPC query input
|
||||||
|
const queryInput = {
|
||||||
|
search: filters.search || undefined,
|
||||||
|
statuses:
|
||||||
|
filters.statuses.length > 0
|
||||||
|
? (filters.statuses as Array<
|
||||||
|
| 'SUBMITTED'
|
||||||
|
| 'ELIGIBLE'
|
||||||
|
| 'ASSIGNED'
|
||||||
|
| 'SEMIFINALIST'
|
||||||
|
| 'FINALIST'
|
||||||
|
| 'REJECTED'
|
||||||
|
>)
|
||||||
|
: undefined,
|
||||||
|
roundId: filters.roundId || undefined,
|
||||||
|
competitionCategory:
|
||||||
|
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
|
||||||
|
undefined,
|
||||||
|
oceanIssue: filters.oceanIssue
|
||||||
|
? (filters.oceanIssue as
|
||||||
|
| 'POLLUTION_REDUCTION'
|
||||||
|
| 'CLIMATE_MITIGATION'
|
||||||
|
| 'TECHNOLOGY_INNOVATION'
|
||||||
|
| 'SUSTAINABLE_SHIPPING'
|
||||||
|
| 'BLUE_CARBON'
|
||||||
|
| 'HABITAT_RESTORATION'
|
||||||
|
| 'COMMUNITY_CAPACITY'
|
||||||
|
| 'SUSTAINABLE_FISHING'
|
||||||
|
| 'CONSUMER_AWARENESS'
|
||||||
|
| 'OCEAN_ACIDIFICATION'
|
||||||
|
| 'OTHER')
|
||||||
|
: undefined,
|
||||||
|
country: filters.country || undefined,
|
||||||
|
wantsMentorship: filters.wantsMentorship,
|
||||||
|
hasFiles: filters.hasFiles,
|
||||||
|
hasAssignments: filters.hasAssignments,
|
||||||
|
page,
|
||||||
|
perPage: PER_PAGE,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isLoading } = trpc.project.list.useQuery(queryInput)
|
||||||
|
const { data: filterOptions } = trpc.project.getFilterOptions.useQuery()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -291,10 +239,234 @@ export default function ProjectsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
placeholder="Search projects by title, team, or description..."
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<ProjectFiltersBar
|
||||||
|
filters={filters}
|
||||||
|
filterOptions={filterOptions}
|
||||||
|
onChange={handleFiltersChange}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Suspense fallback={<ProjectsSkeleton />}>
|
{isLoading ? (
|
||||||
<ProjectsContent />
|
<Card>
|
||||||
</Suspense>
|
<CardContent className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-5 w-64" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-9 w-9" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : data && data.projects.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 font-medium">No projects found</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{filters.search ||
|
||||||
|
filters.statuses.length > 0 ||
|
||||||
|
filters.roundId ||
|
||||||
|
filters.competitionCategory ||
|
||||||
|
filters.oceanIssue ||
|
||||||
|
filters.country
|
||||||
|
? 'Try adjusting your filters'
|
||||||
|
: 'Import projects via CSV or create them manually'}
|
||||||
|
</p>
|
||||||
|
{!filters.search && filters.statuses.length === 0 && (
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/admin/projects/import">
|
||||||
|
<FileUp className="mr-2 h-4 w-4" />
|
||||||
|
Import CSV
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/admin/projects/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Project
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : data ? (
|
||||||
|
<>
|
||||||
|
{/* Desktop table */}
|
||||||
|
<Card className="hidden md:block">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Project</TableHead>
|
||||||
|
<TableHead>Round</TableHead>
|
||||||
|
<TableHead>Files</TableHead>
|
||||||
|
<TableHead>Assignments</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.projects.map((project) => (
|
||||||
|
<TableRow
|
||||||
|
key={project.id}
|
||||||
|
className="group relative cursor-pointer hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={`/admin/projects/${project.id}`}
|
||||||
|
className="flex items-center gap-3 after:absolute after:inset-0 after:content-['']"
|
||||||
|
>
|
||||||
|
<ProjectLogo
|
||||||
|
project={project}
|
||||||
|
size="sm"
|
||||||
|
fallback="initials"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium hover:text-primary">
|
||||||
|
{truncate(project.title, 40)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{project.teamName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<p>{project.round.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{project.round.program?.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{project.files?.length ?? 0}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{project._count.assignments}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={statusColors[project.status] || 'secondary'}
|
||||||
|
>
|
||||||
|
{project.status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="relative z-10 text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/admin/projects/${project.id}`}>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
View Details
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/admin/projects/${project.id}/edit`}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href={`/admin/projects/${project.id}/assignments`}
|
||||||
|
>
|
||||||
|
<Users className="mr-2 h-4 w-4" />
|
||||||
|
Manage Assignments
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Mobile card view */}
|
||||||
|
<div className="space-y-4 md:hidden">
|
||||||
|
{data.projects.map((project) => (
|
||||||
|
<Link
|
||||||
|
key={project.id}
|
||||||
|
href={`/admin/projects/${project.id}`}
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<Card className="transition-colors hover:bg-muted/50">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ProjectLogo
|
||||||
|
project={project}
|
||||||
|
size="md"
|
||||||
|
fallback="initials"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<CardTitle className="text-base line-clamp-2">
|
||||||
|
{project.title}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
statusColors[project.status] || 'secondary'
|
||||||
|
}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{project.status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<CardDescription>{project.teamName}</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Round</span>
|
||||||
|
<span>{project.round.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Assignments</span>
|
||||||
|
<span>{project._count.assignments} jurors</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<Pagination
|
||||||
|
page={data.page}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
total={data.total}
|
||||||
|
perPage={PER_PAGE}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,336 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { ChevronDown, Filter, X } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const ALL_STATUSES = [
|
||||||
|
'SUBMITTED',
|
||||||
|
'ELIGIBLE',
|
||||||
|
'ASSIGNED',
|
||||||
|
'SEMIFINALIST',
|
||||||
|
'FINALIST',
|
||||||
|
'REJECTED',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
SUBMITTED: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||||
|
ELIGIBLE: 'bg-blue-100 text-blue-700 hover:bg-blue-200',
|
||||||
|
ASSIGNED: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200',
|
||||||
|
SEMIFINALIST: 'bg-green-100 text-green-700 hover:bg-green-200',
|
||||||
|
FINALIST: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200',
|
||||||
|
REJECTED: 'bg-red-100 text-red-700 hover:bg-red-200',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ISSUE_LABELS: Record<string, string> = {
|
||||||
|
POLLUTION_REDUCTION: 'Pollution Reduction',
|
||||||
|
CLIMATE_MITIGATION: 'Climate Mitigation',
|
||||||
|
TECHNOLOGY_INNOVATION: 'Technology Innovation',
|
||||||
|
SUSTAINABLE_SHIPPING: 'Sustainable Shipping',
|
||||||
|
BLUE_CARBON: 'Blue Carbon',
|
||||||
|
HABITAT_RESTORATION: 'Habitat Restoration',
|
||||||
|
COMMUNITY_CAPACITY: 'Community Capacity',
|
||||||
|
SUSTAINABLE_FISHING: 'Sustainable Fishing',
|
||||||
|
CONSUMER_AWARENESS: 'Consumer Awareness',
|
||||||
|
OCEAN_ACIDIFICATION: 'Ocean Acidification',
|
||||||
|
OTHER: 'Other',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectFilters {
|
||||||
|
search: string
|
||||||
|
statuses: string[]
|
||||||
|
roundId: string
|
||||||
|
competitionCategory: string
|
||||||
|
oceanIssue: string
|
||||||
|
country: string
|
||||||
|
wantsMentorship: boolean | undefined
|
||||||
|
hasFiles: boolean | undefined
|
||||||
|
hasAssignments: boolean | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterOptions {
|
||||||
|
rounds: Array<{ id: string; name: string; program: { name: string } }>
|
||||||
|
countries: string[]
|
||||||
|
categories: Array<{ value: string; count: number }>
|
||||||
|
issues: Array<{ value: string; count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectFiltersBarProps {
|
||||||
|
filters: ProjectFilters
|
||||||
|
filterOptions: FilterOptions | undefined
|
||||||
|
onChange: (filters: ProjectFilters) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectFiltersBar({
|
||||||
|
filters,
|
||||||
|
filterOptions,
|
||||||
|
onChange,
|
||||||
|
}: ProjectFiltersBarProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
const activeFilterCount = [
|
||||||
|
filters.statuses.length > 0,
|
||||||
|
filters.roundId !== '',
|
||||||
|
filters.competitionCategory !== '',
|
||||||
|
filters.oceanIssue !== '',
|
||||||
|
filters.country !== '',
|
||||||
|
filters.wantsMentorship !== undefined,
|
||||||
|
filters.hasFiles !== undefined,
|
||||||
|
filters.hasAssignments !== undefined,
|
||||||
|
].filter(Boolean).length
|
||||||
|
|
||||||
|
const toggleStatus = (status: string) => {
|
||||||
|
const next = filters.statuses.includes(status)
|
||||||
|
? filters.statuses.filter((s) => s !== status)
|
||||||
|
: [...filters.statuses, status]
|
||||||
|
onChange({ ...filters, statuses: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
onChange({
|
||||||
|
search: filters.search,
|
||||||
|
statuses: [],
|
||||||
|
roundId: '',
|
||||||
|
competitionCategory: '',
|
||||||
|
oceanIssue: '',
|
||||||
|
country: '',
|
||||||
|
wantsMentorship: undefined,
|
||||||
|
hasFiles: undefined,
|
||||||
|
hasAssignments: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<Card>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
Filters
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-1">
|
||||||
|
{activeFilterCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 text-muted-foreground transition-transform',
|
||||||
|
isOpen && 'rotate-180'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<CardContent className="space-y-4 pt-0">
|
||||||
|
{/* Status toggles */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Status</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ALL_STATUSES.map((status) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleStatus(status)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-3 py-1 text-xs font-medium transition-colors',
|
||||||
|
filters.statuses.includes(status)
|
||||||
|
? STATUS_COLORS[status]
|
||||||
|
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status.replace('_', ' ')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Select filters grid */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm">Round / Edition</Label>
|
||||||
|
<Select
|
||||||
|
value={filters.roundId || '_all'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
onChange({ ...filters, roundId: v === '_all' ? '' : v })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="All rounds" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_all">All rounds</SelectItem>
|
||||||
|
{filterOptions?.rounds.map((r) => (
|
||||||
|
<SelectItem key={r.id} value={r.id}>
|
||||||
|
{r.name} ({r.program.name})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm">Category</Label>
|
||||||
|
<Select
|
||||||
|
value={filters.competitionCategory || '_all'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
onChange({
|
||||||
|
...filters,
|
||||||
|
competitionCategory: v === '_all' ? '' : v,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="All categories" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_all">All categories</SelectItem>
|
||||||
|
{filterOptions?.categories.map((c) => (
|
||||||
|
<SelectItem key={c.value} value={c.value}>
|
||||||
|
{c.value.replace('_', ' ')} ({c.count})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm">Ocean Issue</Label>
|
||||||
|
<Select
|
||||||
|
value={filters.oceanIssue || '_all'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
onChange({ ...filters, oceanIssue: v === '_all' ? '' : v })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="All issues" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_all">All issues</SelectItem>
|
||||||
|
{filterOptions?.issues.map((i) => (
|
||||||
|
<SelectItem key={i.value} value={i.value}>
|
||||||
|
{ISSUE_LABELS[i.value] || i.value} ({i.count})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm">Country</Label>
|
||||||
|
<Select
|
||||||
|
value={filters.country || '_all'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
onChange({ ...filters, country: v === '_all' ? '' : v })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="All countries" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_all">All countries</SelectItem>
|
||||||
|
{filterOptions?.countries.map((c) => (
|
||||||
|
<SelectItem key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Boolean toggles */}
|
||||||
|
<div className="flex flex-wrap gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="hasFiles"
|
||||||
|
checked={filters.hasFiles === true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange({
|
||||||
|
...filters,
|
||||||
|
hasFiles: checked ? true : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="hasFiles" className="text-sm">
|
||||||
|
Has Documents
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="hasAssignments"
|
||||||
|
checked={filters.hasAssignments === true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange({
|
||||||
|
...filters,
|
||||||
|
hasAssignments: checked ? true : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="hasAssignments" className="text-sm">
|
||||||
|
Has Assignments
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="wantsMentorship"
|
||||||
|
checked={filters.wantsMentorship === true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange({
|
||||||
|
...filters,
|
||||||
|
wantsMentorship: checked ? true : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="wantsMentorship" className="text-sm">
|
||||||
|
Wants Mentorship
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear all */}
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearAll}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="mr-1 h-3 w-3" />
|
||||||
|
Clear All Filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Card>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { use } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Filter,
|
||||||
|
ListChecks,
|
||||||
|
ClipboardCheck,
|
||||||
|
Play,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export default function FilteringDashboardPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id: roundId } = use(params)
|
||||||
|
|
||||||
|
const { data: round, isLoading: roundLoading } =
|
||||||
|
trpc.round.get.useQuery({ id: roundId })
|
||||||
|
const { data: stats, isLoading: statsLoading, refetch: refetchStats } =
|
||||||
|
trpc.filtering.getResultStats.useQuery({ roundId })
|
||||||
|
const { data: rules } = trpc.filtering.getRules.useQuery({ roundId })
|
||||||
|
|
||||||
|
const executeRules = trpc.filtering.executeRules.useMutation()
|
||||||
|
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
|
||||||
|
|
||||||
|
const handleExecute = async () => {
|
||||||
|
try {
|
||||||
|
const result = await executeRules.mutateAsync({ roundId })
|
||||||
|
toast.success(
|
||||||
|
`Filtering complete: ${result.passed} passed, ${result.filteredOut} filtered out, ${result.flagged} flagged`
|
||||||
|
)
|
||||||
|
refetchStats()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Failed to execute filtering'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFinalize = async () => {
|
||||||
|
try {
|
||||||
|
const result = await finalizeResults.mutateAsync({ roundId })
|
||||||
|
toast.success(
|
||||||
|
`Finalized: ${result.passed} passed, ${result.filteredOut} filtered out`
|
||||||
|
)
|
||||||
|
refetchStats()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Failed to finalize'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roundLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-9 w-48" />
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" asChild className="-ml-4">
|
||||||
|
<Link href={`/admin/rounds/${roundId}`}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Round
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Filtering — {round?.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Configure and run automated project screening
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleExecute}
|
||||||
|
disabled={
|
||||||
|
executeRules.isPending || !rules || rules.length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{executeRules.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Run Filtering
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
{statsLoading ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-4">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-28" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : stats && stats.total > 0 ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||||
|
<Filter className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{stats.total}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Total</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-green-600">
|
||||||
|
{stats.passed}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Passed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-500/10">
|
||||||
|
<XCircle className="h-5 w-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-red-600">
|
||||||
|
{stats.filteredOut}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Filtered Out</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/10">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-amber-600">
|
||||||
|
{stats.flagged}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Flagged</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Filter className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 font-medium">No filtering results yet</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Configure rules and run filtering to screen projects
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Link href={`/admin/rounds/${roundId}/filtering/rules`}>
|
||||||
|
<Card className="transition-colors hover:bg-muted/50 cursor-pointer h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ListChecks className="h-5 w-5" />
|
||||||
|
Filtering Rules
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure field-based, document, and AI screening rules
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{rules?.length || 0} rule{(rules?.length || 0) !== 1 ? 's' : ''}{' '}
|
||||||
|
configured
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href={`/admin/rounds/${roundId}/filtering/results`}>
|
||||||
|
<Card className="transition-colors hover:bg-muted/50 cursor-pointer h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="h-5 w-5" />
|
||||||
|
Review Results
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Review outcomes, override decisions, and finalize filtering
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{stats && stats.total > 0 ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Badge variant="outline" className="text-green-600">
|
||||||
|
{stats.passed} passed
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-red-600">
|
||||||
|
{stats.filteredOut} filtered
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-amber-600">
|
||||||
|
{stats.flagged} flagged
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">No results yet</Badge>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Finalize */}
|
||||||
|
{stats && stats.total > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Finalize Filtering</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Apply filtering outcomes to project statuses. Passed projects become
|
||||||
|
Eligible. Filtered-out projects are set aside (not deleted) and can
|
||||||
|
be reinstated at any time.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button
|
||||||
|
onClick={handleFinalize}
|
||||||
|
disabled={finalizeResults.isPending}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
{finalizeResults.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Finalize Results
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,472 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { use, useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible'
|
||||||
|
import { Pagination } from '@/components/shared/pagination'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronDown,
|
||||||
|
RotateCcw,
|
||||||
|
Loader2,
|
||||||
|
ShieldCheck,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const OUTCOME_BADGES: Record<
|
||||||
|
string,
|
||||||
|
{ variant: 'default' | 'destructive' | 'secondary' | 'outline'; icon: React.ReactNode; label: string }
|
||||||
|
> = {
|
||||||
|
PASSED: {
|
||||||
|
variant: 'default',
|
||||||
|
icon: <CheckCircle2 className="mr-1 h-3 w-3" />,
|
||||||
|
label: 'Passed',
|
||||||
|
},
|
||||||
|
FILTERED_OUT: {
|
||||||
|
variant: 'destructive',
|
||||||
|
icon: <XCircle className="mr-1 h-3 w-3" />,
|
||||||
|
label: 'Filtered Out',
|
||||||
|
},
|
||||||
|
FLAGGED: {
|
||||||
|
variant: 'secondary',
|
||||||
|
icon: <AlertTriangle className="mr-1 h-3 w-3" />,
|
||||||
|
label: 'Flagged',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilteringResultsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id: roundId } = use(params)
|
||||||
|
|
||||||
|
const [outcomeFilter, setOutcomeFilter] = useState<string>('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||||
|
const [overrideDialog, setOverrideDialog] = useState<{
|
||||||
|
id: string
|
||||||
|
currentOutcome: string
|
||||||
|
} | null>(null)
|
||||||
|
const [overrideOutcome, setOverrideOutcome] = useState<string>('PASSED')
|
||||||
|
const [overrideReason, setOverrideReason] = useState('')
|
||||||
|
|
||||||
|
const perPage = 20
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = trpc.filtering.getResults.useQuery({
|
||||||
|
roundId,
|
||||||
|
outcome: outcomeFilter
|
||||||
|
? (outcomeFilter as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED')
|
||||||
|
: undefined,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const overrideResult = trpc.filtering.overrideResult.useMutation()
|
||||||
|
const reinstateProject = trpc.filtering.reinstateProject.useMutation()
|
||||||
|
|
||||||
|
const toggleRow = (id: string) => {
|
||||||
|
const next = new Set(expandedRows)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
setExpandedRows(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOverride = async () => {
|
||||||
|
if (!overrideDialog || !overrideReason.trim()) return
|
||||||
|
try {
|
||||||
|
await overrideResult.mutateAsync({
|
||||||
|
id: overrideDialog.id,
|
||||||
|
finalOutcome: overrideOutcome as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED',
|
||||||
|
reason: overrideReason.trim(),
|
||||||
|
})
|
||||||
|
toast.success('Result overridden')
|
||||||
|
setOverrideDialog(null)
|
||||||
|
setOverrideReason('')
|
||||||
|
refetch()
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to override result')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReinstate = async (projectId: string) => {
|
||||||
|
try {
|
||||||
|
await reinstateProject.mutateAsync({ roundId, projectId })
|
||||||
|
toast.success('Project reinstated')
|
||||||
|
refetch()
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to reinstate project')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-9 w-48" />
|
||||||
|
<Skeleton className="h-96 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" asChild className="-ml-4">
|
||||||
|
<Link href={`/admin/rounds/${roundId}/filtering`}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Filtering
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Filtering Results
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Review and override filtering outcomes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Outcome Filter Tabs */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{['', 'PASSED', 'FILTERED_OUT', 'FLAGGED'].map((outcome) => (
|
||||||
|
<Button
|
||||||
|
key={outcome || 'all'}
|
||||||
|
variant={outcomeFilter === outcome ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setOutcomeFilter(outcome)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{outcome ? (
|
||||||
|
<>
|
||||||
|
{OUTCOME_BADGES[outcome].icon}
|
||||||
|
{OUTCOME_BADGES[outcome].label}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'All'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Table */}
|
||||||
|
{data && data.results.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Project</TableHead>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
|
<TableHead>Country</TableHead>
|
||||||
|
<TableHead>Outcome</TableHead>
|
||||||
|
<TableHead>Override</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.results.map((result) => {
|
||||||
|
const isExpanded = expandedRows.has(result.id)
|
||||||
|
const effectiveOutcome =
|
||||||
|
result.finalOutcome || result.outcome
|
||||||
|
const badge = OUTCOME_BADGES[effectiveOutcome]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableRow
|
||||||
|
key={result.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => toggleRow(result.id)}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{result.project.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{result.project.teamName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{result.project.competitionCategory ? (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{result.project.competitionCategory.replace(
|
||||||
|
'_',
|
||||||
|
' '
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{result.project.country || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={badge?.variant || 'secondary'}>
|
||||||
|
{badge?.icon}
|
||||||
|
{badge?.label || effectiveOutcome}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{result.overriddenByUser ? (
|
||||||
|
<div className="text-xs">
|
||||||
|
<p className="font-medium">
|
||||||
|
{result.overriddenByUser.name || result.overriddenByUser.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{result.overrideReason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div
|
||||||
|
className="flex justify-end gap-1"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setOverrideOutcome('PASSED')
|
||||||
|
setOverrideDialog({
|
||||||
|
id: result.id,
|
||||||
|
currentOutcome: effectiveOutcome,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="mr-1 h-3 w-3" />
|
||||||
|
Override
|
||||||
|
</Button>
|
||||||
|
{effectiveOutcome === 'FILTERED_OUT' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
handleReinstate(result.projectId)
|
||||||
|
}
|
||||||
|
disabled={reinstateProject.isPending}
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-1 h-3 w-3" />
|
||||||
|
Reinstate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{isExpanded && (
|
||||||
|
<TableRow key={`${result.id}-detail`}>
|
||||||
|
<TableCell colSpan={6} className="bg-muted/30">
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Rule Results
|
||||||
|
</p>
|
||||||
|
{result.ruleResultsJson &&
|
||||||
|
Array.isArray(result.ruleResultsJson) ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(
|
||||||
|
result.ruleResultsJson as Array<{
|
||||||
|
ruleName: string
|
||||||
|
ruleType: string
|
||||||
|
passed: boolean
|
||||||
|
action: string
|
||||||
|
reasoning?: string
|
||||||
|
}>
|
||||||
|
).map((rr, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
{rr.passed ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-4 w-4 text-red-600" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium">
|
||||||
|
{rr.ruleName}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{rr.ruleType}
|
||||||
|
</Badge>
|
||||||
|
{rr.reasoning && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
— {rr.reasoning}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No detailed rule results available
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{result.aiScreeningJson && (
|
||||||
|
<Collapsible>
|
||||||
|
<CollapsibleTrigger className="flex items-center gap-1 text-sm font-medium">
|
||||||
|
AI Screening Details
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<pre className="mt-2 text-xs bg-muted rounded p-2 overflow-x-auto">
|
||||||
|
{JSON.stringify(
|
||||||
|
result.aiScreeningJson,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
page={data.page}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
total={data.total}
|
||||||
|
perPage={perPage}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<CheckCircle2 className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 font-medium">No results found</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{outcomeFilter
|
||||||
|
? 'No results match this filter'
|
||||||
|
: 'Run filtering rules to generate results'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Override Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!overrideDialog}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setOverrideDialog(null)
|
||||||
|
setOverrideReason('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Override Filtering Result</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Change the outcome for this project. This will be logged in the
|
||||||
|
audit trail.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>New Outcome</Label>
|
||||||
|
<Select
|
||||||
|
value={overrideOutcome}
|
||||||
|
onValueChange={setOverrideOutcome}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="PASSED">Passed</SelectItem>
|
||||||
|
<SelectItem value="FILTERED_OUT">Filtered Out</SelectItem>
|
||||||
|
<SelectItem value="FLAGGED">Flagged</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Reason</Label>
|
||||||
|
<Input
|
||||||
|
value={overrideReason}
|
||||||
|
onChange={(e) => setOverrideReason(e.target.value)}
|
||||||
|
placeholder="Explain why you're overriding..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOverrideDialog(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleOverride}
|
||||||
|
disabled={
|
||||||
|
overrideResult.isPending || !overrideReason.trim()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{overrideResult.isPending && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Override
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,518 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { use, useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
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 { Badge } from '@/components/ui/badge'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
GripVertical,
|
||||||
|
Loader2,
|
||||||
|
FileCheck,
|
||||||
|
Brain,
|
||||||
|
Filter,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
type RuleType = 'FIELD_BASED' | 'DOCUMENT_CHECK' | 'AI_SCREENING'
|
||||||
|
|
||||||
|
const RULE_TYPE_LABELS: Record<RuleType, string> = {
|
||||||
|
FIELD_BASED: 'Field-Based',
|
||||||
|
DOCUMENT_CHECK: 'Document Check',
|
||||||
|
AI_SCREENING: 'AI Screening',
|
||||||
|
}
|
||||||
|
|
||||||
|
const RULE_TYPE_ICONS: Record<RuleType, React.ReactNode> = {
|
||||||
|
FIELD_BASED: <Filter className="h-4 w-4" />,
|
||||||
|
DOCUMENT_CHECK: <FileCheck className="h-4 w-4" />,
|
||||||
|
AI_SCREENING: <Brain className="h-4 w-4" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_OPTIONS = [
|
||||||
|
{ value: 'competitionCategory', label: 'Competition Category' },
|
||||||
|
{ value: 'foundedAt', label: 'Founded Date' },
|
||||||
|
{ value: 'country', label: 'Country' },
|
||||||
|
{ value: 'geographicZone', label: 'Geographic Zone' },
|
||||||
|
{ value: 'tags', label: 'Tags' },
|
||||||
|
{ value: 'oceanIssue', label: 'Ocean Issue' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const OPERATOR_OPTIONS = [
|
||||||
|
{ value: 'equals', label: 'Equals' },
|
||||||
|
{ value: 'not_equals', label: 'Not Equals' },
|
||||||
|
{ value: 'contains', label: 'Contains' },
|
||||||
|
{ value: 'in', label: 'In (list)' },
|
||||||
|
{ value: 'not_in', label: 'Not In (list)' },
|
||||||
|
{ value: 'is_empty', label: 'Is Empty' },
|
||||||
|
{ value: 'older_than_years', label: 'Older Than (years)' },
|
||||||
|
{ value: 'newer_than_years', label: 'Newer Than (years)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function FilteringRulesPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id: roundId } = use(params)
|
||||||
|
|
||||||
|
const { data: rules, isLoading, refetch } =
|
||||||
|
trpc.filtering.getRules.useQuery({ roundId })
|
||||||
|
const createRule = trpc.filtering.createRule.useMutation()
|
||||||
|
const updateRule = trpc.filtering.updateRule.useMutation()
|
||||||
|
const deleteRule = trpc.filtering.deleteRule.useMutation()
|
||||||
|
|
||||||
|
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||||
|
const [newRuleName, setNewRuleName] = useState('')
|
||||||
|
const [newRuleType, setNewRuleType] = useState<RuleType>('FIELD_BASED')
|
||||||
|
|
||||||
|
// Field-based config state
|
||||||
|
const [conditionField, setConditionField] = useState('competitionCategory')
|
||||||
|
const [conditionOperator, setConditionOperator] = useState('equals')
|
||||||
|
const [conditionValue, setConditionValue] = useState('')
|
||||||
|
const [conditionLogic, setConditionLogic] = useState<'AND' | 'OR'>('AND')
|
||||||
|
const [conditionAction, setConditionAction] = useState<'PASS' | 'REJECT' | 'FLAG'>('REJECT')
|
||||||
|
|
||||||
|
// Document check config state
|
||||||
|
const [minFileCount, setMinFileCount] = useState('1')
|
||||||
|
const [docAction, setDocAction] = useState<'PASS' | 'REJECT' | 'FLAG'>('REJECT')
|
||||||
|
|
||||||
|
// AI screening config state
|
||||||
|
const [criteriaText, setCriteriaText] = useState('')
|
||||||
|
|
||||||
|
const handleCreateRule = async () => {
|
||||||
|
if (!newRuleName.trim()) return
|
||||||
|
|
||||||
|
let configJson: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
if (newRuleType === 'FIELD_BASED') {
|
||||||
|
configJson = {
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
field: conditionField,
|
||||||
|
operator: conditionOperator,
|
||||||
|
value: conditionOperator === 'in' || conditionOperator === 'not_in'
|
||||||
|
? conditionValue.split(',').map((v) => v.trim())
|
||||||
|
: conditionOperator === 'older_than_years' ||
|
||||||
|
conditionOperator === 'newer_than_years' ||
|
||||||
|
conditionOperator === 'greater_than' ||
|
||||||
|
conditionOperator === 'less_than'
|
||||||
|
? Number(conditionValue)
|
||||||
|
: conditionValue,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
logic: conditionLogic,
|
||||||
|
action: conditionAction,
|
||||||
|
}
|
||||||
|
} else if (newRuleType === 'DOCUMENT_CHECK') {
|
||||||
|
configJson = {
|
||||||
|
minFileCount: parseInt(minFileCount) || 1,
|
||||||
|
action: docAction,
|
||||||
|
}
|
||||||
|
} else if (newRuleType === 'AI_SCREENING') {
|
||||||
|
configJson = {
|
||||||
|
criteriaText,
|
||||||
|
action: 'FLAG',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createRule.mutateAsync({
|
||||||
|
roundId,
|
||||||
|
name: newRuleName.trim(),
|
||||||
|
ruleType: newRuleType,
|
||||||
|
configJson,
|
||||||
|
priority: (rules?.length || 0) + 1,
|
||||||
|
})
|
||||||
|
toast.success('Rule created')
|
||||||
|
setShowCreateDialog(false)
|
||||||
|
resetForm()
|
||||||
|
refetch()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Failed to create rule'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleActive = async (ruleId: string, isActive: boolean) => {
|
||||||
|
try {
|
||||||
|
await updateRule.mutateAsync({ id: ruleId, isActive })
|
||||||
|
refetch()
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to update rule')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteRule = async (ruleId: string) => {
|
||||||
|
try {
|
||||||
|
await deleteRule.mutateAsync({ id: ruleId })
|
||||||
|
toast.success('Rule deleted')
|
||||||
|
refetch()
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete rule')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setNewRuleName('')
|
||||||
|
setNewRuleType('FIELD_BASED')
|
||||||
|
setConditionField('competitionCategory')
|
||||||
|
setConditionOperator('equals')
|
||||||
|
setConditionValue('')
|
||||||
|
setConditionLogic('AND')
|
||||||
|
setConditionAction('REJECT')
|
||||||
|
setMinFileCount('1')
|
||||||
|
setDocAction('REJECT')
|
||||||
|
setCriteriaText('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-9 w-48" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-24 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" asChild className="-ml-4">
|
||||||
|
<Link href={`/admin/rounds/${roundId}/filtering`}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Filtering
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Filtering Rules
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Rules are evaluated in order of priority
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Rule
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Filtering Rule</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Define conditions that projects must meet
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Rule Name</Label>
|
||||||
|
<Input
|
||||||
|
value={newRuleName}
|
||||||
|
onChange={(e) => setNewRuleName(e.target.value)}
|
||||||
|
placeholder="e.g., Startup age check"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Rule Type</Label>
|
||||||
|
<Select
|
||||||
|
value={newRuleType}
|
||||||
|
onValueChange={(v) => setNewRuleType(v as RuleType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="FIELD_BASED">Field-Based</SelectItem>
|
||||||
|
<SelectItem value="DOCUMENT_CHECK">
|
||||||
|
Document Check
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="AI_SCREENING">AI Screening</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Field-Based Config */}
|
||||||
|
{newRuleType === 'FIELD_BASED' && (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Field</Label>
|
||||||
|
<Select
|
||||||
|
value={conditionField}
|
||||||
|
onValueChange={setConditionField}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FIELD_OPTIONS.map((f) => (
|
||||||
|
<SelectItem key={f.value} value={f.value}>
|
||||||
|
{f.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Operator</Label>
|
||||||
|
<Select
|
||||||
|
value={conditionOperator}
|
||||||
|
onValueChange={setConditionOperator}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{OPERATOR_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{conditionOperator !== 'is_empty' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Value</Label>
|
||||||
|
<Input
|
||||||
|
value={conditionValue}
|
||||||
|
onChange={(e) => setConditionValue(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
conditionOperator === 'in' ||
|
||||||
|
conditionOperator === 'not_in'
|
||||||
|
? 'Comma-separated values'
|
||||||
|
: 'Value'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Logic</Label>
|
||||||
|
<Select
|
||||||
|
value={conditionLogic}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setConditionLogic(v as 'AND' | 'OR')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="AND">AND</SelectItem>
|
||||||
|
<SelectItem value="OR">OR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Action</Label>
|
||||||
|
<Select
|
||||||
|
value={conditionAction}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setConditionAction(v as 'PASS' | 'REJECT' | 'FLAG')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="PASS">Pass</SelectItem>
|
||||||
|
<SelectItem value="REJECT">Reject</SelectItem>
|
||||||
|
<SelectItem value="FLAG">Flag</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Document Check Config */}
|
||||||
|
{newRuleType === 'DOCUMENT_CHECK' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Minimum File Count</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={minFileCount}
|
||||||
|
onChange={(e) => setMinFileCount(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Action if not met</Label>
|
||||||
|
<Select
|
||||||
|
value={docAction}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setDocAction(v as 'PASS' | 'REJECT' | 'FLAG')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="REJECT">Reject</SelectItem>
|
||||||
|
<SelectItem value="FLAG">Flag</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Screening Config */}
|
||||||
|
{newRuleType === 'AI_SCREENING' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Screening Criteria</Label>
|
||||||
|
<Textarea
|
||||||
|
value={criteriaText}
|
||||||
|
onChange={(e) => setCriteriaText(e.target.value)}
|
||||||
|
placeholder="Describe the criteria for AI to evaluate projects against..."
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
AI screening always flags projects for human review, never
|
||||||
|
auto-rejects.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowCreateDialog(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateRule}
|
||||||
|
disabled={
|
||||||
|
createRule.isPending ||
|
||||||
|
!newRuleName.trim() ||
|
||||||
|
(newRuleType === 'AI_SCREENING' && !criteriaText.trim())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{createRule.isPending && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Create Rule
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rules List */}
|
||||||
|
{rules && rules.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rules.map((rule, index) => (
|
||||||
|
<Card key={rule.id}>
|
||||||
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-mono w-6 text-center">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{RULE_TYPE_ICONS[rule.ruleType as RuleType]}
|
||||||
|
<p className="font-medium">{rule.name}</p>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{RULE_TYPE_LABELS[rule.ruleType as RuleType]}
|
||||||
|
</Badge>
|
||||||
|
{!rule.isActive && (
|
||||||
|
<Badge variant="secondary">Disabled</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{rule.ruleType === 'AI_SCREENING'
|
||||||
|
? (rule.configJson as Record<string, unknown>)
|
||||||
|
?.criteriaText
|
||||||
|
? String(
|
||||||
|
(rule.configJson as Record<string, unknown>)
|
||||||
|
.criteriaText
|
||||||
|
).slice(0, 80) + '...'
|
||||||
|
: 'AI screening rule'
|
||||||
|
: rule.ruleType === 'DOCUMENT_CHECK'
|
||||||
|
? `Min ${(rule.configJson as Record<string, unknown>)?.minFileCount || 1} file(s)`
|
||||||
|
: `${((rule.configJson as Record<string, unknown>)?.conditions as Array<Record<string, unknown>>)?.length || 0} condition(s)`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
checked={rule.isActive}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleToggleActive(rule.id, checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDeleteRule(rule.id)}
|
||||||
|
disabled={deleteRule.isPending}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Filter className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 font-medium">No rules configured</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Add filtering rules to screen projects automatically
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
Pause,
|
Pause,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Upload,
|
Upload,
|
||||||
|
Filter,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { format, formatDistanceToNow, isPast, isFuture } from 'date-fns'
|
import { format, formatDistanceToNow, isPast, isFuture } from 'date-fns'
|
||||||
|
|
||||||
|
|
@ -344,6 +345,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
Import Projects
|
Import Projects
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={`/admin/rounds/${round.id}/filtering`}>
|
||||||
|
<Filter className="mr-2 h-4 w-4" />
|
||||||
|
Manage Filtering
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link href={`/admin/rounds/${round.id}/assignments`}>
|
<Link href={`/admin/rounds/${round.id}/assignments`}>
|
||||||
<Users className="mr-2 h-4 w-4" />
|
<Users className="mr-2 h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -1,316 +1,10 @@
|
||||||
'use client'
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
export default async function UserEditPage({
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
params,
|
||||||
import Link from 'next/link'
|
}: {
|
||||||
import { trpc } from '@/lib/trpc/client'
|
params: Promise<{ id: string }>
|
||||||
import { Button } from '@/components/ui/button'
|
}) {
|
||||||
import { Input } from '@/components/ui/input'
|
const { id } = await params
|
||||||
import { Label } from '@/components/ui/label'
|
redirect(`/admin/members/${id}`)
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { TagInput } from '@/components/shared/tag-input'
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Save,
|
|
||||||
Mail,
|
|
||||||
User,
|
|
||||||
Shield,
|
|
||||||
Loader2,
|
|
||||||
CheckCircle,
|
|
||||||
AlertCircle,
|
|
||||||
} from 'lucide-react'
|
|
||||||
|
|
||||||
export default function UserEditPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const userId = params.id as string
|
|
||||||
|
|
||||||
const { data: user, isLoading, refetch } = trpc.user.get.useQuery({ id: userId })
|
|
||||||
const updateUser = trpc.user.update.useMutation()
|
|
||||||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
|
||||||
const [role, setRole] = useState<'JURY_MEMBER' | 'OBSERVER' | 'PROGRAM_ADMIN'>('JURY_MEMBER')
|
|
||||||
const [status, setStatus] = useState<'INVITED' | 'ACTIVE' | 'SUSPENDED'>('INVITED')
|
|
||||||
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
|
||||||
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
|
||||||
|
|
||||||
// Populate form when user data loads
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
setName(user.name || '')
|
|
||||||
setRole(user.role as 'JURY_MEMBER' | 'OBSERVER' | 'PROGRAM_ADMIN')
|
|
||||||
setStatus(user.status as 'INVITED' | 'ACTIVE' | 'SUSPENDED')
|
|
||||||
setExpertiseTags(user.expertiseTags || [])
|
|
||||||
setMaxAssignments(user.maxAssignments?.toString() || '')
|
|
||||||
}
|
|
||||||
}, [user])
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
await updateUser.mutateAsync({
|
|
||||||
id: userId,
|
|
||||||
name: name || null,
|
|
||||||
role,
|
|
||||||
status,
|
|
||||||
expertiseTags,
|
|
||||||
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
|
|
||||||
})
|
|
||||||
toast.success('User updated successfully')
|
|
||||||
router.push('/admin/users')
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to update user')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSendInvitation = async () => {
|
|
||||||
try {
|
|
||||||
await sendInvitation.mutateAsync({ userId })
|
|
||||||
toast.success('Invitation email sent successfully')
|
|
||||||
refetch()
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Skeleton className="h-9 w-32" />
|
|
||||||
</div>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<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" />
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertTitle>User not found</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
The user you're looking for does not exist.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/admin/users">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back to Users
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
|
||||||
<Link href="/admin/users">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back to Users
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Edit User</h1>
|
|
||||||
<p className="text-muted-foreground">{user.email}</p>
|
|
||||||
</div>
|
|
||||||
{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" />
|
|
||||||
)}
|
|
||||||
Send Invitation
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<CardDescription>
|
|
||||||
Update the user's profile information
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<Input id="email" value={user.email} disabled />
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Email cannot be changed
|
|
||||||
</p>
|
|
||||||
</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) => setRole(v as typeof role)}>
|
|
||||||
<SelectTrigger id="role">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
|
|
||||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
|
||||||
<SelectItem value="PROGRAM_ADMIN">Program Admin</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="status">Status</Label>
|
|
||||||
<Select value={status} onValueChange={(v) => setStatus(v as typeof status)}>
|
|
||||||
<SelectTrigger id="status">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="INVITED">Invited</SelectItem>
|
|
||||||
<SelectItem value="ACTIVE">Active</SelectItem>
|
|
||||||
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Assignment Settings */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Shield className="h-5 w-5" />
|
|
||||||
Assignment Settings
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Configure expertise tags and assignment limits
|
|
||||||
</CardDescription>
|
|
||||||
</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"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Maximum number of projects this user can be assigned
|
|
||||||
</p>
|
|
||||||
</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">Total Assignments</p>
|
|
||||||
<p className="text-2xl font-semibold">{user._count.assignments}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Last Login</p>
|
|
||||||
<p className="text-lg">
|
|
||||||
{user.lastLoginAt
|
|
||||||
? new Date(user.lastLoginAt).toLocaleDateString()
|
|
||||||
: 'Never'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Alert */}
|
|
||||||
{user.status === 'INVITED' && (
|
|
||||||
<Alert>
|
|
||||||
<Mail className="h-4 w-4" />
|
|
||||||
<AlertTitle>Invitation Pending</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
This user hasn't accepted their invitation yet. You can resend the
|
|
||||||
invitation email using the button above.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Save Button */}
|
|
||||||
<div className="flex justify-end gap-4">
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link href="/admin/users">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>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,676 +1,5 @@
|
||||||
'use client'
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
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'
|
|
||||||
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 { Badge } from '@/components/ui/badge'
|
|
||||||
import { Progress } from '@/components/ui/progress'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
ArrowRight,
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle2,
|
|
||||||
Loader2,
|
|
||||||
Upload,
|
|
||||||
Users,
|
|
||||||
X,
|
|
||||||
Mail,
|
|
||||||
FileSpreadsheet,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
type Step = 'input' | 'preview' | 'sending' | 'complete'
|
|
||||||
type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
|
||||||
|
|
||||||
interface ParsedUser {
|
|
||||||
email: string
|
|
||||||
name?: string
|
|
||||||
isValid: boolean
|
|
||||||
error?: string
|
|
||||||
isDuplicate?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// Email validation regex
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
||||||
|
|
||||||
export default function UserInvitePage() {
|
export default function UserInvitePage() {
|
||||||
const router = useRouter()
|
redirect('/admin/members/invite')
|
||||||
const [step, setStep] = useState<Step>('input')
|
|
||||||
|
|
||||||
// Input state
|
|
||||||
const [inputMethod, setInputMethod] = useState<'textarea' | 'csv'>('textarea')
|
|
||||||
const [emailsText, setEmailsText] = useState('')
|
|
||||||
const [csvFile, setCsvFile] = useState<File | null>(null)
|
|
||||||
const [role, setRole] = useState<Role>('JURY_MEMBER')
|
|
||||||
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
|
||||||
const [tagInput, setTagInput] = useState('')
|
|
||||||
|
|
||||||
// Parsed users
|
|
||||||
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([])
|
|
||||||
|
|
||||||
// Send progress
|
|
||||||
const [sendProgress, setSendProgress] = useState(0)
|
|
||||||
|
|
||||||
// Result
|
|
||||||
const [result, setResult] = useState<{ created: number; skipped: number } | null>(null)
|
|
||||||
|
|
||||||
// Mutation
|
|
||||||
const bulkCreate = trpc.user.bulkCreate.useMutation()
|
|
||||||
|
|
||||||
// Parse emails from textarea
|
|
||||||
const parseEmailsFromText = useCallback((text: string): ParsedUser[] => {
|
|
||||||
const lines = text
|
|
||||||
.split(/[\n,;]+/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
|
|
||||||
const seenEmails = new Set<string>()
|
|
||||||
|
|
||||||
return lines.map((line) => {
|
|
||||||
// Try to extract name and email like "Name <email@example.com>" or just "email@example.com"
|
|
||||||
const matchWithName = line.match(/^(.+?)\s*<(.+?)>$/)
|
|
||||||
const email = matchWithName ? matchWithName[2].trim().toLowerCase() : line.toLowerCase()
|
|
||||||
const name = matchWithName ? matchWithName[1].trim() : undefined
|
|
||||||
|
|
||||||
const isValidFormat = emailRegex.test(email)
|
|
||||||
const isDuplicate = seenEmails.has(email)
|
|
||||||
|
|
||||||
if (isValidFormat && !isDuplicate) {
|
|
||||||
seenEmails.add(email)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = isValidFormat && !isDuplicate
|
|
||||||
|
|
||||||
return {
|
|
||||||
email,
|
|
||||||
name,
|
|
||||||
isValid,
|
|
||||||
isDuplicate,
|
|
||||||
error: !isValidFormat
|
|
||||||
? 'Invalid email format'
|
|
||||||
: isDuplicate
|
|
||||||
? 'Duplicate email'
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Parse CSV file
|
|
||||||
const handleCSVUpload = useCallback(
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
setCsvFile(file)
|
|
||||||
|
|
||||||
Papa.parse<Record<string, string>>(file, {
|
|
||||||
header: true,
|
|
||||||
skipEmptyLines: true,
|
|
||||||
complete: (results) => {
|
|
||||||
const seenEmails = new Set<string>()
|
|
||||||
|
|
||||||
const users: ParsedUser[] = results.data.map((row) => {
|
|
||||||
// Try to find email column (case-insensitive)
|
|
||||||
const emailKey = Object.keys(row).find(
|
|
||||||
(key) =>
|
|
||||||
key.toLowerCase() === 'email' ||
|
|
||||||
key.toLowerCase().includes('email')
|
|
||||||
)
|
|
||||||
const nameKey = Object.keys(row).find(
|
|
||||||
(key) =>
|
|
||||||
key.toLowerCase() === 'name' ||
|
|
||||||
key.toLowerCase().includes('name')
|
|
||||||
)
|
|
||||||
|
|
||||||
const email = emailKey ? row[emailKey]?.trim().toLowerCase() : ''
|
|
||||||
const name = nameKey ? row[nameKey]?.trim() : undefined
|
|
||||||
const isValidFormat = emailRegex.test(email)
|
|
||||||
const isDuplicate = email ? seenEmails.has(email) : false
|
|
||||||
|
|
||||||
if (isValidFormat && !isDuplicate && email) {
|
|
||||||
seenEmails.add(email)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = isValidFormat && !isDuplicate
|
|
||||||
|
|
||||||
return {
|
|
||||||
email,
|
|
||||||
name,
|
|
||||||
isValid,
|
|
||||||
isDuplicate,
|
|
||||||
error: !email
|
|
||||||
? 'No email found'
|
|
||||||
: !isValidFormat
|
|
||||||
? 'Invalid email format'
|
|
||||||
: isDuplicate
|
|
||||||
? 'Duplicate email'
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setParsedUsers(users.filter((u) => u.email))
|
|
||||||
setStep('preview')
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
console.error('CSV parse error:', error)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handle text input and proceed to preview
|
|
||||||
const handleTextProceed = () => {
|
|
||||||
const users = parseEmailsFromText(emailsText)
|
|
||||||
setParsedUsers(users)
|
|
||||||
setStep('preview')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add expertise tag
|
|
||||||
const addTag = () => {
|
|
||||||
const tag = tagInput.trim()
|
|
||||||
if (tag && !expertiseTags.includes(tag)) {
|
|
||||||
setExpertiseTags([...expertiseTags, tag])
|
|
||||||
setTagInput('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove expertise tag
|
|
||||||
const removeTag = (tag: string) => {
|
|
||||||
setExpertiseTags(expertiseTags.filter((t) => t !== tag))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary stats
|
|
||||||
const summary = useMemo(() => {
|
|
||||||
const validUsers = parsedUsers.filter((u) => u.isValid)
|
|
||||||
const invalidUsers = parsedUsers.filter((u) => !u.isValid)
|
|
||||||
const duplicateUsers = parsedUsers.filter((u) => u.isDuplicate)
|
|
||||||
return {
|
|
||||||
total: parsedUsers.length,
|
|
||||||
valid: validUsers.length,
|
|
||||||
invalid: invalidUsers.length,
|
|
||||||
duplicates: duplicateUsers.length,
|
|
||||||
validUsers,
|
|
||||||
invalidUsers,
|
|
||||||
duplicateUsers,
|
|
||||||
}
|
|
||||||
}, [parsedUsers])
|
|
||||||
|
|
||||||
// Remove invalid users
|
|
||||||
const removeInvalidUsers = () => {
|
|
||||||
setParsedUsers(parsedUsers.filter((u) => u.isValid))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send invites
|
|
||||||
const handleSendInvites = async () => {
|
|
||||||
if (summary.valid === 0) return
|
|
||||||
|
|
||||||
setStep('sending')
|
|
||||||
setSendProgress(0)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await bulkCreate.mutateAsync({
|
|
||||||
users: summary.validUsers.map((u) => ({
|
|
||||||
email: u.email,
|
|
||||||
name: u.name,
|
|
||||||
role,
|
|
||||||
expertiseTags: expertiseTags.length > 0 ? expertiseTags : undefined,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
|
|
||||||
setSendProgress(100)
|
|
||||||
setResult(result)
|
|
||||||
setStep('complete')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Bulk create failed:', error)
|
|
||||||
setStep('preview')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
const resetForm = () => {
|
|
||||||
setStep('input')
|
|
||||||
setEmailsText('')
|
|
||||||
setCsvFile(null)
|
|
||||||
setParsedUsers([])
|
|
||||||
setResult(null)
|
|
||||||
setSendProgress(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Steps indicator
|
|
||||||
const steps: Array<{ key: Step; label: string }> = [
|
|
||||||
{ key: 'input', label: 'Input' },
|
|
||||||
{ key: 'preview', label: 'Preview' },
|
|
||||||
{ key: 'sending', label: 'Send' },
|
|
||||||
{ key: 'complete', label: 'Done' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const currentStepIndex = steps.findIndex((s) => s.key === step)
|
|
||||||
|
|
||||||
const renderStep = () => {
|
|
||||||
switch (step) {
|
|
||||||
case 'input':
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Invite Users</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Add email addresses to invite new jury members or observers
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Input Method Toggle */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={inputMethod === 'textarea' ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setInputMethod('textarea')}
|
|
||||||
>
|
|
||||||
<Mail className="mr-2 h-4 w-4" />
|
|
||||||
Enter Emails
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={inputMethod === 'csv' ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setInputMethod('csv')}
|
|
||||||
>
|
|
||||||
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
|
||||||
Upload CSV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input Area */}
|
|
||||||
{inputMethod === 'textarea' ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="emails">Email Addresses</Label>
|
|
||||||
<Textarea
|
|
||||||
id="emails"
|
|
||||||
value={emailsText}
|
|
||||||
onChange={(e) => setEmailsText(e.target.value)}
|
|
||||||
placeholder="Enter email addresses, one per line or comma-separated.
|
|
||||||
You can also use format: Name <email@example.com>"
|
|
||||||
rows={8}
|
|
||||||
maxLength={10000}
|
|
||||||
className="font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
One email per line, or separated by commas
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>CSV File</Label>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors',
|
|
||||||
'hover:border-primary/50'
|
|
||||||
)}
|
|
||||||
onClick={() => document.getElementById('csv-input')?.click()}
|
|
||||||
>
|
|
||||||
<FileSpreadsheet className="mx-auto h-10 w-10 text-muted-foreground" />
|
|
||||||
<p className="mt-2 font-medium">
|
|
||||||
{csvFile ? csvFile.name : 'Drop CSV file here or click to browse'}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
CSV should have an "email" column, optionally a "name" column
|
|
||||||
</p>
|
|
||||||
<Input
|
|
||||||
id="csv-input"
|
|
||||||
type="file"
|
|
||||||
accept=".csv"
|
|
||||||
onChange={handleCSVUpload}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Role Selection */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="role">Role</Label>
|
|
||||||
<Select value={role} onValueChange={(v) => setRole(v as Role)}>
|
|
||||||
<SelectTrigger id="role">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
|
|
||||||
<SelectItem value="MENTOR">Mentor</SelectItem>
|
|
||||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{role === 'JURY_MEMBER'
|
|
||||||
? 'Can evaluate assigned projects'
|
|
||||||
: role === 'MENTOR'
|
|
||||||
? 'Can view and mentor assigned projects'
|
|
||||||
: 'Read-only access to dashboards'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expertise Tags */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="expertise">Expertise Tags (Optional)</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="expertise"
|
|
||||||
value={tagInput}
|
|
||||||
onChange={(e) => setTagInput(e.target.value)}
|
|
||||||
placeholder="e.g., Marine Biology"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
addTag()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button type="button" variant="outline" onClick={addTag}>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{expertiseTags.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
|
||||||
{expertiseTags.map((tag) => (
|
|
||||||
<Badge key={tag} variant="secondary" className="gap-1">
|
|
||||||
{tag}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeTag(tag)}
|
|
||||||
className="ml-1 hover:text-destructive"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex justify-between pt-4">
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link href="/admin/users">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Cancel
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleTextProceed}
|
|
||||||
disabled={inputMethod === 'textarea' && !emailsText.trim()}
|
|
||||||
>
|
|
||||||
Preview
|
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'preview':
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Preview Invitations</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Review the list of users to invite
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Summary Stats */}
|
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
|
||||||
<div className="rounded-lg bg-muted p-4 text-center">
|
|
||||||
<p className="text-3xl font-bold">{summary.total}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">Total</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-green-500/10 p-4 text-center">
|
|
||||||
<p className="text-3xl font-bold text-green-600">
|
|
||||||
{summary.valid}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">Valid</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-red-500/10 p-4 text-center">
|
|
||||||
<p className="text-3xl font-bold text-red-600">
|
|
||||||
{summary.invalid}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">Invalid</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Invalid users warning */}
|
|
||||||
{summary.invalid > 0 && (
|
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-amber-500/10 p-4 text-amber-700">
|
|
||||||
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-medium">
|
|
||||||
{summary.invalid} email(s) have issues
|
|
||||||
</p>
|
|
||||||
<p className="text-sm mt-1">
|
|
||||||
{summary.duplicates > 0 && (
|
|
||||||
<span>{summary.duplicates} duplicate(s). </span>
|
|
||||||
)}
|
|
||||||
{summary.invalid - summary.duplicates > 0 && (
|
|
||||||
<span>{summary.invalid - summary.duplicates} invalid format(s). </span>
|
|
||||||
)}
|
|
||||||
These will be excluded from the invitation.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={removeInvalidUsers}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
Remove Invalid
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Settings Summary */}
|
|
||||||
<div className="flex flex-wrap gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">Role:</span>{' '}
|
|
||||||
<Badge variant="outline">{role.replace('_', ' ')}</Badge>
|
|
||||||
</div>
|
|
||||||
{expertiseTags.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-muted-foreground">Tags:</span>
|
|
||||||
{expertiseTags.map((tag) => (
|
|
||||||
<Badge key={tag} variant="secondary" className="text-xs">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Users Table */}
|
|
||||||
<div className="rounded-lg border max-h-80 overflow-y-auto">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Email</TableHead>
|
|
||||||
<TableHead>Name</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{parsedUsers.map((user, index) => (
|
|
||||||
<TableRow
|
|
||||||
key={index}
|
|
||||||
className={cn(!user.isValid && 'bg-red-500/5')}
|
|
||||||
>
|
|
||||||
<TableCell className="font-mono text-sm">
|
|
||||||
{user.email}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{user.name || '-'}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{user.isValid ? (
|
|
||||||
<Badge variant="outline" className="text-green-600">
|
|
||||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
|
||||||
Valid
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="destructive">{user.error}</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex justify-between pt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setParsedUsers([])
|
|
||||||
setStep('input')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSendInvites}
|
|
||||||
disabled={summary.valid === 0 || bulkCreate.isPending}
|
|
||||||
>
|
|
||||||
{bulkCreate.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Users className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Create {summary.valid} User{summary.valid !== 1 ? 's' : ''}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{bulkCreate.error && (
|
|
||||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-4 text-destructive">
|
|
||||||
<AlertCircle className="h-5 w-5" />
|
|
||||||
<span>{bulkCreate.error.message}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'sending':
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
|
||||||
<p className="mt-4 font-medium">Creating users...</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Please wait while we process your request
|
|
||||||
</p>
|
|
||||||
<Progress value={sendProgress} className="mt-4 w-48" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'complete':
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10">
|
|
||||||
<CheckCircle2 className="h-8 w-8 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<p className="mt-4 text-xl font-semibold">Users Created!</p>
|
|
||||||
<p className="text-muted-foreground text-center max-w-sm mt-2">
|
|
||||||
{result?.created} user{result?.created !== 1 ? 's' : ''} created
|
|
||||||
successfully.
|
|
||||||
{result?.skipped ? ` ${result.skipped} skipped (already exist).` : ''}
|
|
||||||
</p>
|
|
||||||
<div className="mt-6 flex gap-3">
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link href="/admin/users">View Users</Link>
|
|
||||||
</Button>
|
|
||||||
<Button onClick={resetForm}>Invite More</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
|
||||||
<Link href="/admin/users">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back to Users
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Invite Users</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Add new jury members or observers to the platform
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress indicator */}
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
{steps.map((s, index) => (
|
|
||||||
<div key={s.key} className="flex items-center">
|
|
||||||
{index > 0 && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'h-0.5 w-8 mx-1',
|
|
||||||
index <= currentStepIndex ? 'bg-primary' : 'bg-muted'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium',
|
|
||||||
index === currentStepIndex
|
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: index < currentStepIndex
|
|
||||||
? 'bg-primary/20 text-primary'
|
|
||||||
: 'bg-muted text-muted-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{renderStep()}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,281 +1,5 @@
|
||||||
import { Suspense } from 'react'
|
import { redirect } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
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 { UserAvatar } from '@/components/shared/user-avatar'
|
|
||||||
import { getUserAvatarUrl } from '@/server/utils/avatar-url'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import { Plus, Users } from 'lucide-react'
|
|
||||||
import { formatDate } from '@/lib/utils'
|
|
||||||
import { UserActions, UserMobileActions } from '@/components/admin/user-actions'
|
|
||||||
|
|
||||||
async function UsersContent() {
|
|
||||||
const users = await prisma.user.findMany({
|
|
||||||
where: {
|
|
||||||
role: { in: ['JURY_MEMBER', 'OBSERVER'] },
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
assignments: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
assignments: {
|
|
||||||
select: {
|
|
||||||
evaluation: {
|
|
||||||
select: { status: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: [{ role: 'asc' }, { name: 'asc' }],
|
|
||||||
})
|
|
||||||
|
|
||||||
// Generate avatar URLs
|
|
||||||
const usersWithAvatars = await Promise.all(
|
|
||||||
users.map(async (user) => ({
|
|
||||||
...user,
|
|
||||||
avatarUrl: await getUserAvatarUrl(user.profileImageKey, user.profileImageProvider),
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
if (usersWithAvatars.length === 0) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<Users className="h-12 w-12 text-muted-foreground/50" />
|
|
||||||
<p className="mt-2 font-medium">No jury members yet</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Invite jury members to start assigning projects for evaluation
|
|
||||||
</p>
|
|
||||||
<Button asChild className="mt-4">
|
|
||||||
<Link href="/admin/users/invite">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Invite Member
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
|
|
||||||
ACTIVE: 'success',
|
|
||||||
PENDING: 'secondary',
|
|
||||||
INACTIVE: 'secondary',
|
|
||||||
SUSPENDED: 'destructive',
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
|
||||||
JURY_MEMBER: 'default',
|
|
||||||
OBSERVER: 'outline',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Desktop table view */}
|
|
||||||
<Card className="hidden md:block">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Member</TableHead>
|
|
||||||
<TableHead>Role</TableHead>
|
|
||||||
<TableHead>Expertise</TableHead>
|
|
||||||
<TableHead>Assignments</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Last Login</TableHead>
|
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{usersWithAvatars.map((user) => (
|
|
||||||
<TableRow key={user.id}>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<UserAvatar user={user} avatarUrl={user.avatarUrl} size="sm" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{user.name || 'Unnamed'}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{user.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={roleColors[user.role] || 'secondary'}>
|
|
||||||
{user.role.replace('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{user.expertiseTags && user.expertiseTags.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{user.expertiseTags.slice(0, 2).map((tag) => (
|
|
||||||
<Badge key={tag} variant="outline" className="text-xs">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{user.expertiseTags.length > 2 && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
+{user.expertiseTags.length - 2}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-muted-foreground">-</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div>
|
|
||||||
<p>{user._count.assignments} assigned</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{user.assignments.filter(a => a.evaluation?.status === 'SUBMITTED').length} completed
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={statusColors[user.status] || 'secondary'}>
|
|
||||||
{user.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{user.lastLoginAt ? (
|
|
||||||
formatDate(user.lastLoginAt)
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">Never</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<UserActions
|
|
||||||
userId={user.id}
|
|
||||||
userEmail={user.email}
|
|
||||||
userStatus={user.status}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Mobile card view */}
|
|
||||||
<div className="space-y-4 md:hidden">
|
|
||||||
{usersWithAvatars.map((user) => (
|
|
||||||
<Card key={user.id}>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<UserAvatar user={user} avatarUrl={user.avatarUrl} size="md" />
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base">
|
|
||||||
{user.name || 'Unnamed'}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
{user.email}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant={statusColors[user.status] || 'secondary'}>
|
|
||||||
{user.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Role</span>
|
|
||||||
<Badge variant={roleColors[user.role] || 'secondary'}>
|
|
||||||
{user.role.replace('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Assignments</span>
|
|
||||||
<span>
|
|
||||||
{user.assignments.filter(a => a.evaluation?.status === 'SUBMITTED').length}/{user._count.assignments} completed
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{user.expertiseTags && user.expertiseTags.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{user.expertiseTags.map((tag) => (
|
|
||||||
<Badge key={tag} variant="outline" className="text-xs">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<UserMobileActions
|
|
||||||
userId={user.id}
|
|
||||||
userEmail={user.email}
|
|
||||||
userStatus={user.status}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function UsersSkeleton() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-4">
|
|
||||||
<Skeleton className="h-10 w-10 rounded-full" />
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<Skeleton className="h-5 w-32" />
|
|
||||||
<Skeleton className="h-4 w-48" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-9 w-9" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
return (
|
redirect('/admin/members')
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Jury Members</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Manage jury members and observers
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/admin/users/invite">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Invite Member
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<Suspense fallback={<UsersSkeleton />}>
|
|
||||||
<UsersContent />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,322 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { use, useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Trophy,
|
||||||
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
GripVertical,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export default function JuryAwardVotingPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id: awardId } = use(params)
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } =
|
||||||
|
trpc.specialAward.getMyAwardDetail.useQuery({ awardId })
|
||||||
|
const submitVote = trpc.specialAward.submitVote.useMutation()
|
||||||
|
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
const [rankedIds, setRankedIds] = useState<string[]>([])
|
||||||
|
|
||||||
|
// Initialize from existing votes
|
||||||
|
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
|
||||||
|
if (data.award.scoringMode === 'PICK_WINNER') {
|
||||||
|
setSelectedProjectId(data.myVotes[0]?.projectId || null)
|
||||||
|
} else if (data.award.scoringMode === 'RANKED') {
|
||||||
|
const sorted = [...data.myVotes]
|
||||||
|
.sort((a, b) => (a.rank || 0) - (b.rank || 0))
|
||||||
|
.map((v) => v.projectId)
|
||||||
|
setRankedIds(sorted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitPickWinner = async () => {
|
||||||
|
if (!selectedProjectId) return
|
||||||
|
try {
|
||||||
|
await submitVote.mutateAsync({
|
||||||
|
awardId,
|
||||||
|
votes: [{ projectId: selectedProjectId }],
|
||||||
|
})
|
||||||
|
toast.success('Vote submitted')
|
||||||
|
refetch()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Failed to submit vote'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitRanked = async () => {
|
||||||
|
if (rankedIds.length === 0) return
|
||||||
|
try {
|
||||||
|
await submitVote.mutateAsync({
|
||||||
|
awardId,
|
||||||
|
votes: rankedIds.map((projectId, index) => ({
|
||||||
|
projectId,
|
||||||
|
rank: index + 1,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
toast.success('Rankings submitted')
|
||||||
|
refetch()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Failed to submit rankings'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleRanked = (projectId: string) => {
|
||||||
|
if (rankedIds.includes(projectId)) {
|
||||||
|
setRankedIds(rankedIds.filter((id) => id !== projectId))
|
||||||
|
} else {
|
||||||
|
const maxPicks = data?.award.maxRankedPicks || 5
|
||||||
|
if (rankedIds.length < maxPicks) {
|
||||||
|
setRankedIds([...rankedIds, projectId])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-9 w-48" />
|
||||||
|
<Skeleton className="h-96 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null
|
||||||
|
|
||||||
|
const { award, projects, myVotes } = data
|
||||||
|
const hasVoted = myVotes.length > 0
|
||||||
|
const isVotingOpen = award.status === 'VOTING_OPEN'
|
||||||
|
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||||
|
<Trophy className="h-6 w-6 text-amber-500" />
|
||||||
|
{award.name}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge
|
||||||
|
variant={isVotingOpen ? 'default' : 'secondary'}
|
||||||
|
>
|
||||||
|
{award.status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
{hasVoted && (
|
||||||
|
<Badge variant="outline" className="text-green-600">
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
Voted
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{award.criteriaText && (
|
||||||
|
<p className="text-muted-foreground mt-2">{award.criteriaText}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isVotingOpen ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Trophy className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 font-medium">Voting is not open</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Check back when voting opens for this award
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : award.scoringMode === 'PICK_WINNER' ? (
|
||||||
|
/* PICK_WINNER Mode */
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Select one project as the winner
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<Card
|
||||||
|
key={project.id}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer transition-all',
|
||||||
|
selectedProjectId === project.id
|
||||||
|
? 'ring-2 ring-primary bg-primary/5'
|
||||||
|
: 'hover:bg-muted/50'
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedProjectId(project.id)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">{project.title}</CardTitle>
|
||||||
|
<CardDescription>{project.teamName}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{project.competitionCategory && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{project.competitionCategory.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{project.country && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{project.country}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmitPickWinner}
|
||||||
|
disabled={!selectedProjectId || submitVote.isPending}
|
||||||
|
>
|
||||||
|
{submitVote.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : award.scoringMode === 'RANKED' ? (
|
||||||
|
/* RANKED Mode */
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Select and rank your top {award.maxRankedPicks || 5} projects. Click
|
||||||
|
to add/remove, drag to reorder.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Selected rankings */}
|
||||||
|
{rankedIds.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Your Rankings</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{rankedIds.map((id, index) => {
|
||||||
|
const project = projects.find((p) => p.id === id)
|
||||||
|
if (!project) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className="flex items-center gap-3 rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<span className="font-bold text-lg w-8 text-center">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">{project.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{project.teamName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleRanked(id)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Available projects */}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{projects
|
||||||
|
.filter((p) => !rankedIds.includes(p.id))
|
||||||
|
.map((project) => (
|
||||||
|
<Card
|
||||||
|
key={project.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => toggleRanked(project.id)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{project.title}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{project.teamName}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{project.competitionCategory && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{project.competitionCategory.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{project.country && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{project.country}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmitRanked}
|
||||||
|
disabled={rankedIds.length === 0 || submitVote.isPending}
|
||||||
|
>
|
||||||
|
{submitVote.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{hasVoted ? 'Update Rankings' : 'Submit Rankings'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* SCORED Mode — redirect to evaluation */
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Trophy className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 font-medium">Scored Award</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This award uses the evaluation system. Check your evaluation
|
||||||
|
assignments.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Trophy } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function JuryAwardsPage() {
|
||||||
|
const { data: awards, isLoading } = trpc.specialAward.getMyAwards.useQuery()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-9 w-48" />
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{[...Array(2)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-40" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Special Awards
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Vote on special awards you have been assigned to
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{awards && awards.length > 0 ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{awards.map((award) => (
|
||||||
|
<Link key={award.id} href={`/jury/awards/${award.id}`}>
|
||||||
|
<Card className="transition-colors hover:bg-muted/50 cursor-pointer h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Trophy className="h-5 w-5 text-amber-500" />
|
||||||
|
{award.name}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
award.status === 'VOTING_OPEN' ? 'default' : 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{award.status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{award.description && (
|
||||||
|
<CardDescription className="line-clamp-2">
|
||||||
|
{award.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{award._count.eligibilities} eligible projects
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Trophy className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 font-medium">No awards assigned</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You will see awards here when assigned as a juror
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,353 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useSearchParams, usePathname } from 'next/navigation'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
|
import { UserActions, UserMobileActions } from '@/components/admin/user-actions'
|
||||||
|
import { Pagination } from '@/components/shared/pagination'
|
||||||
|
import { Plus, Users, Search } from 'lucide-react'
|
||||||
|
import { formatDate } from '@/lib/utils'
|
||||||
|
type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||||
|
|
||||||
|
type TabKey = 'all' | 'jury' | 'mentors' | 'observers' | 'admins'
|
||||||
|
|
||||||
|
const TAB_ROLES: Record<TabKey, RoleValue[] | undefined> = {
|
||||||
|
all: undefined,
|
||||||
|
jury: ['JURY_MEMBER'],
|
||||||
|
mentors: ['MENTOR'],
|
||||||
|
observers: ['OBSERVER'],
|
||||||
|
admins: ['SUPER_ADMIN', 'PROGRAM_ADMIN'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
|
||||||
|
ACTIVE: 'success',
|
||||||
|
INVITED: 'secondary',
|
||||||
|
SUSPENDED: 'destructive',
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
||||||
|
JURY_MEMBER: 'default',
|
||||||
|
MENTOR: 'secondary',
|
||||||
|
OBSERVER: 'outline',
|
||||||
|
PROGRAM_ADMIN: 'default',
|
||||||
|
SUPER_ADMIN: 'destructive' as 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MembersContent() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
const tab = (searchParams.get('tab') as TabKey) || 'all'
|
||||||
|
const search = searchParams.get('search') || ''
|
||||||
|
const page = parseInt(searchParams.get('page') || '1', 10)
|
||||||
|
|
||||||
|
const [searchInput, setSearchInput] = useState(search)
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
updateParams({ search: searchInput || null, page: '1' })
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchInput])
|
||||||
|
|
||||||
|
const updateParams = useCallback(
|
||||||
|
(updates: Record<string, string | null>) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
|
if (value === null || value === '') {
|
||||||
|
params.delete(key)
|
||||||
|
} else {
|
||||||
|
params.set(key, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
window.history.replaceState(null, '', `${pathname}?${params.toString()}`)
|
||||||
|
},
|
||||||
|
[searchParams, pathname]
|
||||||
|
)
|
||||||
|
|
||||||
|
const roles = TAB_ROLES[tab]
|
||||||
|
|
||||||
|
const { data, isLoading } = trpc.user.list.useQuery({
|
||||||
|
roles: roles,
|
||||||
|
search: search || undefined,
|
||||||
|
page,
|
||||||
|
perPage: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleTabChange = (value: string) => {
|
||||||
|
updateParams({ tab: value === 'all' ? null : value, page: '1' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Members</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage jury members, mentors, observers, and admins
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/admin/members/invite">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Invite Member
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs value={tab} onValueChange={handleTabChange}>
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="all">All</TabsTrigger>
|
||||||
|
<TabsTrigger value="jury">Jury</TabsTrigger>
|
||||||
|
<TabsTrigger value="mentors">Mentors</TabsTrigger>
|
||||||
|
<TabsTrigger value="observers">Observers</TabsTrigger>
|
||||||
|
<TabsTrigger value="admins">Admins</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative w-full sm:w-64">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{isLoading ? (
|
||||||
|
<MembersSkeleton />
|
||||||
|
) : data && data.users.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* Desktop table */}
|
||||||
|
<Card className="hidden md:block">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Member</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Expertise</TableHead>
|
||||||
|
<TableHead>Assignments</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Last Login</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserAvatar
|
||||||
|
user={user}
|
||||||
|
avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{user.name || 'Unnamed'}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={roleColors[user.role] || 'secondary'}>
|
||||||
|
{user.role.replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.expertiseTags && user.expertiseTags.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{user.expertiseTags.slice(0, 2).map((tag) => (
|
||||||
|
<Badge key={tag} variant="outline" className="text-xs">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{user.expertiseTags.length > 2 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{user.expertiseTags.length - 2}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
{user.role === 'MENTOR' ? (
|
||||||
|
<p>{user._count.mentorAssignments} mentored</p>
|
||||||
|
) : (
|
||||||
|
<p>{user._count.assignments} assigned</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusColors[user.status] || 'secondary'}>
|
||||||
|
{user.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.lastLoginAt ? (
|
||||||
|
formatDate(user.lastLoginAt)
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Never</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<UserActions
|
||||||
|
userId={user.id}
|
||||||
|
userEmail={user.email}
|
||||||
|
userStatus={user.status}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Mobile cards */}
|
||||||
|
<div className="space-y-4 md:hidden">
|
||||||
|
{data.users.map((user) => (
|
||||||
|
<Card key={user.id}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserAvatar
|
||||||
|
user={user}
|
||||||
|
avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{user.name || 'Unnamed'}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
{user.email}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={statusColors[user.status] || 'secondary'}>
|
||||||
|
{user.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Role</span>
|
||||||
|
<Badge variant={roleColors[user.role] || 'secondary'}>
|
||||||
|
{user.role.replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Assignments</span>
|
||||||
|
<span>
|
||||||
|
{user.role === 'MENTOR'
|
||||||
|
? `${user._count.mentorAssignments} mentored`
|
||||||
|
: `${user._count.assignments} assigned`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{user.expertiseTags && user.expertiseTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{user.expertiseTags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="outline" className="text-xs">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<UserMobileActions
|
||||||
|
userId={user.id}
|
||||||
|
userEmail={user.email}
|
||||||
|
userStatus={user.status}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
total={data.total}
|
||||||
|
perPage={data.perPage}
|
||||||
|
onPageChange={(newPage) => updateParams({ page: String(newPage) })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Users className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 font-medium">No members found</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{search
|
||||||
|
? 'Try adjusting your search'
|
||||||
|
: 'Invite members to get started'}
|
||||||
|
</p>
|
||||||
|
<Button asChild className="mt-4">
|
||||||
|
<Link href="/admin/members/invite">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Invite Member
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MembersSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4">
|
||||||
|
<Skeleton className="h-10 w-10 rounded-full" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-9 w-9" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -88,7 +88,7 @@ export function UserActions({ userId, userEmail, userStatus }: UserActionsProps)
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/admin/users/${userId}`}>
|
<Link href={`/admin/members/${userId}`}>
|
||||||
<UserCog className="mr-2 h-4 w-4" />
|
<UserCog className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -172,7 +172,7 @@ export function UserMobileActions({
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button variant="outline" size="sm" className="flex-1" asChild>
|
<Button variant="outline" size="sm" className="flex-1" asChild>
|
||||||
<Link href={`/admin/users/${userId}`}>
|
<Link href={`/admin/members/${userId}`}>
|
||||||
<UserCog className="mr-2 h-4 w-4" />
|
<UserCog className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ const PROJECT_FIELDS = [
|
||||||
{ key: 'teamName', label: 'Team Name', required: false },
|
{ key: 'teamName', label: 'Team Name', required: false },
|
||||||
{ key: 'description', label: 'Description', required: false },
|
{ key: 'description', label: 'Description', required: false },
|
||||||
{ key: 'tags', label: 'Tags (comma-separated)', required: false },
|
{ key: 'tags', label: 'Tags (comma-separated)', required: false },
|
||||||
|
{ key: 'foundedAt', label: 'Founded Date', required: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface ParsedRow {
|
interface ParsedRow {
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ import {
|
||||||
Handshake,
|
Handshake,
|
||||||
FileText,
|
FileText,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
GraduationCap,
|
|
||||||
History,
|
History,
|
||||||
|
Trophy,
|
||||||
User,
|
User,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getInitials } from '@/lib/utils'
|
import { getInitials } from '@/lib/utils'
|
||||||
|
|
@ -60,21 +60,21 @@ const navigation = [
|
||||||
href: '/admin/rounds' as const,
|
href: '/admin/rounds' as const,
|
||||||
icon: CircleDot,
|
icon: CircleDot,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Awards',
|
||||||
|
href: '/admin/awards' as const,
|
||||||
|
icon: Trophy,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Projects',
|
name: 'Projects',
|
||||||
href: '/admin/projects' as const,
|
href: '/admin/projects' as const,
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Jury Members',
|
name: 'Members',
|
||||||
href: '/admin/users' as const,
|
href: '/admin/members' as const,
|
||||||
icon: Users,
|
icon: Users,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'Mentors',
|
|
||||||
href: '/admin/mentors' as const,
|
|
||||||
icon: GraduationCap,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Reports',
|
name: 'Reports',
|
||||||
href: '/admin/reports' as const,
|
href: '/admin/reports' as const,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
|
interface PaginationProps {
|
||||||
|
page: number
|
||||||
|
totalPages: number
|
||||||
|
total: number
|
||||||
|
perPage: number
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pagination({
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
total,
|
||||||
|
perPage,
|
||||||
|
onPageChange,
|
||||||
|
}: PaginationProps) {
|
||||||
|
if (totalPages <= 1) return null
|
||||||
|
|
||||||
|
const from = (page - 1) * perPage + 1
|
||||||
|
const to = Math.min(page * perPage, total)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing {from} to {to} of {total} results
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Clock, Activity } from 'lucide-react'
|
||||||
|
import { formatDate } from '@/lib/utils'
|
||||||
|
|
||||||
|
const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'outline'> = {
|
||||||
|
CREATE: 'default',
|
||||||
|
UPDATE: 'secondary',
|
||||||
|
DELETE: 'destructive',
|
||||||
|
LOGIN_SUCCESS: 'outline',
|
||||||
|
LOGIN_FAILED: 'destructive',
|
||||||
|
INVITATION_ACCEPTED: 'default',
|
||||||
|
EVALUATION_SUBMITTED: 'default',
|
||||||
|
SUBMIT_EVALUATION: 'default',
|
||||||
|
ROLE_CHANGED: 'secondary',
|
||||||
|
PASSWORD_SET: 'outline',
|
||||||
|
PASSWORD_CHANGED: 'outline',
|
||||||
|
FILE_DOWNLOADED: 'outline',
|
||||||
|
ROUND_ACTIVATED: 'default',
|
||||||
|
ROUND_CLOSED: 'secondary',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserActivityLogProps {
|
||||||
|
userId: string
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserActivityLog({ userId, limit = 20 }: UserActivityLogProps) {
|
||||||
|
const { data: logs, isLoading } = trpc.audit.getByUser.useQuery({
|
||||||
|
userId,
|
||||||
|
limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Activity Log</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-6 w-20" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Activity className="h-5 w-5" />
|
||||||
|
Activity Log
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Recent actions by this member</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{logs && logs.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{logs.map((log) => (
|
||||||
|
<div
|
||||||
|
key={log.id}
|
||||||
|
className="flex items-center gap-3 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1 text-muted-foreground shrink-0 w-36">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
<span className="text-xs font-mono">
|
||||||
|
{formatDate(log.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={actionColors[log.action] || 'secondary'}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{log.action.replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground truncate">
|
||||||
|
{log.entityType}
|
||||||
|
{log.entityId && (
|
||||||
|
<span className="font-mono text-xs ml-1">
|
||||||
|
{log.entityId.slice(0, 8)}...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No activity recorded yet.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -79,6 +79,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Log invitation accepted
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
action: 'INVITATION_ACCEPTED',
|
||||||
|
entityType: 'User',
|
||||||
|
entityId: user.id,
|
||||||
|
detailsJson: { email: user.email, role: user.role },
|
||||||
|
},
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
|
@ -124,6 +135,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
current.count = 0
|
current.count = 0
|
||||||
}
|
}
|
||||||
failedAttempts.set(email, current)
|
failedAttempts.set(email, current)
|
||||||
|
|
||||||
|
// Log failed login
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: null,
|
||||||
|
action: 'LOGIN_FAILED',
|
||||||
|
entityType: 'User',
|
||||||
|
detailsJson: { email, reason: !user ? 'user_not_found' : user.status === 'SUSPENDED' ? 'suspended' : 'no_password' },
|
||||||
|
},
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,6 +160,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
current.count = 0
|
current.count = 0
|
||||||
}
|
}
|
||||||
failedAttempts.set(email, current)
|
failedAttempts.set(email, current)
|
||||||
|
|
||||||
|
// Log failed login
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
action: 'LOGIN_FAILED',
|
||||||
|
entityType: 'User',
|
||||||
|
entityId: user.id,
|
||||||
|
detailsJson: { email, reason: 'invalid_password' },
|
||||||
|
},
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,6 +262,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// Ignore errors from updating last login
|
// Ignore errors from updating last login
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Log successful login
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id as string,
|
||||||
|
action: 'LOGIN_SUCCESS',
|
||||||
|
entityType: 'User',
|
||||||
|
entityId: user.id as string,
|
||||||
|
detailsJson: { method: account?.provider || 'unknown', email: user.email },
|
||||||
|
},
|
||||||
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ import { logoRouter } from './logo'
|
||||||
// Applicant system routers
|
// Applicant system routers
|
||||||
import { applicationRouter } from './application'
|
import { applicationRouter } from './application'
|
||||||
import { mentorRouter } from './mentor'
|
import { mentorRouter } from './mentor'
|
||||||
|
import { filteringRouter } from './filtering'
|
||||||
|
import { specialAwardRouter } from './specialAward'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root tRPC router that combines all domain routers
|
* Root tRPC router that combines all domain routers
|
||||||
|
|
@ -60,6 +62,8 @@ export const appRouter = router({
|
||||||
// Applicant system routers
|
// Applicant system routers
|
||||||
application: applicationRouter,
|
application: applicationRouter,
|
||||||
mentor: mentorRouter,
|
mentor: mentorRouter,
|
||||||
|
filtering: filteringRouter,
|
||||||
|
specialAward: specialAwardRouter,
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter
|
export type AppRouter = typeof appRouter
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export const auditRouter = router({
|
||||||
const where: Record<string, unknown> = {}
|
const where: Record<string, unknown> = {}
|
||||||
|
|
||||||
if (userId) where.userId = userId
|
if (userId) where.userId = userId
|
||||||
if (action) where.action = { contains: action, mode: 'insensitive' }
|
if (action) where.action = action
|
||||||
if (entityType) where.entityType = entityType
|
if (entityType) where.entityType = entityType
|
||||||
if (entityId) where.entityId = entityId
|
if (entityId) where.entityId = entityId
|
||||||
if (startDate || endDate) {
|
if (startDate || endDate) {
|
||||||
|
|
|
||||||
|
|
@ -216,9 +216,15 @@ export const evaluationRouter = router({
|
||||||
await ctx.prisma.auditLog.create({
|
await ctx.prisma.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: 'SUBMIT_EVALUATION',
|
action: 'EVALUATION_SUBMITTED',
|
||||||
entityType: 'Evaluation',
|
entityType: 'Evaluation',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
|
detailsJson: {
|
||||||
|
projectId: evaluation.assignment.projectId,
|
||||||
|
roundId: evaluation.assignment.roundId,
|
||||||
|
globalScore: data.globalScore,
|
||||||
|
binaryDecision: data.binaryDecision,
|
||||||
|
},
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,19 @@ export const fileRouter = router({
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min
|
const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min
|
||||||
|
|
||||||
|
// Log file access
|
||||||
|
await ctx.prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'FILE_DOWNLOADED',
|
||||||
|
entityType: 'ProjectFile',
|
||||||
|
detailsJson: { bucket: input.bucket, objectKey: input.objectKey },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
},
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
return { url }
|
return { url }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,522 @@
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { Prisma } from '@prisma/client'
|
||||||
|
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||||
|
import { executeFilteringRules } from '../services/ai-filtering'
|
||||||
|
import { logAudit } from '../utils/audit'
|
||||||
|
|
||||||
|
export const filteringRouter = router({
|
||||||
|
/**
|
||||||
|
* Get filtering rules for a round
|
||||||
|
*/
|
||||||
|
getRules: protectedProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return ctx.prisma.filteringRule.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
orderBy: { priority: 'asc' },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a filtering rule
|
||||||
|
*/
|
||||||
|
createRule: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
ruleType: z.enum(['FIELD_BASED', 'DOCUMENT_CHECK', 'AI_SCREENING']),
|
||||||
|
configJson: z.record(z.unknown()),
|
||||||
|
priority: z.number().int().default(0),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const rule = await ctx.prisma.filteringRule.create({
|
||||||
|
data: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
name: input.name,
|
||||||
|
ruleType: input.ruleType,
|
||||||
|
configJson: input.configJson as Prisma.InputJsonValue,
|
||||||
|
priority: input.priority,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
entityType: 'FilteringRule',
|
||||||
|
entityId: rule.id,
|
||||||
|
detailsJson: { roundId: input.roundId, name: input.name, ruleType: input.ruleType },
|
||||||
|
})
|
||||||
|
|
||||||
|
return rule
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a filtering rule
|
||||||
|
*/
|
||||||
|
updateRule: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
configJson: z.record(z.unknown()).optional(),
|
||||||
|
priority: z.number().int().optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { id, configJson, ...rest } = input
|
||||||
|
const rule = await ctx.prisma.filteringRule.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...rest,
|
||||||
|
...(configJson !== undefined && { configJson: configJson as Prisma.InputJsonValue }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
entityType: 'FilteringRule',
|
||||||
|
entityId: id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return rule
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a filtering rule
|
||||||
|
*/
|
||||||
|
deleteRule: adminProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.prisma.filteringRule.delete({ where: { id: input.id } })
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'DELETE',
|
||||||
|
entityType: 'FilteringRule',
|
||||||
|
entityId: input.id,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder rules (batch update priorities)
|
||||||
|
*/
|
||||||
|
reorderRules: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
rules: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
priority: z.number().int(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.prisma.$transaction(
|
||||||
|
input.rules.map((r) =>
|
||||||
|
ctx.prisma.filteringRule.update({
|
||||||
|
where: { id: r.id },
|
||||||
|
data: { priority: r.priority },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute all filtering rules against projects in a round
|
||||||
|
*/
|
||||||
|
executeRules: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Get rules
|
||||||
|
const rules = await ctx.prisma.filteringRule.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
orderBy: { priority: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rules.length === 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'No filtering rules configured for this round',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get projects in this round
|
||||||
|
const projects = await ctx.prisma.project.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
include: {
|
||||||
|
files: {
|
||||||
|
select: { id: true, fileName: true, fileType: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (projects.length === 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'No projects found in this round',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute rules
|
||||||
|
const results = await executeFilteringRules(rules, projects)
|
||||||
|
|
||||||
|
// Upsert results
|
||||||
|
await ctx.prisma.$transaction(
|
||||||
|
results.map((r) =>
|
||||||
|
ctx.prisma.filteringResult.upsert({
|
||||||
|
where: {
|
||||||
|
roundId_projectId: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
projectId: r.projectId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
projectId: r.projectId,
|
||||||
|
outcome: r.outcome,
|
||||||
|
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
|
||||||
|
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
outcome: r.outcome,
|
||||||
|
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
|
||||||
|
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||||
|
// Clear any previous override
|
||||||
|
overriddenBy: null,
|
||||||
|
overriddenAt: null,
|
||||||
|
overrideReason: null,
|
||||||
|
finalOutcome: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
entityType: 'Round',
|
||||||
|
entityId: input.roundId,
|
||||||
|
detailsJson: {
|
||||||
|
action: 'EXECUTE_FILTERING',
|
||||||
|
projectCount: projects.length,
|
||||||
|
passed: results.filter((r) => r.outcome === 'PASSED').length,
|
||||||
|
filteredOut: results.filter((r) => r.outcome === 'FILTERED_OUT').length,
|
||||||
|
flagged: results.filter((r) => r.outcome === 'FLAGGED').length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: results.length,
|
||||||
|
passed: results.filter((r) => r.outcome === 'PASSED').length,
|
||||||
|
filteredOut: results.filter((r) => r.outcome === 'FILTERED_OUT').length,
|
||||||
|
flagged: results.filter((r) => r.outcome === 'FLAGGED').length,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filtering results for a round (paginated)
|
||||||
|
*/
|
||||||
|
getResults: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
outcome: z.enum(['PASSED', 'FILTERED_OUT', 'FLAGGED']).optional(),
|
||||||
|
page: z.number().int().min(1).default(1),
|
||||||
|
perPage: z.number().int().min(1).max(100).default(20),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { roundId, outcome, page, perPage } = input
|
||||||
|
const skip = (page - 1) * perPage
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { roundId }
|
||||||
|
if (outcome) where.outcome = outcome
|
||||||
|
|
||||||
|
const [results, total] = await Promise.all([
|
||||||
|
ctx.prisma.filteringResult.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: perPage,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
teamName: true,
|
||||||
|
status: true,
|
||||||
|
competitionCategory: true,
|
||||||
|
country: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overriddenByUser: {
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.prisma.filteringResult.count({ where }),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
results,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
totalPages: Math.ceil(total / perPage),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get aggregate stats for filtering results
|
||||||
|
*/
|
||||||
|
getResultStats: protectedProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const [passed, filteredOut, flagged, overridden] = await Promise.all([
|
||||||
|
ctx.prisma.filteringResult.count({
|
||||||
|
where: { roundId: input.roundId, outcome: 'PASSED' },
|
||||||
|
}),
|
||||||
|
ctx.prisma.filteringResult.count({
|
||||||
|
where: { roundId: input.roundId, outcome: 'FILTERED_OUT' },
|
||||||
|
}),
|
||||||
|
ctx.prisma.filteringResult.count({
|
||||||
|
where: { roundId: input.roundId, outcome: 'FLAGGED' },
|
||||||
|
}),
|
||||||
|
ctx.prisma.filteringResult.count({
|
||||||
|
where: { roundId: input.roundId, overriddenBy: { not: null } },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
return { passed, filteredOut, flagged, overridden, total: passed + filteredOut + flagged }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override a single filtering result
|
||||||
|
*/
|
||||||
|
overrideResult: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
finalOutcome: z.enum(['PASSED', 'FILTERED_OUT', 'FLAGGED']),
|
||||||
|
reason: z.string().min(1),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const result = await ctx.prisma.filteringResult.update({
|
||||||
|
where: { id: input.id },
|
||||||
|
data: {
|
||||||
|
finalOutcome: input.finalOutcome,
|
||||||
|
overriddenBy: ctx.user.id,
|
||||||
|
overriddenAt: new Date(),
|
||||||
|
overrideReason: input.reason,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
entityType: 'FilteringResult',
|
||||||
|
entityId: input.id,
|
||||||
|
detailsJson: {
|
||||||
|
action: 'OVERRIDE',
|
||||||
|
originalOutcome: result.outcome,
|
||||||
|
finalOutcome: input.finalOutcome,
|
||||||
|
reason: input.reason,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk override multiple results
|
||||||
|
*/
|
||||||
|
bulkOverride: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
ids: z.array(z.string()),
|
||||||
|
finalOutcome: z.enum(['PASSED', 'FILTERED_OUT', 'FLAGGED']),
|
||||||
|
reason: z.string().min(1),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.prisma.filteringResult.updateMany({
|
||||||
|
where: { id: { in: input.ids } },
|
||||||
|
data: {
|
||||||
|
finalOutcome: input.finalOutcome,
|
||||||
|
overriddenBy: ctx.user.id,
|
||||||
|
overriddenAt: new Date(),
|
||||||
|
overrideReason: input.reason,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'BULK_UPDATE_STATUS',
|
||||||
|
entityType: 'FilteringResult',
|
||||||
|
detailsJson: {
|
||||||
|
action: 'BULK_OVERRIDE',
|
||||||
|
count: input.ids.length,
|
||||||
|
finalOutcome: input.finalOutcome,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { updated: input.ids.length }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize filtering results — apply outcomes to project statuses
|
||||||
|
* PASSED → keep in pool, FILTERED_OUT → set aside (NOT deleted)
|
||||||
|
*/
|
||||||
|
finalizeResults: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const results = await ctx.prisma.filteringResult.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use finalOutcome if overridden, otherwise use outcome
|
||||||
|
const filteredOutIds = results
|
||||||
|
.filter((r) => (r.finalOutcome || r.outcome) === 'FILTERED_OUT')
|
||||||
|
.map((r) => r.projectId)
|
||||||
|
|
||||||
|
const passedIds = results
|
||||||
|
.filter((r) => (r.finalOutcome || r.outcome) === 'PASSED')
|
||||||
|
.map((r) => r.projectId)
|
||||||
|
|
||||||
|
// Update project statuses
|
||||||
|
await ctx.prisma.$transaction([
|
||||||
|
// Filtered out projects get REJECTED status (data preserved)
|
||||||
|
...(filteredOutIds.length > 0
|
||||||
|
? [
|
||||||
|
ctx.prisma.project.updateMany({
|
||||||
|
where: { id: { in: filteredOutIds } },
|
||||||
|
data: { status: 'REJECTED' },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
// Passed projects get ELIGIBLE status
|
||||||
|
...(passedIds.length > 0
|
||||||
|
? [
|
||||||
|
ctx.prisma.project.updateMany({
|
||||||
|
where: { id: { in: passedIds } },
|
||||||
|
data: { status: 'ELIGIBLE' },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
])
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
entityType: 'Round',
|
||||||
|
entityId: input.roundId,
|
||||||
|
detailsJson: {
|
||||||
|
action: 'FINALIZE_FILTERING',
|
||||||
|
passed: passedIds.length,
|
||||||
|
filteredOut: filteredOutIds.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { passed: passedIds.length, filteredOut: filteredOutIds.length }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reinstate a filtered-out project back into the active pool
|
||||||
|
*/
|
||||||
|
reinstateProject: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Update filtering result
|
||||||
|
await ctx.prisma.filteringResult.update({
|
||||||
|
where: {
|
||||||
|
roundId_projectId: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
finalOutcome: 'PASSED',
|
||||||
|
overriddenBy: ctx.user.id,
|
||||||
|
overriddenAt: new Date(),
|
||||||
|
overrideReason: 'Reinstated by admin',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Restore project status
|
||||||
|
await ctx.prisma.project.update({
|
||||||
|
where: { id: input.projectId },
|
||||||
|
data: { status: 'ELIGIBLE' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
entityType: 'FilteringResult',
|
||||||
|
detailsJson: {
|
||||||
|
action: 'REINSTATE',
|
||||||
|
roundId: input.roundId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk reinstate filtered-out projects
|
||||||
|
*/
|
||||||
|
bulkReinstate: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
projectIds: z.array(z.string()),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.prisma.$transaction([
|
||||||
|
...input.projectIds.map((projectId) =>
|
||||||
|
ctx.prisma.filteringResult.update({
|
||||||
|
where: {
|
||||||
|
roundId_projectId: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
finalOutcome: 'PASSED',
|
||||||
|
overriddenBy: ctx.user.id,
|
||||||
|
overriddenAt: new Date(),
|
||||||
|
overrideReason: 'Bulk reinstated by admin',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
ctx.prisma.project.updateMany({
|
||||||
|
where: { id: { in: input.projectIds } },
|
||||||
|
data: { status: 'ELIGIBLE' },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'BULK_UPDATE_STATUS',
|
||||||
|
entityType: 'FilteringResult',
|
||||||
|
detailsJson: {
|
||||||
|
action: 'BULK_REINSTATE',
|
||||||
|
roundId: input.roundId,
|
||||||
|
count: input.projectIds.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { reinstated: input.projectIds.length }
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
@ -12,7 +12,7 @@ export const projectRouter = router({
|
||||||
list: protectedProcedure
|
list: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
roundId: z.string(),
|
roundId: z.string().optional(),
|
||||||
status: z
|
status: z
|
||||||
.enum([
|
.enum([
|
||||||
'SUBMITTED',
|
'SUBMITTED',
|
||||||
|
|
@ -23,23 +23,63 @@ export const projectRouter = router({
|
||||||
'REJECTED',
|
'REJECTED',
|
||||||
])
|
])
|
||||||
.optional(),
|
.optional(),
|
||||||
|
statuses: z.array(
|
||||||
|
z.enum([
|
||||||
|
'SUBMITTED',
|
||||||
|
'ELIGIBLE',
|
||||||
|
'ASSIGNED',
|
||||||
|
'SEMIFINALIST',
|
||||||
|
'FINALIST',
|
||||||
|
'REJECTED',
|
||||||
|
])
|
||||||
|
).optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
tags: z.array(z.string()).optional(),
|
tags: z.array(z.string()).optional(),
|
||||||
|
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
||||||
|
oceanIssue: z.enum([
|
||||||
|
'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
|
||||||
|
'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
|
||||||
|
'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
|
||||||
|
'OCEAN_ACIDIFICATION', 'OTHER',
|
||||||
|
]).optional(),
|
||||||
|
country: z.string().optional(),
|
||||||
|
wantsMentorship: z.boolean().optional(),
|
||||||
|
hasFiles: z.boolean().optional(),
|
||||||
|
hasAssignments: z.boolean().optional(),
|
||||||
page: z.number().int().min(1).default(1),
|
page: z.number().int().min(1).default(1),
|
||||||
perPage: z.number().int().min(1).max(100).default(20),
|
perPage: z.number().int().min(1).max(100).default(20),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const { roundId, status, search, tags, page, perPage } = input
|
const {
|
||||||
|
roundId, status, statuses, search, tags,
|
||||||
|
competitionCategory, oceanIssue, country,
|
||||||
|
wantsMentorship, hasFiles, hasAssignments,
|
||||||
|
page, perPage,
|
||||||
|
} = input
|
||||||
const skip = (page - 1) * perPage
|
const skip = (page - 1) * perPage
|
||||||
|
|
||||||
// Build where clause
|
// Build where clause
|
||||||
const where: Record<string, unknown> = { roundId }
|
const where: Record<string, unknown> = {}
|
||||||
|
|
||||||
if (status) where.status = status
|
if (roundId) where.roundId = roundId
|
||||||
|
if (statuses && statuses.length > 0) {
|
||||||
|
where.status = { in: statuses }
|
||||||
|
} else if (status) {
|
||||||
|
where.status = status
|
||||||
|
}
|
||||||
if (tags && tags.length > 0) {
|
if (tags && tags.length > 0) {
|
||||||
where.tags = { hasSome: tags }
|
where.tags = { hasSome: tags }
|
||||||
}
|
}
|
||||||
|
if (competitionCategory) where.competitionCategory = competitionCategory
|
||||||
|
if (oceanIssue) where.oceanIssue = oceanIssue
|
||||||
|
if (country) where.country = country
|
||||||
|
if (wantsMentorship !== undefined) where.wantsMentorship = wantsMentorship
|
||||||
|
if (hasFiles === true) where.files = { some: {} }
|
||||||
|
if (hasFiles === false) where.files = { none: {} }
|
||||||
|
if (hasAssignments === true) where.assignments = { some: {} }
|
||||||
|
if (hasAssignments === false) where.assignments = { none: {} }
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ title: { contains: search, mode: 'insensitive' } },
|
{ title: { contains: search, mode: 'insensitive' } },
|
||||||
|
|
@ -50,7 +90,9 @@ export const projectRouter = router({
|
||||||
|
|
||||||
// Jury members can only see assigned projects
|
// Jury members can only see assigned projects
|
||||||
if (ctx.user.role === 'JURY_MEMBER') {
|
if (ctx.user.role === 'JURY_MEMBER') {
|
||||||
|
// If hasAssignments filter is already set, combine with jury filter
|
||||||
where.assignments = {
|
where.assignments = {
|
||||||
|
...((where.assignments as Record<string, unknown>) || {}),
|
||||||
some: { userId: ctx.user.id },
|
some: { userId: ctx.user.id },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +105,9 @@ export const projectRouter = router({
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
files: true,
|
files: true,
|
||||||
|
round: {
|
||||||
|
select: { id: true, name: true, program: { select: { name: true } } },
|
||||||
|
},
|
||||||
_count: { select: { assignments: true } },
|
_count: { select: { assignments: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
@ -78,6 +123,48 @@ export const projectRouter = router({
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filter options for the project list (distinct values)
|
||||||
|
*/
|
||||||
|
getFilterOptions: protectedProcedure
|
||||||
|
.query(async ({ ctx }) => {
|
||||||
|
const [rounds, countries, categories, issues] = await Promise.all([
|
||||||
|
ctx.prisma.round.findMany({
|
||||||
|
select: { id: true, name: true, program: { select: { name: true } } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
}),
|
||||||
|
ctx.prisma.project.findMany({
|
||||||
|
where: { country: { not: null } },
|
||||||
|
select: { country: true },
|
||||||
|
distinct: ['country'],
|
||||||
|
orderBy: { country: 'asc' },
|
||||||
|
}),
|
||||||
|
ctx.prisma.project.groupBy({
|
||||||
|
by: ['competitionCategory'],
|
||||||
|
where: { competitionCategory: { not: null } },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
ctx.prisma.project.groupBy({
|
||||||
|
by: ['oceanIssue'],
|
||||||
|
where: { oceanIssue: { not: null } },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
rounds,
|
||||||
|
countries: countries.map((c) => c.country).filter(Boolean) as string[],
|
||||||
|
categories: categories.map((c) => ({
|
||||||
|
value: c.competitionCategory!,
|
||||||
|
count: c._count,
|
||||||
|
})),
|
||||||
|
issues: issues.map((i) => ({
|
||||||
|
value: i.oceanIssue!,
|
||||||
|
count: i._count,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single project with details
|
* Get a single project with details
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -165,19 +165,33 @@ export const roundRouter = router({
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Get previous status for audit
|
||||||
|
const previousRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.id },
|
||||||
|
select: { status: true },
|
||||||
|
})
|
||||||
|
|
||||||
const round = await ctx.prisma.round.update({
|
const round = await ctx.prisma.round.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
data: { status: input.status },
|
data: { status: input.status },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Map status to specific action name
|
||||||
|
const statusActionMap: Record<string, string> = {
|
||||||
|
ACTIVE: 'ROUND_ACTIVATED',
|
||||||
|
CLOSED: 'ROUND_CLOSED',
|
||||||
|
ARCHIVED: 'ROUND_ARCHIVED',
|
||||||
|
}
|
||||||
|
const action = statusActionMap[input.status] || 'UPDATE_STATUS'
|
||||||
|
|
||||||
// Audit log
|
// Audit log
|
||||||
await ctx.prisma.auditLog.create({
|
await ctx.prisma.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: 'UPDATE_STATUS',
|
action,
|
||||||
entityType: 'Round',
|
entityType: 'Round',
|
||||||
entityId: input.id,
|
entityId: input.id,
|
||||||
detailsJson: { status: input.status },
|
detailsJson: { status: input.status, previousStatus: previousRound.status },
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,775 @@
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { Prisma } from '@prisma/client'
|
||||||
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||||
|
import { logAudit } from '../utils/audit'
|
||||||
|
import {
|
||||||
|
applyAutoTagRules,
|
||||||
|
aiInterpretCriteria,
|
||||||
|
type AutoTagRule,
|
||||||
|
} from '../services/ai-award-eligibility'
|
||||||
|
|
||||||
|
export const specialAwardRouter = router({
|
||||||
|
// ─── Admin Queries ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List awards for a program
|
||||||
|
*/
|
||||||
|
list: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
programId: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return ctx.prisma.specialAward.findMany({
|
||||||
|
where: input.programId ? { programId: input.programId } : {},
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
eligibilities: true,
|
||||||
|
jurors: true,
|
||||||
|
votes: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
winnerProject: {
|
||||||
|
select: { id: true, title: true, teamName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get award detail with stats
|
||||||
|
*/
|
||||||
|
get: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||||
|
where: { id: input.id },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
eligibilities: true,
|
||||||
|
jurors: true,
|
||||||
|
votes: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
winnerProject: {
|
||||||
|
select: { id: true, title: true, teamName: true },
|
||||||
|
},
|
||||||
|
program: {
|
||||||
|
select: { id: true, name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Count eligible projects
|
||||||
|
const eligibleCount = await ctx.prisma.awardEligibility.count({
|
||||||
|
where: { awardId: input.id, eligible: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...award, eligibleCount }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ─── Admin Mutations ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create award
|
||||||
|
*/
|
||||||
|
create: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
programId: z.string(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
criteriaText: z.string().optional(),
|
||||||
|
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']),
|
||||||
|
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
||||||
|
autoTagRulesJson: z.record(z.unknown()).optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const maxOrder = await ctx.prisma.specialAward.aggregate({
|
||||||
|
where: { programId: input.programId },
|
||||||
|
_max: { sortOrder: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const award = await ctx.prisma.specialAward.create({
|
||||||
|
data: {
|
||||||
|
programId: input.programId,
|
||||||
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
criteriaText: input.criteriaText,
|
||||||
|
scoringMode: input.scoringMode,
|
||||||
|
maxRankedPicks: input.maxRankedPicks,
|
||||||
|
autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined,
|
||||||
|
sortOrder: (maxOrder._max.sortOrder || 0) + 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
entityType: 'SpecialAward',
|
||||||
|
entityId: award.id,
|
||||||
|
detailsJson: { name: input.name, scoringMode: input.scoringMode },
|
||||||
|
})
|
||||||
|
|
||||||
|
return award
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update award config
|
||||||
|
*/
|
||||||
|
update: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
criteriaText: z.string().optional(),
|
||||||
|
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).optional(),
|
||||||
|
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
||||||
|
autoTagRulesJson: z.record(z.unknown()).optional(),
|
||||||
|
votingStartAt: z.date().optional(),
|
||||||
|
votingEndAt: z.date().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { id, autoTagRulesJson, ...rest } = input
|
||||||
|
const award = await ctx.prisma.specialAward.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...rest,
|
||||||
|
...(autoTagRulesJson !== undefined && { autoTagRulesJson: autoTagRulesJson as Prisma.InputJsonValue }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
entityType: 'SpecialAward',
|
||||||
|
entityId: id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return award
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete award
|
||||||
|
*/
|
||||||
|
delete: adminProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.prisma.specialAward.delete({ where: { id: input.id } })
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'DELETE',
|
||||||
|
entityType: 'SpecialAward',
|
||||||
|
entityId: input.id,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update award status
|
||||||
|
*/
|
||||||
|
updateStatus: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
status: z.enum([
|
||||||
|
'DRAFT',
|
||||||
|
'NOMINATIONS_OPEN',
|
||||||
|
'VOTING_OPEN',
|
||||||
|
'CLOSED',
|
||||||
|
'ARCHIVED',
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const current = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||||
|
where: { id: input.id },
|
||||||
|
select: { status: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const award = await ctx.prisma.specialAward.update({
|
||||||
|
where: { id: input.id },
|
||||||
|
data: { status: input.status },
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'UPDATE_STATUS',
|
||||||
|
entityType: 'SpecialAward',
|
||||||
|
entityId: input.id,
|
||||||
|
detailsJson: {
|
||||||
|
previousStatus: current.status,
|
||||||
|
newStatus: input.status,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return award
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ─── Eligibility ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run auto-tag + AI eligibility
|
||||||
|
*/
|
||||||
|
runEligibility: adminProcedure
|
||||||
|
.input(z.object({ awardId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||||
|
where: { id: input.awardId },
|
||||||
|
include: { program: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get all projects in the program's rounds
|
||||||
|
const projects = await ctx.prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
round: { programId: award.programId },
|
||||||
|
status: { in: ['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
competitionCategory: true,
|
||||||
|
country: true,
|
||||||
|
geographicZone: true,
|
||||||
|
tags: true,
|
||||||
|
oceanIssue: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (projects.length === 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'No eligible projects found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Auto-tag rules (deterministic)
|
||||||
|
const autoTagRules = award.autoTagRulesJson as unknown as AutoTagRule[] | null
|
||||||
|
let autoResults: Map<string, boolean> | undefined
|
||||||
|
if (autoTagRules && Array.isArray(autoTagRules) && autoTagRules.length > 0) {
|
||||||
|
autoResults = applyAutoTagRules(autoTagRules, projects)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: AI interpretation (if criteria text exists)
|
||||||
|
let aiResults: Map<string, { eligible: boolean; confidence: number; reasoning: string }> | undefined
|
||||||
|
if (award.criteriaText) {
|
||||||
|
const aiEvals = await aiInterpretCriteria(award.criteriaText, projects)
|
||||||
|
aiResults = new Map(
|
||||||
|
aiEvals.map((e) => [
|
||||||
|
e.projectId,
|
||||||
|
{ eligible: e.eligible, confidence: e.confidence, reasoning: e.reasoning },
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine results: auto-tag AND AI must agree (or just one if only one configured)
|
||||||
|
const eligibilities = projects.map((project) => {
|
||||||
|
const autoEligible = autoResults?.get(project.id) ?? true
|
||||||
|
const aiEval = aiResults?.get(project.id)
|
||||||
|
const aiEligible = aiEval?.eligible ?? true
|
||||||
|
|
||||||
|
const eligible = autoEligible && aiEligible
|
||||||
|
const method = autoResults && aiResults ? 'AUTO' : autoResults ? 'AUTO' : 'MANUAL'
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId: project.id,
|
||||||
|
eligible,
|
||||||
|
method,
|
||||||
|
aiReasoningJson: aiEval
|
||||||
|
? { confidence: aiEval.confidence, reasoning: aiEval.reasoning }
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Upsert eligibilities
|
||||||
|
await ctx.prisma.$transaction(
|
||||||
|
eligibilities.map((e) =>
|
||||||
|
ctx.prisma.awardEligibility.upsert({
|
||||||
|
where: {
|
||||||
|
awardId_projectId: {
|
||||||
|
awardId: input.awardId,
|
||||||
|
projectId: e.projectId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
awardId: input.awardId,
|
||||||
|
projectId: e.projectId,
|
||||||
|
eligible: e.eligible,
|
||||||
|
method: e.method as 'AUTO' | 'MANUAL',
|
||||||
|
aiReasoningJson: e.aiReasoningJson ?? undefined,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
eligible: e.eligible,
|
||||||
|
method: e.method as 'AUTO' | 'MANUAL',
|
||||||
|
aiReasoningJson: e.aiReasoningJson ?? undefined,
|
||||||
|
// Clear overrides
|
||||||
|
overriddenBy: null,
|
||||||
|
overriddenAt: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const eligibleCount = eligibilities.filter((e) => e.eligible).length
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
entityType: 'SpecialAward',
|
||||||
|
entityId: input.awardId,
|
||||||
|
detailsJson: {
|
||||||
|
action: 'RUN_ELIGIBILITY',
|
||||||
|
totalProjects: projects.length,
|
||||||
|
eligible: eligibleCount,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: projects.length,
|
||||||
|
eligible: eligibleCount,
|
||||||
|
ineligible: projects.length - eligibleCount,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List eligible projects
|
||||||
|
*/
|
||||||
|
listEligible: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
awardId: z.string(),
|
||||||
|
eligibleOnly: z.boolean().default(false),
|
||||||
|
page: z.number().int().min(1).default(1),
|
||||||
|
perPage: z.number().int().min(1).max(100).default(50),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { awardId, eligibleOnly, page, perPage } = input
|
||||||
|
const skip = (page - 1) * perPage
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { awardId }
|
||||||
|
if (eligibleOnly) where.eligible = true
|
||||||
|
|
||||||
|
const [eligibilities, total] = await Promise.all([
|
||||||
|
ctx.prisma.awardEligibility.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: perPage,
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
teamName: true,
|
||||||
|
competitionCategory: true,
|
||||||
|
country: true,
|
||||||
|
tags: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { project: { title: 'asc' } },
|
||||||
|
}),
|
||||||
|
ctx.prisma.awardEligibility.count({ where }),
|
||||||
|
])
|
||||||
|
|
||||||
|
return { eligibilities, total, page, perPage, totalPages: Math.ceil(total / perPage) }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual eligibility override
|
||||||
|
*/
|
||||||
|
setEligibility: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
awardId: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
eligible: z.boolean(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.prisma.awardEligibility.upsert({
|
||||||
|
where: {
|
||||||
|
awardId_projectId: {
|
||||||
|
awardId: input.awardId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
awardId: input.awardId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
eligible: input.eligible,
|
||||||
|
method: 'MANUAL',
|
||||||
|
overriddenBy: ctx.user.id,
|
||||||
|
overriddenAt: new Date(),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
eligible: input.eligible,
|
||||||
|
overriddenBy: ctx.user.id,
|
||||||
|
overriddenAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ─── Jurors ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List jurors for an award
|
||||||
|
*/
|
||||||
|
listJurors: protectedProcedure
|
||||||
|
.input(z.object({ awardId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return ctx.prisma.awardJuror.findMany({
|
||||||
|
where: { awardId: input.awardId },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
profileImageKey: true,
|
||||||
|
profileImageProvider: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add juror
|
||||||
|
*/
|
||||||
|
addJuror: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
awardId: z.string(),
|
||||||
|
userId: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return ctx.prisma.awardJuror.create({
|
||||||
|
data: {
|
||||||
|
awardId: input.awardId,
|
||||||
|
userId: input.userId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove juror
|
||||||
|
*/
|
||||||
|
removeJuror: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
awardId: z.string(),
|
||||||
|
userId: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.prisma.awardJuror.delete({
|
||||||
|
where: {
|
||||||
|
awardId_userId: {
|
||||||
|
awardId: input.awardId,
|
||||||
|
userId: input.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk add jurors
|
||||||
|
*/
|
||||||
|
bulkAddJurors: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
awardId: z.string(),
|
||||||
|
userIds: z.array(z.string()),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const data = input.userIds.map((userId) => ({
|
||||||
|
awardId: input.awardId,
|
||||||
|
userId,
|
||||||
|
}))
|
||||||
|
|
||||||
|
await ctx.prisma.awardJuror.createMany({
|
||||||
|
data,
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { added: input.userIds.length }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ─── Jury Queries ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get awards where current user is a juror
|
||||||
|
*/
|
||||||
|
getMyAwards: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const jurorships = await ctx.prisma.awardJuror.findMany({
|
||||||
|
where: { userId: ctx.user.id },
|
||||||
|
include: {
|
||||||
|
award: {
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { eligibilities: { where: { eligible: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return jurorships.map((j) => j.award)
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get award detail for voting (jury view)
|
||||||
|
*/
|
||||||
|
getMyAwardDetail: protectedProcedure
|
||||||
|
.input(z.object({ awardId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
// Verify user is a juror
|
||||||
|
const juror = await ctx.prisma.awardJuror.findUnique({
|
||||||
|
where: {
|
||||||
|
awardId_userId: {
|
||||||
|
awardId: input.awardId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!juror) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'You are not a juror for this award',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||||
|
where: { id: input.awardId },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get eligible projects
|
||||||
|
const eligibleProjects = await ctx.prisma.awardEligibility.findMany({
|
||||||
|
where: { awardId: input.awardId, eligible: true },
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
teamName: true,
|
||||||
|
description: true,
|
||||||
|
competitionCategory: true,
|
||||||
|
country: true,
|
||||||
|
tags: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get user's existing votes
|
||||||
|
const myVotes = await ctx.prisma.awardVote.findMany({
|
||||||
|
where: { awardId: input.awardId, userId: ctx.user.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
award,
|
||||||
|
projects: eligibleProjects.map((e) => e.project),
|
||||||
|
myVotes,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ─── Voting ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit vote (PICK_WINNER or RANKED)
|
||||||
|
*/
|
||||||
|
submitVote: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
awardId: z.string(),
|
||||||
|
votes: z.array(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
rank: z.number().int().min(1).optional(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Verify juror
|
||||||
|
const juror = await ctx.prisma.awardJuror.findUnique({
|
||||||
|
where: {
|
||||||
|
awardId_userId: {
|
||||||
|
awardId: input.awardId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!juror) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'You are not a juror for this award',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify award is open for voting
|
||||||
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||||
|
where: { id: input.awardId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (award.status !== 'VOTING_OPEN') {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Voting is not currently open for this award',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete existing votes and create new ones
|
||||||
|
await ctx.prisma.$transaction([
|
||||||
|
ctx.prisma.awardVote.deleteMany({
|
||||||
|
where: { awardId: input.awardId, userId: ctx.user.id },
|
||||||
|
}),
|
||||||
|
...input.votes.map((vote) =>
|
||||||
|
ctx.prisma.awardVote.create({
|
||||||
|
data: {
|
||||||
|
awardId: input.awardId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
projectId: vote.projectId,
|
||||||
|
rank: vote.rank,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
entityType: 'AwardVote',
|
||||||
|
entityId: input.awardId,
|
||||||
|
detailsJson: {
|
||||||
|
awardId: input.awardId,
|
||||||
|
voteCount: input.votes.length,
|
||||||
|
scoringMode: award.scoringMode,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { submitted: input.votes.length }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ─── Results ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get aggregated vote results
|
||||||
|
*/
|
||||||
|
getVoteResults: adminProcedure
|
||||||
|
.input(z.object({ awardId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||||
|
where: { id: input.awardId },
|
||||||
|
})
|
||||||
|
|
||||||
|
const votes = await ctx.prisma.awardVote.findMany({
|
||||||
|
where: { awardId: input.awardId },
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: { id: true, title: true, teamName: true },
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const jurorCount = await ctx.prisma.awardJuror.count({
|
||||||
|
where: { awardId: input.awardId },
|
||||||
|
})
|
||||||
|
|
||||||
|
const votedJurorCount = new Set(votes.map((v) => v.userId)).size
|
||||||
|
|
||||||
|
// Tally by scoring mode
|
||||||
|
const projectTallies = new Map<
|
||||||
|
string,
|
||||||
|
{ project: { id: string; title: string; teamName: string | null }; votes: number; points: number }
|
||||||
|
>()
|
||||||
|
|
||||||
|
for (const vote of votes) {
|
||||||
|
const existing = projectTallies.get(vote.projectId) || {
|
||||||
|
project: vote.project,
|
||||||
|
votes: 0,
|
||||||
|
points: 0,
|
||||||
|
}
|
||||||
|
existing.votes += 1
|
||||||
|
if (award.scoringMode === 'RANKED' && vote.rank) {
|
||||||
|
existing.points += (award.maxRankedPicks || 5) - vote.rank + 1
|
||||||
|
} else {
|
||||||
|
existing.points += 1
|
||||||
|
}
|
||||||
|
projectTallies.set(vote.projectId, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranked = Array.from(projectTallies.values()).sort(
|
||||||
|
(a, b) => b.points - a.points
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
scoringMode: award.scoringMode,
|
||||||
|
jurorCount,
|
||||||
|
votedJurorCount,
|
||||||
|
results: ranked,
|
||||||
|
winnerId: award.winnerProjectId,
|
||||||
|
winnerOverridden: award.winnerOverridden,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set/override winner
|
||||||
|
*/
|
||||||
|
setWinner: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
awardId: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
overridden: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const previous = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||||
|
where: { id: input.awardId },
|
||||||
|
select: { winnerProjectId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const award = await ctx.prisma.specialAward.update({
|
||||||
|
where: { id: input.awardId },
|
||||||
|
data: {
|
||||||
|
winnerProjectId: input.projectId,
|
||||||
|
winnerOverridden: input.overridden,
|
||||||
|
winnerOverriddenBy: input.overridden ? ctx.user.id : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
entityType: 'SpecialAward',
|
||||||
|
entityId: input.awardId,
|
||||||
|
detailsJson: {
|
||||||
|
action: 'SET_AWARD_WINNER',
|
||||||
|
previousWinner: previous.winnerProjectId,
|
||||||
|
newWinner: input.projectId,
|
||||||
|
overridden: input.overridden,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return award
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
@ -170,6 +170,7 @@ export const userRouter = router({
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
||||||
|
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
|
||||||
status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
page: z.number().int().min(1).default(1),
|
page: z.number().int().min(1).default(1),
|
||||||
|
|
@ -177,12 +178,16 @@ export const userRouter = router({
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const { role, status, search, page, perPage } = input
|
const { role, roles, status, search, page, perPage } = input
|
||||||
const skip = (page - 1) * perPage
|
const skip = (page - 1) * perPage
|
||||||
|
|
||||||
const where: Record<string, unknown> = {}
|
const where: Record<string, unknown> = {}
|
||||||
|
|
||||||
if (role) where.role = role
|
if (roles && roles.length > 0) {
|
||||||
|
where.role = { in: roles }
|
||||||
|
} else if (role) {
|
||||||
|
where.role = role
|
||||||
|
}
|
||||||
if (status) where.status = status
|
if (status) where.status = status
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
|
|
@ -210,7 +215,7 @@ export const userRouter = router({
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
lastLoginAt: true,
|
lastLoginAt: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: { assignments: true },
|
select: { assignments: true, mentorAssignments: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
@ -238,7 +243,7 @@ export const userRouter = router({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { assignments: true },
|
select: { assignments: true, mentorAssignments: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -356,6 +361,21 @@ export const userRouter = router({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Track role change specifically
|
||||||
|
if (data.role && data.role !== targetUser.role) {
|
||||||
|
await ctx.prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'ROLE_CHANGED',
|
||||||
|
entityType: 'User',
|
||||||
|
entityId: id,
|
||||||
|
detailsJson: { previousRole: targetUser.role, newRole: data.role },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
},
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
return user
|
return user
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
@ -816,7 +836,7 @@ export const userRouter = router({
|
||||||
await ctx.prisma.auditLog.create({
|
await ctx.prisma.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: 'SET_PASSWORD',
|
action: 'PASSWORD_SET',
|
||||||
entityType: 'User',
|
entityType: 'User',
|
||||||
entityId: ctx.user.id,
|
entityId: ctx.user.id,
|
||||||
detailsJson: { timestamp: new Date().toISOString() },
|
detailsJson: { timestamp: new Date().toISOString() },
|
||||||
|
|
@ -896,7 +916,7 @@ export const userRouter = router({
|
||||||
await ctx.prisma.auditLog.create({
|
await ctx.prisma.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: 'CHANGE_PASSWORD',
|
action: 'PASSWORD_CHANGED',
|
||||||
entityType: 'User',
|
entityType: 'User',
|
||||||
entityId: ctx.user.id,
|
entityId: ctx.user.id,
|
||||||
detailsJson: { timestamp: new Date().toISOString() },
|
detailsJson: { timestamp: new Date().toISOString() },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
/**
|
||||||
|
* AI-Powered Award Eligibility Service
|
||||||
|
*
|
||||||
|
* Determines project eligibility for special awards using:
|
||||||
|
* - Deterministic field matching (tags, country, category)
|
||||||
|
* - AI interpretation of plain-language criteria
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getOpenAI, getConfiguredModel } from '@/lib/openai'
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type AutoTagRule = {
|
||||||
|
field: 'competitionCategory' | 'country' | 'geographicZone' | 'tags' | 'oceanIssue'
|
||||||
|
operator: 'equals' | 'contains' | 'in'
|
||||||
|
value: string | string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EligibilityResult {
|
||||||
|
projectId: string
|
||||||
|
eligible: boolean
|
||||||
|
confidence: number
|
||||||
|
reasoning: string
|
||||||
|
method: 'AUTO' | 'AI'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectForEligibility {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description?: string | null
|
||||||
|
competitionCategory?: string | null
|
||||||
|
country?: string | null
|
||||||
|
geographicZone?: string | null
|
||||||
|
tags: string[]
|
||||||
|
oceanIssue?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auto Tag Rules ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function applyAutoTagRules(
|
||||||
|
rules: AutoTagRule[],
|
||||||
|
projects: ProjectForEligibility[]
|
||||||
|
): Map<string, boolean> {
|
||||||
|
const results = new Map<string, boolean>()
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
const matches = rules.every((rule) => {
|
||||||
|
const fieldValue = getFieldValue(project, rule.field)
|
||||||
|
|
||||||
|
switch (rule.operator) {
|
||||||
|
case 'equals':
|
||||||
|
return String(fieldValue).toLowerCase() === String(rule.value).toLowerCase()
|
||||||
|
case 'contains':
|
||||||
|
if (Array.isArray(fieldValue)) {
|
||||||
|
return fieldValue.some((v) =>
|
||||||
|
String(v).toLowerCase().includes(String(rule.value).toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return String(fieldValue || '').toLowerCase().includes(String(rule.value).toLowerCase())
|
||||||
|
case 'in':
|
||||||
|
if (Array.isArray(rule.value)) {
|
||||||
|
return rule.value.some((v) =>
|
||||||
|
String(v).toLowerCase() === String(fieldValue).toLowerCase()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
results.set(project.id, matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFieldValue(
|
||||||
|
project: ProjectForEligibility,
|
||||||
|
field: AutoTagRule['field']
|
||||||
|
): unknown {
|
||||||
|
switch (field) {
|
||||||
|
case 'competitionCategory':
|
||||||
|
return project.competitionCategory
|
||||||
|
case 'country':
|
||||||
|
return project.country
|
||||||
|
case 'geographicZone':
|
||||||
|
return project.geographicZone
|
||||||
|
case 'tags':
|
||||||
|
return project.tags
|
||||||
|
case 'oceanIssue':
|
||||||
|
return project.oceanIssue
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AI Criteria Interpretation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const AI_ELIGIBILITY_SYSTEM_PROMPT = `You are a special award eligibility evaluator. Given a list of projects and award criteria, determine which projects are eligible.
|
||||||
|
|
||||||
|
Return a JSON object with this structure:
|
||||||
|
{
|
||||||
|
"evaluations": [
|
||||||
|
{
|
||||||
|
"project_id": "string",
|
||||||
|
"eligible": boolean,
|
||||||
|
"confidence": number (0-1),
|
||||||
|
"reasoning": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Be fair, objective, and base your evaluation only on the provided information. Do not include personal identifiers in reasoning.`
|
||||||
|
|
||||||
|
export async function aiInterpretCriteria(
|
||||||
|
criteriaText: string,
|
||||||
|
projects: ProjectForEligibility[]
|
||||||
|
): Promise<EligibilityResult[]> {
|
||||||
|
const results: EligibilityResult[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const openai = await getOpenAI()
|
||||||
|
if (!openai) {
|
||||||
|
// No OpenAI — mark all as needing manual review
|
||||||
|
return projects.map((p) => ({
|
||||||
|
projectId: p.id,
|
||||||
|
eligible: false,
|
||||||
|
confidence: 0,
|
||||||
|
reasoning: 'AI unavailable — requires manual eligibility review',
|
||||||
|
method: 'AI' as const,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = await getConfiguredModel()
|
||||||
|
|
||||||
|
// Anonymize and batch
|
||||||
|
const anonymized = projects.map((p, i) => ({
|
||||||
|
project_id: `P${i + 1}`,
|
||||||
|
real_id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
description: p.description?.slice(0, 500) || '',
|
||||||
|
category: p.competitionCategory || 'Unknown',
|
||||||
|
ocean_issue: p.oceanIssue || 'Unknown',
|
||||||
|
country: p.country || 'Unknown',
|
||||||
|
region: p.geographicZone || 'Unknown',
|
||||||
|
tags: p.tags.join(', '),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const batchSize = 20
|
||||||
|
for (let i = 0; i < anonymized.length; i += batchSize) {
|
||||||
|
const batch = anonymized.slice(i, i + batchSize)
|
||||||
|
|
||||||
|
const userPrompt = `Award criteria: ${criteriaText}
|
||||||
|
|
||||||
|
Projects to evaluate:
|
||||||
|
${JSON.stringify(
|
||||||
|
batch.map(({ real_id, ...rest }) => rest),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}
|
||||||
|
|
||||||
|
Evaluate each project against the award criteria.`
|
||||||
|
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: AI_ELIGIBILITY_SYSTEM_PROMPT },
|
||||||
|
{ role: 'user', content: userPrompt },
|
||||||
|
],
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 4000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = response.choices[0]?.message?.content
|
||||||
|
if (content) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content) as {
|
||||||
|
evaluations: Array<{
|
||||||
|
project_id: string
|
||||||
|
eligible: boolean
|
||||||
|
confidence: number
|
||||||
|
reasoning: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const eval_ of parsed.evaluations) {
|
||||||
|
const anon = batch.find((b) => b.project_id === eval_.project_id)
|
||||||
|
if (anon) {
|
||||||
|
results.push({
|
||||||
|
projectId: anon.real_id,
|
||||||
|
eligible: eval_.eligible,
|
||||||
|
confidence: eval_.confidence,
|
||||||
|
reasoning: eval_.reasoning,
|
||||||
|
method: 'AI',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Parse error — mark batch for manual review
|
||||||
|
for (const item of batch) {
|
||||||
|
results.push({
|
||||||
|
projectId: item.real_id,
|
||||||
|
eligible: false,
|
||||||
|
confidence: 0,
|
||||||
|
reasoning: 'AI response parse error — requires manual review',
|
||||||
|
method: 'AI',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// OpenAI error — mark all for manual review
|
||||||
|
return projects.map((p) => ({
|
||||||
|
projectId: p.id,
|
||||||
|
eligible: false,
|
||||||
|
confidence: 0,
|
||||||
|
reasoning: 'AI error — requires manual eligibility review',
|
||||||
|
method: 'AI' as const,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,509 @@
|
||||||
|
/**
|
||||||
|
* AI-Powered Filtering Service
|
||||||
|
*
|
||||||
|
* Runs automated filtering rules against projects:
|
||||||
|
* - Field-based rules (age checks, category, country, etc.)
|
||||||
|
* - Document checks (file existence/types)
|
||||||
|
* - AI screening (GPT interprets criteria text, flags spam)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getOpenAI, getConfiguredModel } from '@/lib/openai'
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type FieldRuleCondition = {
|
||||||
|
field:
|
||||||
|
| 'competitionCategory'
|
||||||
|
| 'foundedAt'
|
||||||
|
| 'country'
|
||||||
|
| 'geographicZone'
|
||||||
|
| 'tags'
|
||||||
|
| 'oceanIssue'
|
||||||
|
operator:
|
||||||
|
| 'equals'
|
||||||
|
| 'not_equals'
|
||||||
|
| 'greater_than'
|
||||||
|
| 'less_than'
|
||||||
|
| 'contains'
|
||||||
|
| 'in'
|
||||||
|
| 'not_in'
|
||||||
|
| 'older_than_years'
|
||||||
|
| 'newer_than_years'
|
||||||
|
| 'is_empty'
|
||||||
|
value: string | number | string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FieldRuleConfig = {
|
||||||
|
conditions: FieldRuleCondition[]
|
||||||
|
logic: 'AND' | 'OR'
|
||||||
|
action: 'PASS' | 'REJECT' | 'FLAG'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocumentCheckConfig = {
|
||||||
|
requiredFileTypes?: string[] // e.g. ['pdf', 'docx']
|
||||||
|
minFileCount?: number
|
||||||
|
action: 'PASS' | 'REJECT' | 'FLAG'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AIScreeningConfig = {
|
||||||
|
criteriaText: string
|
||||||
|
action: 'FLAG' // AI screening always flags for human review
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuleConfig = FieldRuleConfig | DocumentCheckConfig | AIScreeningConfig
|
||||||
|
|
||||||
|
export interface RuleResult {
|
||||||
|
ruleId: string
|
||||||
|
ruleName: string
|
||||||
|
ruleType: string
|
||||||
|
passed: boolean
|
||||||
|
action: 'PASS' | 'REJECT' | 'FLAG'
|
||||||
|
reasoning?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectFilteringResult {
|
||||||
|
projectId: string
|
||||||
|
outcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED'
|
||||||
|
ruleResults: RuleResult[]
|
||||||
|
aiScreeningJson?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectForFiltering {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description?: string | null
|
||||||
|
competitionCategory?: string | null
|
||||||
|
foundedAt?: Date | null
|
||||||
|
country?: string | null
|
||||||
|
geographicZone?: string | null
|
||||||
|
tags: string[]
|
||||||
|
oceanIssue?: string | null
|
||||||
|
wantsMentorship?: boolean | null
|
||||||
|
files: Array<{ id: string; fileName: string; fileType?: string | null }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilteringRuleInput {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
ruleType: string
|
||||||
|
configJson: Prisma.JsonValue
|
||||||
|
priority: number
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Field-Based Rule Evaluation ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function evaluateCondition(
|
||||||
|
condition: FieldRuleCondition,
|
||||||
|
project: ProjectForFiltering
|
||||||
|
): boolean {
|
||||||
|
const { field, operator, value } = condition
|
||||||
|
|
||||||
|
// Get field value from project
|
||||||
|
let fieldValue: unknown
|
||||||
|
switch (field) {
|
||||||
|
case 'competitionCategory':
|
||||||
|
fieldValue = project.competitionCategory
|
||||||
|
break
|
||||||
|
case 'foundedAt':
|
||||||
|
fieldValue = project.foundedAt
|
||||||
|
break
|
||||||
|
case 'country':
|
||||||
|
fieldValue = project.country
|
||||||
|
break
|
||||||
|
case 'geographicZone':
|
||||||
|
fieldValue = project.geographicZone
|
||||||
|
break
|
||||||
|
case 'tags':
|
||||||
|
fieldValue = project.tags
|
||||||
|
break
|
||||||
|
case 'oceanIssue':
|
||||||
|
fieldValue = project.oceanIssue
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case 'equals':
|
||||||
|
return String(fieldValue) === String(value)
|
||||||
|
case 'not_equals':
|
||||||
|
return String(fieldValue) !== String(value)
|
||||||
|
case 'contains':
|
||||||
|
if (Array.isArray(fieldValue)) {
|
||||||
|
return fieldValue.some((v) =>
|
||||||
|
String(v).toLowerCase().includes(String(value).toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return String(fieldValue || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(String(value).toLowerCase())
|
||||||
|
case 'in':
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.includes(String(fieldValue))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
case 'not_in':
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return !value.includes(String(fieldValue))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case 'is_empty':
|
||||||
|
if (fieldValue === null || fieldValue === undefined) return true
|
||||||
|
if (Array.isArray(fieldValue)) return fieldValue.length === 0
|
||||||
|
return String(fieldValue).trim() === ''
|
||||||
|
case 'older_than_years': {
|
||||||
|
if (!fieldValue || !(fieldValue instanceof Date)) return false
|
||||||
|
const yearsAgo = new Date()
|
||||||
|
yearsAgo.setFullYear(yearsAgo.getFullYear() - Number(value))
|
||||||
|
return fieldValue < yearsAgo
|
||||||
|
}
|
||||||
|
case 'newer_than_years': {
|
||||||
|
if (!fieldValue || !(fieldValue instanceof Date)) return false
|
||||||
|
const yearsAgo = new Date()
|
||||||
|
yearsAgo.setFullYear(yearsAgo.getFullYear() - Number(value))
|
||||||
|
return fieldValue >= yearsAgo
|
||||||
|
}
|
||||||
|
case 'greater_than':
|
||||||
|
return Number(fieldValue) > Number(value)
|
||||||
|
case 'less_than':
|
||||||
|
return Number(fieldValue) < Number(value)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateFieldRule(
|
||||||
|
config: FieldRuleConfig,
|
||||||
|
project: ProjectForFiltering
|
||||||
|
): { passed: boolean; action: 'PASS' | 'REJECT' | 'FLAG' } {
|
||||||
|
const results = config.conditions.map((c) => evaluateCondition(c, project))
|
||||||
|
|
||||||
|
const allConditionsMet =
|
||||||
|
config.logic === 'AND'
|
||||||
|
? results.every(Boolean)
|
||||||
|
: results.some(Boolean)
|
||||||
|
|
||||||
|
// If conditions met, the rule's action applies
|
||||||
|
// For PASS action: conditions met = passed, not met = not passed
|
||||||
|
// For REJECT action: conditions met = rejected (not passed)
|
||||||
|
// For FLAG action: conditions met = flagged
|
||||||
|
if (config.action === 'PASS') {
|
||||||
|
return { passed: allConditionsMet, action: config.action }
|
||||||
|
}
|
||||||
|
// For REJECT/FLAG: conditions matching means the project should be rejected/flagged
|
||||||
|
return { passed: !allConditionsMet, action: config.action }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Document Check Evaluation ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function evaluateDocumentRule(
|
||||||
|
config: DocumentCheckConfig,
|
||||||
|
project: ProjectForFiltering
|
||||||
|
): { passed: boolean; action: 'PASS' | 'REJECT' | 'FLAG' } {
|
||||||
|
const files = project.files || []
|
||||||
|
|
||||||
|
if (config.minFileCount !== undefined && files.length < config.minFileCount) {
|
||||||
|
return { passed: false, action: config.action }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.requiredFileTypes && config.requiredFileTypes.length > 0) {
|
||||||
|
const fileExtensions = files.map((f) => {
|
||||||
|
const ext = f.fileName.split('.').pop()?.toLowerCase()
|
||||||
|
return ext || ''
|
||||||
|
})
|
||||||
|
const hasAllTypes = config.requiredFileTypes.every((type) =>
|
||||||
|
fileExtensions.some((ext) => ext === type.toLowerCase())
|
||||||
|
)
|
||||||
|
if (!hasAllTypes) {
|
||||||
|
return { passed: false, action: config.action }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed: true, action: config.action }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AI Screening ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const AI_SCREENING_SYSTEM_PROMPT = `You are a project screening assistant. You evaluate projects against specific criteria.
|
||||||
|
You must return a JSON object with this structure:
|
||||||
|
{
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"project_id": "string",
|
||||||
|
"meets_criteria": boolean,
|
||||||
|
"confidence": number (0-1),
|
||||||
|
"reasoning": "string",
|
||||||
|
"quality_score": number (1-10),
|
||||||
|
"spam_risk": boolean
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Be fair and objective. Base your evaluation only on the information provided.
|
||||||
|
Never include personal identifiers in your reasoning.`
|
||||||
|
|
||||||
|
export async function executeAIScreening(
|
||||||
|
config: AIScreeningConfig,
|
||||||
|
projects: ProjectForFiltering[]
|
||||||
|
): Promise<
|
||||||
|
Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
meetsCriteria: boolean
|
||||||
|
confidence: number
|
||||||
|
reasoning: string
|
||||||
|
qualityScore: number
|
||||||
|
spamRisk: boolean
|
||||||
|
}
|
||||||
|
>
|
||||||
|
> {
|
||||||
|
const results = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
meetsCriteria: boolean
|
||||||
|
confidence: number
|
||||||
|
reasoning: string
|
||||||
|
qualityScore: number
|
||||||
|
spamRisk: boolean
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const openai = await getOpenAI()
|
||||||
|
if (!openai) {
|
||||||
|
// No OpenAI configured — flag all for manual review
|
||||||
|
for (const p of projects) {
|
||||||
|
results.set(p.id, {
|
||||||
|
meetsCriteria: false,
|
||||||
|
confidence: 0,
|
||||||
|
reasoning: 'AI screening unavailable — flagged for manual review',
|
||||||
|
qualityScore: 5,
|
||||||
|
spamRisk: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = await getConfiguredModel()
|
||||||
|
|
||||||
|
// Anonymize project data — use numeric IDs
|
||||||
|
const anonymizedProjects = projects.map((p, i) => ({
|
||||||
|
project_id: `P${i + 1}`,
|
||||||
|
real_id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
description: p.description?.slice(0, 500) || '',
|
||||||
|
category: p.competitionCategory || 'Unknown',
|
||||||
|
ocean_issue: p.oceanIssue || 'Unknown',
|
||||||
|
country: p.country || 'Unknown',
|
||||||
|
tags: p.tags.join(', '),
|
||||||
|
has_files: (p.files?.length || 0) > 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Process in batches of 20
|
||||||
|
const batchSize = 20
|
||||||
|
for (let i = 0; i < anonymizedProjects.length; i += batchSize) {
|
||||||
|
const batch = anonymizedProjects.slice(i, i + batchSize)
|
||||||
|
|
||||||
|
const userPrompt = `Evaluate these projects against the following criteria:
|
||||||
|
|
||||||
|
CRITERIA: ${config.criteriaText}
|
||||||
|
|
||||||
|
PROJECTS:
|
||||||
|
${JSON.stringify(
|
||||||
|
batch.map(({ real_id, ...rest }) => rest),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}
|
||||||
|
|
||||||
|
Return your evaluation as JSON.`
|
||||||
|
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: AI_SCREENING_SYSTEM_PROMPT },
|
||||||
|
{ role: 'user', content: userPrompt },
|
||||||
|
],
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 4000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = response.choices[0]?.message?.content
|
||||||
|
if (content) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content) as {
|
||||||
|
projects: Array<{
|
||||||
|
project_id: string
|
||||||
|
meets_criteria: boolean
|
||||||
|
confidence: number
|
||||||
|
reasoning: string
|
||||||
|
quality_score: number
|
||||||
|
spam_risk: boolean
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const result of parsed.projects) {
|
||||||
|
const anon = batch.find((b) => b.project_id === result.project_id)
|
||||||
|
if (anon) {
|
||||||
|
results.set(anon.real_id, {
|
||||||
|
meetsCriteria: result.meets_criteria,
|
||||||
|
confidence: result.confidence,
|
||||||
|
reasoning: result.reasoning,
|
||||||
|
qualityScore: result.quality_score,
|
||||||
|
spamRisk: result.spam_risk,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Parse error — flag batch for manual review
|
||||||
|
for (const item of batch) {
|
||||||
|
results.set(item.real_id, {
|
||||||
|
meetsCriteria: false,
|
||||||
|
confidence: 0,
|
||||||
|
reasoning: 'AI response parse error — flagged for manual review',
|
||||||
|
qualityScore: 5,
|
||||||
|
spamRisk: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// OpenAI error — flag all for manual review
|
||||||
|
for (const p of projects) {
|
||||||
|
results.set(p.id, {
|
||||||
|
meetsCriteria: false,
|
||||||
|
confidence: 0,
|
||||||
|
reasoning: 'AI screening error — flagged for manual review',
|
||||||
|
qualityScore: 5,
|
||||||
|
spamRisk: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Execution ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function executeFilteringRules(
|
||||||
|
rules: FilteringRuleInput[],
|
||||||
|
projects: ProjectForFiltering[]
|
||||||
|
): Promise<ProjectFilteringResult[]> {
|
||||||
|
const activeRules = rules
|
||||||
|
.filter((r) => r.isActive)
|
||||||
|
.sort((a, b) => a.priority - b.priority)
|
||||||
|
|
||||||
|
// Separate AI screening rules (need batch processing)
|
||||||
|
const aiRules = activeRules.filter((r) => r.ruleType === 'AI_SCREENING')
|
||||||
|
const nonAiRules = activeRules.filter((r) => r.ruleType !== 'AI_SCREENING')
|
||||||
|
|
||||||
|
// Pre-compute AI screening results if needed
|
||||||
|
const aiResults = new Map<
|
||||||
|
string,
|
||||||
|
Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
meetsCriteria: boolean
|
||||||
|
confidence: number
|
||||||
|
reasoning: string
|
||||||
|
qualityScore: number
|
||||||
|
spamRisk: boolean
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>()
|
||||||
|
|
||||||
|
for (const aiRule of aiRules) {
|
||||||
|
const config = aiRule.configJson as unknown as AIScreeningConfig
|
||||||
|
const screeningResults = await executeAIScreening(config, projects)
|
||||||
|
aiResults.set(aiRule.id, screeningResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate each project
|
||||||
|
const results: ProjectFilteringResult[] = []
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
const ruleResults: RuleResult[] = []
|
||||||
|
let hasFailed = false
|
||||||
|
let hasFlagged = false
|
||||||
|
|
||||||
|
// Evaluate non-AI rules
|
||||||
|
for (const rule of nonAiRules) {
|
||||||
|
let result: { passed: boolean; action: 'PASS' | 'REJECT' | 'FLAG' }
|
||||||
|
|
||||||
|
if (rule.ruleType === 'FIELD_BASED') {
|
||||||
|
const config = rule.configJson as unknown as FieldRuleConfig
|
||||||
|
result = evaluateFieldRule(config, project)
|
||||||
|
} else if (rule.ruleType === 'DOCUMENT_CHECK') {
|
||||||
|
const config = rule.configJson as unknown as DocumentCheckConfig
|
||||||
|
result = evaluateDocumentRule(config, project)
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleResults.push({
|
||||||
|
ruleId: rule.id,
|
||||||
|
ruleName: rule.name,
|
||||||
|
ruleType: rule.ruleType,
|
||||||
|
passed: result.passed,
|
||||||
|
action: result.action,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.passed) {
|
||||||
|
if (result.action === 'REJECT') hasFailed = true
|
||||||
|
if (result.action === 'FLAG') hasFlagged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate AI rules
|
||||||
|
for (const aiRule of aiRules) {
|
||||||
|
const ruleScreening = aiResults.get(aiRule.id)
|
||||||
|
const screening = ruleScreening?.get(project.id)
|
||||||
|
|
||||||
|
if (screening) {
|
||||||
|
const passed = screening.meetsCriteria && !screening.spamRisk
|
||||||
|
ruleResults.push({
|
||||||
|
ruleId: aiRule.id,
|
||||||
|
ruleName: aiRule.name,
|
||||||
|
ruleType: 'AI_SCREENING',
|
||||||
|
passed,
|
||||||
|
action: 'FLAG',
|
||||||
|
reasoning: screening.reasoning,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!passed) hasFlagged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine overall outcome
|
||||||
|
let outcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED'
|
||||||
|
if (hasFailed) {
|
||||||
|
outcome = 'FILTERED_OUT'
|
||||||
|
} else if (hasFlagged) {
|
||||||
|
outcome = 'FLAGGED'
|
||||||
|
} else {
|
||||||
|
outcome = 'PASSED'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect AI screening data
|
||||||
|
const aiScreeningData: Record<string, unknown> = {}
|
||||||
|
for (const aiRule of aiRules) {
|
||||||
|
const screening = aiResults.get(aiRule.id)?.get(project.id)
|
||||||
|
if (screening) {
|
||||||
|
aiScreeningData[aiRule.id] = screening
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
projectId: project.id,
|
||||||
|
outcome,
|
||||||
|
ruleResults,
|
||||||
|
aiScreeningJson:
|
||||||
|
Object.keys(aiScreeningData).length > 0 ? aiScreeningData : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared utility for creating audit log entries.
|
||||||
|
* Wrapped in try-catch so audit failures never break the calling operation.
|
||||||
|
*/
|
||||||
|
export async function logAudit(input: {
|
||||||
|
userId?: string | null
|
||||||
|
action: string
|
||||||
|
entityType: string
|
||||||
|
entityId?: string
|
||||||
|
detailsJson?: Record<string, unknown>
|
||||||
|
ipAddress?: string
|
||||||
|
userAgent?: string
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: input.userId ?? null,
|
||||||
|
action: input.action,
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: input.entityId,
|
||||||
|
detailsJson: input.detailsJson as Prisma.InputJsonValue ?? undefined,
|
||||||
|
ipAddress: input.ipAddress,
|
||||||
|
userAgent: input.userAgent,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// Never break the calling operation on audit failure
|
||||||
|
console.error('[Audit] Failed to create audit log entry:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue