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

- 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:
Matt 2026-02-02 16:58:29 +01:00
parent 8fda8deded
commit 90e3adfab2
44 changed files with 7268 additions and 2154 deletions

View File

@ -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;

View File

@ -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"

View File

@ -259,6 +259,16 @@ model User {
teamMemberships TeamMember[]
mentorAssignments MentorAssignment[] @relation("MentorAssignments")
// Awards
awardJurorships AwardJuror[]
awardVotes AwardVote[]
// Filtering overrides
filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy")
// Award overrides
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
// NextAuth relations
accounts Account[]
sessions Session[]
@ -328,6 +338,7 @@ model Program {
learningResources LearningResource[]
partners Partner[]
applicationForms ApplicationForm[]
specialAwards SpecialAward[]
@@unique([name, year])
@@index([status])
@ -369,6 +380,8 @@ model Round {
evaluationForms EvaluationForm[]
gracePeriods GracePeriod[]
liveVotingSession LiveVotingSession?
filteringRules FilteringRule[]
filteringResults FilteringResult[]
@@index([programId])
@@index([status])
@ -428,6 +441,9 @@ model Project {
// Mentorship
wantsMentorship Boolean @default(false)
// Founding date
foundedAt DateTime? // When the project/company was founded
// Submission links (external, from CSV)
phase1SubmissionUrl String?
phase2SubmissionUrl String?
@ -464,6 +480,10 @@ model Project {
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
teamMembers TeamMember[]
mentorAssignment MentorAssignment?
filteringResults FilteringResult[]
awardEligibilities AwardEligibility[]
awardVotes AwardVote[]
wonAwards SpecialAward[] @relation("AwardWinner")
@@index([roundId])
@@index([status])
@ -975,3 +995,194 @@ model MentorAssignment {
@@index([mentorId])
@@index([method])
}
// =============================================================================
// FILTERING ROUND SYSTEM
// =============================================================================
enum FilteringOutcome {
PASSED
FILTERED_OUT
FLAGGED
}
enum FilteringRuleType {
FIELD_BASED
DOCUMENT_CHECK
AI_SCREENING
}
model FilteringRule {
id String @id @default(cuid())
roundId String
name String
ruleType FilteringRuleType
configJson Json @db.JsonB // Conditions, logic, action per rule type
priority Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
@@index([roundId])
@@index([priority])
}
model FilteringResult {
id String @id @default(cuid())
roundId String
projectId String
outcome FilteringOutcome
ruleResultsJson Json? @db.JsonB // Per-rule results
aiScreeningJson Json? @db.JsonB // AI screening details
// Admin override
overriddenBy String?
overriddenAt DateTime?
overrideReason String? @db.Text
finalOutcome FilteringOutcome?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
overriddenByUser User? @relation("FilteringOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull)
@@unique([roundId, projectId])
@@index([roundId])
@@index([projectId])
@@index([outcome])
}
// =============================================================================
// SPECIAL AWARDS SYSTEM
// =============================================================================
enum AwardScoringMode {
PICK_WINNER
RANKED
SCORED
}
enum AwardStatus {
DRAFT
NOMINATIONS_OPEN
VOTING_OPEN
CLOSED
ARCHIVED
}
enum EligibilityMethod {
AUTO
MANUAL
}
model SpecialAward {
id String @id @default(cuid())
programId String
name String
description String? @db.Text
status AwardStatus @default(DRAFT)
// Criteria
criteriaText String? @db.Text // Plain-language criteria for AI
autoTagRulesJson Json? @db.JsonB // Deterministic eligibility rules
// Scoring
scoringMode AwardScoringMode @default(PICK_WINNER)
maxRankedPicks Int? // For RANKED mode
// Voting window
votingStartAt DateTime?
votingEndAt DateTime?
// Evaluation form (for SCORED mode)
evaluationFormId String?
// Winner
winnerProjectId String?
winnerOverridden Boolean @default(false)
winnerOverriddenBy String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
winnerProject Project? @relation("AwardWinner", fields: [winnerProjectId], references: [id], onDelete: SetNull)
eligibilities AwardEligibility[]
jurors AwardJuror[]
votes AwardVote[]
@@index([programId])
@@index([status])
@@index([sortOrder])
}
model AwardEligibility {
id String @id @default(cuid())
awardId String
projectId String
method EligibilityMethod @default(AUTO)
eligible Boolean @default(false)
aiReasoningJson Json? @db.JsonB
// Admin override
overriddenBy String?
overriddenAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
overriddenByUser User? @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull)
@@unique([awardId, projectId])
@@index([awardId])
@@index([projectId])
@@index([eligible])
}
model AwardJuror {
id String @id @default(cuid())
awardId String
userId String
createdAt DateTime @default(now())
// Relations
award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([awardId, userId])
@@index([awardId])
@@index([userId])
}
model AwardVote {
id String @id @default(cuid())
awardId String
userId String
projectId String
rank Int? // For RANKED mode
votedAt DateTime @default(now())
// Relations
award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([awardId, userId, projectId])
@@index([awardId])
@@index([userId])
@@index([projectId])
}

View File

@ -60,13 +60,24 @@ const ACTION_TYPES = [
'IMPORT',
'EXPORT',
'LOGIN',
'LOGIN_SUCCESS',
'LOGIN_FAILED',
'INVITATION_ACCEPTED',
'SUBMIT_EVALUATION',
'EVALUATION_SUBMITTED',
'UPDATE_STATUS',
'ROUND_ACTIVATED',
'ROUND_CLOSED',
'ROUND_ARCHIVED',
'UPLOAD_FILE',
'DELETE_FILE',
'FILE_DOWNLOADED',
'BULK_CREATE',
'BULK_UPDATE_STATUS',
'UPDATE_EVALUATION_FORM',
'ROLE_CHANGED',
'PASSWORD_SET',
'PASSWORD_CHANGED',
]
// Entity type options
@ -90,7 +101,18 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
IMPORT: 'default',
EXPORT: 'outline',
LOGIN: 'outline',
LOGIN_SUCCESS: 'outline',
LOGIN_FAILED: 'destructive',
INVITATION_ACCEPTED: '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() {

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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&apos;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&apos;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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,7 @@
import { MembersContent } from '@/components/admin/members-content'
export const dynamic = 'force-dynamic'
export default function MembersPage() {
return <MembersContent />
}

View File

@ -1,389 +1,10 @@
'use client'
import { redirect } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
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 {
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&apos;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&apos;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&apos;t accepted their invitation yet. You can resend
the invitation email using the button above.
</AlertDescription>
</Alert>
)}
</div>
)
export default async function MentorDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
redirect(`/admin/members/${id}`)
}

View File

@ -1,252 +1,5 @@
import { Suspense } from 'react'
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>
)
}
import { redirect } from 'next/navigation'
export default function MentorsPage() {
return (
<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>
)
redirect('/admin/members')
}

View File

@ -69,6 +69,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
)
}
try {
const edition = await prisma.program.findUnique({
where: { id: editionId },
select: { name: true, year: true },
@ -651,6 +652,20 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
<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() {

View File

@ -267,6 +267,15 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</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>
{/* Submission URLs */}

View File

@ -1,8 +1,9 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { prisma } from '@/lib/prisma'
'use client'
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 {
Card,
CardContent,
@ -12,6 +13,7 @@ import {
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
@ -35,236 +37,182 @@ import {
Pencil,
FileUp,
Users,
Search,
} from 'lucide-react'
import { formatDateOnly, truncate } from '@/lib/utils'
import { truncate } from '@/lib/utils'
import { ProjectLogo } from '@/components/shared/project-logo'
import { Pagination } from '@/components/shared/pagination'
import {
ProjectFiltersBar,
type ProjectFilters,
} from './project-filters'
async function ProjectsContent() {
const projects = await prisma.project.findMany({
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
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'> = {
const statusColors: Record<
string,
'default' | 'success' | 'secondary' | 'destructive' | 'warning'
> = {
SUBMITTED: 'secondary',
UNDER_REVIEW: 'default',
SHORTLISTED: 'success',
ELIGIBLE: 'default',
ASSIGNED: 'default',
SEMIFINALIST: 'success',
FINALIST: 'success',
WINNER: 'success',
REJECTED: 'destructive',
WITHDRAWN: 'secondary',
}
function parseFiltersFromParams(
searchParams: URLSearchParams
): ProjectFilters & { page: number } {
return {
search: searchParams.get('q') || '',
statuses: searchParams.get('status')
? searchParams.get('status')!.split(',')
: [],
roundId: searchParams.get('round') || '',
competitionCategory: searchParams.get('category') || '',
oceanIssue: searchParams.get('issue') || '',
country: searchParams.get('country') || '',
wantsMentorship:
searchParams.get('mentorship') === 'true'
? true
: searchParams.get('mentorship') === 'false'
? false
: undefined,
hasFiles:
searchParams.get('hasFiles') === 'true'
? true
: searchParams.get('hasFiles') === 'false'
? false
: undefined,
hasAssignments:
searchParams.get('hasAssign') === 'true'
? true
: searchParams.get('hasAssign') === 'false'
? false
: undefined,
page: parseInt(searchParams.get('page') || '1', 10),
}
return (
<>
{/* Desktop table view */}
<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>
{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._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() {
return (
<Card>
<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>
)
function filtersToParams(
filters: ProjectFilters & { page: number }
): URLSearchParams {
const params = new URLSearchParams()
if (filters.search) params.set('q', filters.search)
if (filters.statuses.length > 0)
params.set('status', filters.statuses.join(','))
if (filters.roundId) params.set('round', filters.roundId)
if (filters.competitionCategory)
params.set('category', filters.competitionCategory)
if (filters.oceanIssue) params.set('issue', filters.oceanIssue)
if (filters.country) params.set('country', filters.country)
if (filters.wantsMentorship !== undefined)
params.set('mentorship', String(filters.wantsMentorship))
if (filters.hasFiles !== undefined)
params.set('hasFiles', String(filters.hasFiles))
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() {
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 (
<div className="space-y-6">
{/* Header */}
@ -291,10 +239,234 @@ export default function ProjectsPage() {
</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 */}
<Suspense fallback={<ProjectsSkeleton />}>
<ProjectsContent />
</Suspense>
{isLoading ? (
<Card>
<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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -30,6 +30,7 @@ import {
Pause,
BarChart3,
Upload,
Filter,
} from 'lucide-react'
import { format, formatDistanceToNow, isPast, isFuture } from 'date-fns'
@ -344,6 +345,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
Import Projects
</Link>
</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>
<Link href={`/admin/rounds/${round.id}/assignments`}>
<Users className="mr-2 h-4 w-4" />

View File

@ -1,316 +1,10 @@
'use client'
import { redirect } from 'next/navigation'
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 {
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&apos;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&apos;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&apos;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>
)
export default async function UserEditPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
redirect(`/admin/members/${id}`)
}

View File

@ -1,676 +1,5 @@
'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,
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@]+$/
import { redirect } from 'next/navigation'
export default function UserInvitePage() {
const router = useRouter()
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 &quot;email&quot; column, optionally a &quot;name&quot; 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>
)
redirect('/admin/members/invite')
}

View File

@ -1,281 +1,5 @@
import { Suspense } from 'react'
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>
)
}
import { redirect } from 'next/navigation'
export default function UsersPage() {
return (
<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>
)
redirect('/admin/members')
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -88,7 +88,7 @@ export function UserActions({ userId, userEmail, userStatus }: UserActionsProps)
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/users/${userId}`}>
<Link href={`/admin/members/${userId}`}>
<UserCog className="mr-2 h-4 w-4" />
Edit
</Link>
@ -172,7 +172,7 @@ export function UserMobileActions({
return (
<div className="flex gap-2 pt-2">
<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" />
Edit
</Link>

View File

@ -51,6 +51,7 @@ const PROJECT_FIELDS = [
{ key: 'teamName', label: 'Team Name', required: false },
{ key: 'description', label: 'Description', required: false },
{ key: 'tags', label: 'Tags (comma-separated)', required: false },
{ key: 'foundedAt', label: 'Founded Date', required: false },
]
interface ParsedRow {

View File

@ -30,8 +30,8 @@ import {
Handshake,
FileText,
CircleDot,
GraduationCap,
History,
Trophy,
User,
} from 'lucide-react'
import { getInitials } from '@/lib/utils'
@ -60,21 +60,21 @@ const navigation = [
href: '/admin/rounds' as const,
icon: CircleDot,
},
{
name: 'Awards',
href: '/admin/awards' as const,
icon: Trophy,
},
{
name: 'Projects',
href: '/admin/projects' as const,
icon: ClipboardList,
},
{
name: 'Jury Members',
href: '/admin/users' as const,
name: 'Members',
href: '/admin/members' as const,
icon: Users,
},
{
name: 'Mentors',
href: '/admin/mentors' as const,
icon: GraduationCap,
},
{
name: 'Reports',
href: '/admin/reports' as const,

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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 {
id: user.id,
email: user.email,
@ -124,6 +135,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
current.count = 0
}
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
}
@ -138,6 +160,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
current.count = 0
}
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
}
@ -228,6 +262,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
}).catch(() => {
// 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

View File

@ -27,6 +27,8 @@ import { logoRouter } from './logo'
// Applicant system routers
import { applicationRouter } from './application'
import { mentorRouter } from './mentor'
import { filteringRouter } from './filtering'
import { specialAwardRouter } from './specialAward'
/**
* Root tRPC router that combines all domain routers
@ -60,6 +62,8 @@ export const appRouter = router({
// Applicant system routers
application: applicationRouter,
mentor: mentorRouter,
filtering: filteringRouter,
specialAward: specialAwardRouter,
})
export type AppRouter = typeof appRouter

View File

@ -25,7 +25,7 @@ export const auditRouter = router({
const where: Record<string, unknown> = {}
if (userId) where.userId = userId
if (action) where.action = { contains: action, mode: 'insensitive' }
if (action) where.action = action
if (entityType) where.entityType = entityType
if (entityId) where.entityId = entityId
if (startDate || endDate) {

View File

@ -216,9 +216,15 @@ export const evaluationRouter = router({
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'SUBMIT_EVALUATION',
action: 'EVALUATION_SUBMITTED',
entityType: 'Evaluation',
entityId: id,
detailsJson: {
projectId: evaluation.assignment.projectId,
roundId: evaluation.assignment.roundId,
globalScore: data.globalScore,
binaryDecision: data.binaryDecision,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},

View File

@ -53,6 +53,19 @@ export const fileRouter = router({
}
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 }
}),

View File

@ -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 }
}),
})

View File

@ -12,7 +12,7 @@ export const projectRouter = router({
list: protectedProcedure
.input(
z.object({
roundId: z.string(),
roundId: z.string().optional(),
status: z
.enum([
'SUBMITTED',
@ -23,23 +23,63 @@ export const projectRouter = router({
'REJECTED',
])
.optional(),
statuses: z.array(
z.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
])
).optional(),
search: 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),
perPage: z.number().int().min(1).max(100).default(20),
})
)
.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
// 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) {
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) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
@ -50,7 +90,9 @@ export const projectRouter = router({
// Jury members can only see assigned projects
if (ctx.user.role === 'JURY_MEMBER') {
// If hasAssignments filter is already set, combine with jury filter
where.assignments = {
...((where.assignments as Record<string, unknown>) || {}),
some: { userId: ctx.user.id },
}
}
@ -63,6 +105,9 @@ export const projectRouter = router({
orderBy: { createdAt: 'desc' },
include: {
files: true,
round: {
select: { id: true, name: true, program: { select: { name: 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
*/

View File

@ -165,19 +165,33 @@ export const roundRouter = router({
})
)
.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({
where: { id: input.id },
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
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE_STATUS',
action,
entityType: 'Round',
entityId: input.id,
detailsJson: { status: input.status },
detailsJson: { status: input.status, previousStatus: previousRound.status },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},

View File

@ -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
}),
})

View File

@ -170,6 +170,7 @@ export const userRouter = router({
.input(
z.object({
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(),
search: z.string().optional(),
page: z.number().int().min(1).default(1),
@ -177,12 +178,16 @@ export const userRouter = router({
})
)
.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 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 (search) {
where.OR = [
@ -210,7 +215,7 @@ export const userRouter = router({
createdAt: true,
lastLoginAt: true,
_count: {
select: { assignments: true },
select: { assignments: true, mentorAssignments: true },
},
},
}),
@ -238,7 +243,7 @@ export const userRouter = router({
where: { id: input.id },
include: {
_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
}),
@ -816,7 +836,7 @@ export const userRouter = router({
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'SET_PASSWORD',
action: 'PASSWORD_SET',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: { timestamp: new Date().toISOString() },
@ -896,7 +916,7 @@ export const userRouter = router({
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'CHANGE_PASSWORD',
action: 'PASSWORD_CHANGED',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: { timestamp: new Date().toISOString() },

View File

@ -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
}

View File

@ -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
}

33
src/server/utils/audit.ts Normal file
View File

@ -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)
}
}