diff --git a/prisma/check-data.ts b/prisma/check-data.ts index 4d8f3a9..2e2984d 100644 --- a/prisma/check-data.ts +++ b/prisma/check-data.ts @@ -8,23 +8,23 @@ async function check() { const rounds = await prisma.round.findMany({ include: { - _count: { select: { projects: true } } + _count: { select: { roundProjects: true } } } }) for (const r of rounds) { console.log(`Round: ${r.name} (id: ${r.id})`) - console.log(` Projects: ${r._count.projects}`) + console.log(` Projects: ${r._count.roundProjects}`) } - // Check if projects have roundId set - const projectsWithRound = await prisma.project.findMany({ - select: { id: true, title: true, roundId: true }, + // Check if projects have programId set + const sampleProjects = await prisma.project.findMany({ + select: { id: true, title: true, programId: true }, take: 5 }) console.log('\nSample projects:') - for (const p of projectsWithRound) { - console.log(` ${p.title}: roundId=${p.roundId}`) + for (const p of sampleProjects) { + console.log(` ${p.title}: programId=${p.programId}`) } } diff --git a/prisma/cleanup-all-dummy.ts b/prisma/cleanup-all-dummy.ts index 7e61869..8763564 100644 --- a/prisma/cleanup-all-dummy.ts +++ b/prisma/cleanup-all-dummy.ts @@ -10,18 +10,18 @@ async function cleanup() { id: true, name: true, slug: true, - projects: { select: { id: true, title: true } }, - _count: { select: { projects: true } } + roundProjects: { select: { id: true, projectId: true, project: { select: { id: true, title: true } } } }, + _count: { select: { roundProjects: true } } } }) console.log(`Found ${rounds.length} rounds:`) for (const round of rounds) { - console.log(`- ${round.name} (slug: ${round.slug}): ${round._count.projects} projects`) + console.log(`- ${round.name} (slug: ${round.slug}): ${round._count.roundProjects} projects`) } // Find rounds with 9 or fewer projects (dummy data) - const dummyRounds = rounds.filter(r => r._count.projects <= 9) + const dummyRounds = rounds.filter(r => r._count.roundProjects <= 9) if (dummyRounds.length > 0) { console.log(`\nDeleting ${dummyRounds.length} dummy round(s)...`) @@ -29,10 +29,16 @@ async function cleanup() { for (const round of dummyRounds) { console.log(`\nProcessing: ${round.name}`) - const projectIds = round.projects.map(p => p.id) + const projectIds = round.roundProjects.map(rp => rp.projectId) if (projectIds.length > 0) { - // Delete team members first + // Delete round-project associations first + const rpDeleted = await prisma.roundProject.deleteMany({ + where: { roundId: round.id } + }) + console.log(` Deleted ${rpDeleted.count} round-project associations`) + + // Delete team members const teamDeleted = await prisma.teamMember.deleteMany({ where: { projectId: { in: projectIds } } }) diff --git a/prisma/cleanup-dummy.ts b/prisma/cleanup-dummy.ts index f91aee8..e6d049b 100644 --- a/prisma/cleanup-dummy.ts +++ b/prisma/cleanup-dummy.ts @@ -8,15 +8,15 @@ async function cleanup() { // Find and delete the dummy round const dummyRound = await prisma.round.findFirst({ where: { slug: 'round-1-2026' }, - include: { projects: true } + include: { roundProjects: { include: { project: true } } } }) if (dummyRound) { console.log(`Found dummy round: ${dummyRound.name}`) - console.log(`Projects in round: ${dummyRound.projects.length}`) + console.log(`Projects in round: ${dummyRound.roundProjects.length}`) // Get project IDs to delete - const projectIds = dummyRound.projects.map(p => p.id) + const projectIds = dummyRound.roundProjects.map(rp => rp.projectId) // Delete team members for these projects if (projectIds.length > 0) { @@ -25,11 +25,11 @@ async function cleanup() { }) console.log(`Deleted ${teamDeleted.count} team members`) - // Disconnect projects from round first - await prisma.round.update({ - where: { id: dummyRound.id }, - data: { projects: { disconnect: projectIds.map(id => ({ id })) } } + // Delete round-project associations + await prisma.roundProject.deleteMany({ + where: { roundId: dummyRound.id } }) + console.log(`Deleted round-project associations`) // Delete the projects const projDeleted = await prisma.project.deleteMany({ diff --git a/prisma/migrations/20260202120000_decouple_projects_from_rounds/migration.sql b/prisma/migrations/20260202120000_decouple_projects_from_rounds/migration.sql new file mode 100644 index 0000000..1ee0729 --- /dev/null +++ b/prisma/migrations/20260202120000_decouple_projects_from_rounds/migration.sql @@ -0,0 +1,69 @@ +-- Step 1: Add sortOrder to Round +ALTER TABLE "Round" ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0; + +-- Set initial sort order by creation date within each program +UPDATE "Round" r SET "sortOrder" = sub.rn - 1 +FROM ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY "programId" ORDER BY "createdAt") as rn + FROM "Round" +) sub +WHERE r.id = sub.id; + +-- Step 2: Add programId to Project (nullable initially) +ALTER TABLE "Project" ADD COLUMN "programId" TEXT; + +-- Populate programId from the round's program +UPDATE "Project" p SET "programId" = r."programId" +FROM "Round" r WHERE p."roundId" = r."id"; + +-- Make programId required +ALTER TABLE "Project" ALTER COLUMN "programId" SET NOT NULL; + +-- Add foreign key constraint +ALTER TABLE "Project" ADD CONSTRAINT "Project_programId_fkey" + FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Step 3: Create RoundProject table +CREATE TABLE "RoundProject" ( + "id" TEXT NOT NULL, + "roundId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "status" "ProjectStatus" NOT NULL DEFAULT 'SUBMITTED', + "addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RoundProject_pkey" PRIMARY KEY ("id") +); + +-- Populate RoundProject from existing Project.roundId and status +INSERT INTO "RoundProject" ("id", "roundId", "projectId", "status", "addedAt") +SELECT gen_random_uuid(), p."roundId", p."id", p."status", p."createdAt" +FROM "Project" p; + +-- Add indexes and unique constraint +CREATE UNIQUE INDEX "RoundProject_roundId_projectId_key" ON "RoundProject"("roundId", "projectId"); +CREATE INDEX "RoundProject_roundId_idx" ON "RoundProject"("roundId"); +CREATE INDEX "RoundProject_projectId_idx" ON "RoundProject"("projectId"); +CREATE INDEX "RoundProject_status_idx" ON "RoundProject"("status"); + +-- Add foreign keys +ALTER TABLE "RoundProject" ADD CONSTRAINT "RoundProject_roundId_fkey" + FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "RoundProject" ADD CONSTRAINT "RoundProject_projectId_fkey" + FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Step 4: Drop old columns from Project +-- Drop the roundId foreign key constraint first +ALTER TABLE "Project" DROP CONSTRAINT "Project_roundId_fkey"; + +-- Drop the roundId index +DROP INDEX IF EXISTS "Project_roundId_idx"; + +-- Drop status index +DROP INDEX IF EXISTS "Project_status_idx"; + +-- Drop the columns +ALTER TABLE "Project" DROP COLUMN "roundId"; +ALTER TABLE "Project" DROP COLUMN "status"; + +-- Add programId index +CREATE INDEX "Project_programId_idx" ON "Project"("programId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5708552..bb847bd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -335,6 +335,7 @@ model Program { // Relations rounds Round[] + projects Project[] learningResources LearningResource[] partners Partner[] applicationForms ApplicationForm[] @@ -351,6 +352,7 @@ model Round { slug String? @unique // URL-friendly identifier for public submissions status RoundStatus @default(DRAFT) roundType RoundType @default(EVALUATION) + sortOrder Int @default(0) // Progression order within program // Submission window (for applicant portal) submissionDeadline DateTime? // Deadline for project submissions @@ -375,7 +377,7 @@ model Round { // Relations program Program @relation(fields: [programId], references: [id], onDelete: Cascade) - projects Project[] + roundProjects RoundProject[] assignments Assignment[] evaluationForms EvaluationForm[] gracePeriods GracePeriod[] @@ -419,13 +421,12 @@ model EvaluationForm { model Project { id String @id @default(cuid()) - roundId String + programId String // Core fields title String teamName String? description String? @db.Text - status ProjectStatus @default(SUBMITTED) // Competition category competitionCategory CompetitionCategory? @@ -474,7 +475,8 @@ model Project { updatedAt DateTime @updatedAt // Relations - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + program Program @relation(fields: [programId], references: [id], onDelete: Cascade) + roundProjects RoundProject[] files ProjectFile[] assignments Assignment[] submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull) @@ -485,8 +487,7 @@ model Project { awardVotes AwardVote[] wonAwards SpecialAward[] @relation("AwardWinner") - @@index([roundId]) - @@index([status]) + @@index([programId]) @@index([tags]) @@index([submissionSource]) @@index([submittedByUserId]) @@ -495,6 +496,23 @@ model Project { @@index([country]) } +model RoundProject { + id String @id @default(cuid()) + roundId String + projectId String + status ProjectStatus @default(SUBMITTED) + addedAt DateTime @default(now()) + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([roundId, projectId]) + @@index([roundId]) + @@index([projectId]) + @@index([status]) +} + model ProjectFile { id String @id @default(cuid()) projectId String diff --git a/prisma/seed-candidatures.ts b/prisma/seed-candidatures.ts index 3240805..2397481 100644 --- a/prisma/seed-candidatures.ts +++ b/prisma/seed-candidatures.ts @@ -321,7 +321,7 @@ async function main() { // Check if project already exists const existingProject = await prisma.project.findFirst({ where: { - roundId: round.id, + programId: program.id, OR: [ { title: projectName }, { submittedByEmail: email }, @@ -365,10 +365,9 @@ async function main() { // Create project const project = await prisma.project.create({ data: { - roundId: round.id, + programId: program.id, title: projectName, description: row['Comment ']?.trim() || null, - status: 'SUBMITTED', competitionCategory: mapCategory(row['Category']), oceanIssue: mapOceanIssue(row['Issue']), country: extractCountry(row['Country']), @@ -392,6 +391,15 @@ async function main() { }, }) + // Create round-project association + await prisma.roundProject.create({ + data: { + roundId: round.id, + projectId: project.id, + status: 'SUBMITTED', + }, + }) + // Create team lead membership await prisma.teamMember.create({ data: { @@ -466,7 +474,7 @@ async function main() { console.log('\nBackfilling missing country codes...\n') let backfilled = 0 const nullCountryProjects = await prisma.project.findMany({ - where: { roundId: round.id, country: null }, + where: { programId: program.id, country: null }, select: { id: true, submittedByEmail: true, title: true }, }) diff --git a/prisma/seed-jury-demo.ts b/prisma/seed-jury-demo.ts index 4a297db..b3fe7fc 100644 --- a/prisma/seed-jury-demo.ts +++ b/prisma/seed-jury-demo.ts @@ -64,13 +64,14 @@ async function main() { console.log(`Voting window: ${votingStart.toISOString()} → ${votingEnd.toISOString()}\n`) - // Get some projects to assign - const projects = await prisma.project.findMany({ + // Get some projects to assign (via RoundProject) + const roundProjects = await prisma.roundProject.findMany({ where: { roundId: round.id }, take: 8, - orderBy: { createdAt: 'desc' }, - select: { id: true, title: true }, + orderBy: { addedAt: 'desc' }, + select: { project: { select: { id: true, title: true } } }, }) + const projects = roundProjects.map(rp => rp.project) if (projects.length === 0) { console.error('No projects found! Run seed-candidatures first.') diff --git a/src/app/(admin)/admin/members/[id]/page.tsx b/src/app/(admin)/admin/members/[id]/page.tsx index a6a1a38..418a109 100644 --- a/src/app/(admin)/admin/members/[id]/page.tsx +++ b/src/app/(admin)/admin/members/[id]/page.tsx @@ -329,7 +329,7 @@ export default function MemberDetailPage() { - {assignment.project.status} + {assignment.project.roundProjects?.[0]?.status ?? 'SUBMITTED'} diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx index e1c8037..1eb11ac 100644 --- a/src/app/(admin)/admin/page.tsx +++ b/src/app/(admin)/admin/page.tsx @@ -112,11 +112,11 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) { where: { programId: editionId }, }), prisma.project.count({ - where: { round: { programId: editionId } }, + where: { programId: editionId }, }), prisma.project.count({ where: { - round: { programId: editionId }, + programId: editionId, createdAt: { gte: sevenDaysAgo }, }, }), @@ -149,7 +149,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) { include: { _count: { select: { - projects: true, + roundProjects: true, assignments: true, }, }, @@ -161,31 +161,33 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) { }, }), prisma.project.findMany({ - where: { round: { programId: editionId } }, + where: { programId: editionId }, orderBy: { createdAt: 'desc' }, take: 8, select: { id: true, title: true, teamName: true, - status: true, country: true, competitionCategory: true, oceanIssue: true, logoKey: true, createdAt: true, submittedAt: true, - round: { select: { name: true } }, + roundProjects: { + select: { status: true, round: { select: { name: true } } }, + take: 1, + }, }, }), prisma.project.groupBy({ by: ['competitionCategory'], - where: { round: { programId: editionId } }, + where: { programId: editionId }, _count: true, }), prisma.project.groupBy({ by: ['oceanIssue'], - where: { round: { programId: editionId } }, + where: { programId: editionId }, _count: true, }), ]) @@ -392,7 +394,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {

- {round._count.projects} projects · {round._count.assignments} assignments + {round._count.roundProjects} projects · {round._count.assignments} assignments {round.totalEvals > 0 && ( <> · {round.evalPercent}% evaluated )} @@ -459,10 +461,10 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) { {truncate(project.title, 45)}

- {project.status.replace('_', ' ')} + {(project.roundProjects[0]?.status ?? 'SUBMITTED').replace('_', ' ')}

diff --git a/src/app/(admin)/admin/programs/[id]/page.tsx b/src/app/(admin)/admin/programs/[id]/page.tsx index 31e1994..473e7c1 100644 --- a/src/app/(admin)/admin/programs/[id]/page.tsx +++ b/src/app/(admin)/admin/programs/[id]/page.tsx @@ -138,7 +138,7 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro {round.status} - {round._count.projects} + {round._count.roundProjects} {round._count.assignments} {formatDateOnly(round.createdAt)} diff --git a/src/app/(admin)/admin/projects/[id]/assignments/page.tsx b/src/app/(admin)/admin/projects/[id]/assignments/page.tsx index 026e479..7e6d4c6 100644 --- a/src/app/(admin)/admin/projects/[id]/assignments/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/assignments/page.tsx @@ -96,7 +96,7 @@ export default function ProjectAssignmentsPage() { diff --git a/src/app/(admin)/admin/projects/import/page.tsx b/src/app/(admin)/admin/projects/import/page.tsx index a1465d2..d067210 100644 --- a/src/app/(admin)/admin/projects/import/page.tsx +++ b/src/app/(admin)/admin/projects/import/page.tsx @@ -43,6 +43,7 @@ function ImportPageContent() { const rounds = programs?.flatMap((p) => (p.rounds || []).map((r) => ({ ...r, + programId: p.id, programName: `${p.year} Edition`, })) ) || [] @@ -170,6 +171,7 @@ function ImportPageContent() { { diff --git a/src/app/(admin)/admin/projects/new/page.tsx b/src/app/(admin)/admin/projects/new/page.tsx index e17041f..8eacceb 100644 --- a/src/app/(admin)/admin/projects/new/page.tsx +++ b/src/app/(admin)/admin/projects/new/page.tsx @@ -73,6 +73,7 @@ function NewProjectPageContent() { const rounds = programs?.flatMap((p) => (p.rounds || []).map((r) => ({ ...r, + programId: p.id, programName: `${p.year} Edition`, })) ) || [] @@ -117,6 +118,7 @@ function NewProjectPageContent() { }) createProject.mutate({ + programId: selectedRound!.programId, roundId: selectedRoundId, title: title.trim(), teamName: teamName.trim() || undefined, diff --git a/src/app/(admin)/admin/projects/page.tsx b/src/app/(admin)/admin/projects/page.tsx index cba4a1a..661c075 100644 --- a/src/app/(admin)/admin/projects/page.tsx +++ b/src/app/(admin)/admin/projects/page.tsx @@ -350,9 +350,9 @@ export default function ProjectsPage() {

-

{project.round.name}

+

{project.roundProjects?.[0]?.round?.name ?? '-'}

- {project.round.program?.name} + {project.program?.name}

@@ -365,9 +365,9 @@ export default function ProjectsPage() { - {project.status.replace('_', ' ')} + {(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')} @@ -431,11 +431,11 @@ export default function ProjectsPage() { - {project.status.replace('_', ' ')} + {(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')} {project.teamName} @@ -445,7 +445,7 @@ export default function ProjectsPage() {
Round - {project.round.name} + {project.roundProjects?.[0]?.round?.name ?? '-'}
Assignments diff --git a/src/app/(admin)/admin/projects/project-filters.tsx b/src/app/(admin)/admin/projects/project-filters.tsx index 17aa425..32effbb 100644 --- a/src/app/(admin)/admin/projects/project-filters.tsx +++ b/src/app/(admin)/admin/projects/project-filters.tsx @@ -72,7 +72,7 @@ export interface ProjectFilters { } interface FilterOptions { - rounds: Array<{ id: string; name: string; program: { name: string; year: number } }> + rounds: Array<{ id: string; name: string; sortOrder: number; program: { name: string; year: number } }> countries: string[] categories: Array<{ value: string; count: number }> issues: Array<{ value: string; count: number }> diff --git a/src/app/(admin)/admin/rounds/[id]/live-voting/page.tsx b/src/app/(admin)/admin/rounds/[id]/live-voting/page.tsx index aced4d8..4eaad74 100644 --- a/src/app/(admin)/admin/rounds/[id]/live-voting/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/live-voting/page.tsx @@ -180,7 +180,7 @@ function LiveVotingContent({ roundId }: { roundId: string }) { if (storedOrder.length > 0) { setProjectOrder(storedOrder) } else { - setProjectOrder(sessionData.round.projects.map((p) => p.id)) + setProjectOrder(sessionData.round.roundProjects.map((rp) => rp.project.id)) } } }, [sessionData]) @@ -253,7 +253,7 @@ function LiveVotingContent({ roundId }: { roundId: string }) { ) } - const projects = sessionData.round.projects + const projects = sessionData.round.roundProjects.map((rp) => rp.project) const sortedProjects = projectOrder .map((id) => projects.find((p) => p.id === id)) .filter((p): p is Project => !!p) diff --git a/src/app/(admin)/admin/rounds/[id]/page.tsx b/src/app/(admin)/admin/rounds/[id]/page.tsx index 5782e69..ad28a45 100644 --- a/src/app/(admin)/admin/rounds/[id]/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { Suspense, use } from 'react' +import { Suspense, use, useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { trpc } from '@/lib/trpc/client' @@ -44,8 +44,14 @@ import { Filter, Trash2, Loader2, + Plus, + ArrowRightCircle, + Minus, } from 'lucide-react' import { toast } from 'sonner' +import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog' +import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog' +import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog' import { format, formatDistanceToNow, isPast, isFuture } from 'date-fns' interface PageProps { @@ -54,6 +60,9 @@ interface PageProps { function RoundDetailContent({ roundId }: { roundId: string }) { const router = useRouter() + const [assignOpen, setAssignOpen] = useState(false) + const [advanceOpen, setAdvanceOpen] = useState(false) + const [removeOpen, setRemoveOpen] = useState(false) const { data: round, isLoading } = trpc.round.get.useQuery({ id: roundId }) const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId }) @@ -235,7 +244,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) { -
{round._count.projects}
+
{round._count.roundProjects}
@@ -423,9 +432,43 @@ function RoundDetailContent({ roundId }: { roundId: string }) { View Projects + + +
+ + {/* Dialogs */} + utils.round.get.invalidate({ id: roundId })} + /> + utils.round.get.invalidate({ id: roundId })} + /> + utils.round.get.invalidate({ id: roundId })} + /> ) } diff --git a/src/app/(admin)/admin/rounds/new/page.tsx b/src/app/(admin)/admin/rounds/new/page.tsx index 9cb3e15..b6ae9e5 100644 --- a/src/app/(admin)/admin/rounds/new/page.tsx +++ b/src/app/(admin)/admin/rounds/new/page.tsx @@ -16,7 +16,6 @@ import { } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' import { Select, SelectContent, @@ -34,6 +33,7 @@ import { FormLabel, FormMessage, } from '@/components/ui/form' +import { RoundTypeSettings } from '@/components/forms/round-type-settings' import { ArrowLeft, Loader2, AlertCircle } from 'lucide-react' const createRoundSchema = z.object({ @@ -58,6 +58,8 @@ function CreateRoundContent() { const router = useRouter() const searchParams = useSearchParams() const programIdParam = searchParams.get('program') + const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION') + const [roundSettings, setRoundSettings] = useState>({}) const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery() @@ -82,7 +84,9 @@ function CreateRoundContent() { await createRound.mutateAsync({ programId: data.programId, name: data.name, + roundType, requiredReviews: data.requiredReviews, + settingsJson: roundSettings, votingStartAt: data.votingStartAt ? new Date(data.votingStartAt) : undefined, votingEndAt: data.votingEndAt ? new Date(data.votingEndAt) : undefined, }) @@ -218,6 +222,14 @@ function CreateRoundContent() { + {/* Round Type & Settings */} + + Voting Window diff --git a/src/app/(admin)/admin/rounds/page.tsx b/src/app/(admin)/admin/rounds/page.tsx index 142dbf6..ad7f42e 100644 --- a/src/app/(admin)/admin/rounds/page.tsx +++ b/src/app/(admin)/admin/rounds/page.tsx @@ -52,6 +52,8 @@ import { Archive, Trash2, Loader2, + ChevronUp, + ChevronDown, } from 'lucide-react' import { format, isPast, isFuture } from 'date-fns' @@ -106,6 +108,7 @@ function RoundsContent() { + Order Round Status Voting Window @@ -115,8 +118,15 @@ function RoundsContent() { - {program.rounds.map((round) => ( - + {program.rounds.map((round, index) => ( + r.id)} + programId={program.id} + /> ))}
@@ -133,10 +143,40 @@ function RoundsContent() { ) } -function RoundRow({ round }: { round: any }) { +function RoundRow({ + round, + index, + totalRounds, + allRoundIds, + programId, +}: { + round: any + index: number + totalRounds: number + allRoundIds: string[] + programId: string +}) { const utils = trpc.useUtils() const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const reorder = trpc.round.reorder.useMutation({ + onSuccess: () => { + utils.program.list.invalidate() + }, + }) + + const moveUp = () => { + const ids = [...allRoundIds] + ;[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]] + reorder.mutate({ programId, roundIds: ids }) + } + + const moveDown = () => { + const ids = [...allRoundIds] + ;[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]] + reorder.mutate({ programId, roundIds: ids }) + } + const updateStatus = trpc.round.updateStatus.useMutation({ onSuccess: () => { utils.program.list.invalidate() @@ -229,6 +269,28 @@ function RoundRow({ round }: { round: any }) { return ( + +
+ + +
+
- {round._count?.projects || 0} + {round._count?.roundProjects || 0}
@@ -325,9 +387,9 @@ function RoundRow({ round }: { round: any }) { Delete Round Are you sure you want to delete "{round.name}"? This will - permanently delete all {round._count?.projects || 0} projects,{' '} - {round._count?.assignments || 0} assignments, and all evaluations - in this round. This action cannot be undone. + remove {round._count?.roundProjects || 0} project assignments,{' '} + {round._count?.assignments || 0} reviewer assignments, and all evaluations + in this round. The projects themselves will remain in the program. This action cannot be undone. diff --git a/src/app/(jury)/jury/assignments/page.tsx b/src/app/(jury)/jury/assignments/page.tsx index 66da231..94eae5e 100644 --- a/src/app/(jury)/jury/assignments/page.tsx +++ b/src/app/(jury)/jury/assignments/page.tsx @@ -56,7 +56,6 @@ async function AssignmentsContent({ title: true, teamName: true, description: true, - status: true, files: { select: { id: true, diff --git a/src/app/(jury)/jury/page.tsx b/src/app/(jury)/jury/page.tsx index 0384a91..0df7025 100644 --- a/src/app/(jury)/jury/page.tsx +++ b/src/app/(jury)/jury/page.tsx @@ -45,7 +45,6 @@ async function JuryDashboardContent() { id: true, title: true, teamName: true, - status: true, }, }, round: { diff --git a/src/app/(jury)/jury/projects/[id]/evaluate/page.tsx b/src/app/(jury)/jury/projects/[id]/evaluate/page.tsx index 18f3233..6c27244 100644 --- a/src/app/(jury)/jury/projects/[id]/evaluate/page.tsx +++ b/src/app/(jury)/jury/projects/[id]/evaluate/page.tsx @@ -34,11 +34,18 @@ async function EvaluateContent({ projectId }: { projectId: string }) { redirect('/login') } - // Get project with assignment info for this user - const project = await prisma.project.findUnique({ - where: { id: projectId }, + // Check if user is assigned to this project + const assignment = await prisma.assignment.findFirst({ + where: { + projectId, + userId, + }, include: { - files: true, + evaluation: { + include: { + form: true, + }, + }, round: { include: { program: { @@ -53,25 +60,18 @@ async function EvaluateContent({ projectId }: { projectId: string }) { }, }) + // Get project details + const project = await prisma.project.findUnique({ + where: { id: projectId }, + include: { + files: true, + }, + }) + if (!project) { notFound() } - // Check if user is assigned to this project - const assignment = await prisma.assignment.findFirst({ - where: { - projectId, - userId, - }, - include: { - evaluation: { - include: { - form: true, - }, - }, - }, - }) - if (!assignment) { return (
@@ -95,7 +95,7 @@ async function EvaluateContent({ projectId }: { projectId: string }) { ) } - const round = project.round + const round = assignment.round const now = new Date() // Check voting window diff --git a/src/app/(jury)/jury/projects/[id]/evaluation/page.tsx b/src/app/(jury)/jury/projects/[id]/evaluation/page.tsx index fe00468..1c2719e 100644 --- a/src/app/(jury)/jury/projects/[id]/evaluation/page.tsx +++ b/src/app/(jury)/jury/projects/[id]/evaluation/page.tsx @@ -49,10 +49,18 @@ async function EvaluationContent({ projectId }: { projectId: string }) { redirect('/login') } - // Get project with assignment info for this user - const project = await prisma.project.findUnique({ - where: { id: projectId }, + // Check if user is assigned to this project + const assignment = await prisma.assignment.findFirst({ + where: { + projectId, + userId, + }, include: { + evaluation: { + include: { + form: true, + }, + }, round: { include: { program: { @@ -67,25 +75,20 @@ async function EvaluationContent({ projectId }: { projectId: string }) { }, }) + // Get project details + const project = await prisma.project.findUnique({ + where: { id: projectId }, + select: { + id: true, + title: true, + teamName: true, + }, + }) + if (!project) { notFound() } - // Check if user is assigned to this project - const assignment = await prisma.assignment.findFirst({ - where: { - projectId, - userId, - }, - include: { - evaluation: { - include: { - form: true, - }, - }, - }, - }) - if (!assignment) { return (
@@ -145,7 +148,7 @@ async function EvaluationContent({ projectId }: { projectId: string }) { const criterionScores = (evaluation.criterionScoresJson as unknown as Record) || {} - const round = project.round + const round = assignment.round return (
diff --git a/src/app/(jury)/jury/projects/[id]/page.tsx b/src/app/(jury)/jury/projects/[id]/page.tsx index dac8974..4e7f3e5 100644 --- a/src/app/(jury)/jury/projects/[id]/page.tsx +++ b/src/app/(jury)/jury/projects/[id]/page.tsx @@ -43,11 +43,14 @@ async function ProjectContent({ projectId }: { projectId: string }) { redirect('/login') } - // Get project with assignment info for this user - const project = await prisma.project.findUnique({ - where: { id: projectId }, + // Check if user is assigned to this project + const assignment = await prisma.assignment.findFirst({ + where: { + projectId, + userId, + }, include: { - files: true, + evaluation: true, round: { include: { program: { @@ -62,21 +65,18 @@ async function ProjectContent({ projectId }: { projectId: string }) { }, }) + // Get project details + const project = await prisma.project.findUnique({ + where: { id: projectId }, + include: { + files: true, + }, + }) + if (!project) { notFound() } - // Check if user is assigned to this project - const assignment = await prisma.assignment.findFirst({ - where: { - projectId, - userId, - }, - include: { - evaluation: true, - }, - }) - if (!assignment) { // User is not assigned to this project return ( @@ -99,7 +99,7 @@ async function ProjectContent({ projectId }: { projectId: string }) { } const evaluation = assignment.evaluation - const round = project.round + const round = assignment.round const now = new Date() // Check voting window diff --git a/src/app/(mentor)/mentor/page.tsx b/src/app/(mentor)/mentor/page.tsx index 7d604ab..bf7135b 100644 --- a/src/app/(mentor)/mentor/page.tsx +++ b/src/app/(mentor)/mentor/page.tsx @@ -149,18 +149,24 @@ export default function MentorDashboard() {
- {project.round.program.year} Edition + {project.program.year} Edition - - {project.round.name} + {project.roundProjects?.[0]?.round && ( + <> + + {project.roundProjects[0].round.name} + + )}
{project.title} - - {project.status.replace('_', ' ')} - + {project.roundProjects?.[0]?.status && ( + + {project.roundProjects[0].status.replace('_', ' ')} + + )} {project.teamName && ( {project.teamName} diff --git a/src/app/(mentor)/mentor/projects/[id]/page.tsx b/src/app/(mentor)/mentor/projects/[id]/page.tsx index 9f4ed49..3683750 100644 --- a/src/app/(mentor)/mentor/projects/[id]/page.tsx +++ b/src/app/(mentor)/mentor/projects/[id]/page.tsx @@ -109,18 +109,24 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
- {project.round.program.year} Edition + {project.program.year} Edition - - {project.round.name} + {project.roundProjects?.[0]?.round && ( + <> + + {project.roundProjects[0].round.name} + + )}

{project.title}

- - {project.status.replace('_', ' ')} - + {project.roundProjects?.[0]?.status && ( + + {project.roundProjects[0].status.replace('_', ' ')} + + )}
{project.teamName && (

{project.teamName}

diff --git a/src/app/(mentor)/mentor/projects/page.tsx b/src/app/(mentor)/mentor/projects/page.tsx index 821d0c1..25a4222 100644 --- a/src/app/(mentor)/mentor/projects/page.tsx +++ b/src/app/(mentor)/mentor/projects/page.tsx @@ -94,16 +94,22 @@ export default function MentorProjectsPage() {
- {project.round.program.year} Edition + {project.program.year} Edition - - {project.round.name} + {project.roundProjects?.[0]?.round && ( + <> + + {project.roundProjects[0].round.name} + + )}
{project.title} - - {project.status.replace('_', ' ')} - + {project.roundProjects?.[0]?.status && ( + + {project.roundProjects[0].status.replace('_', ' ')} + + )} {project.teamName && ( {project.teamName} diff --git a/src/app/(observer)/observer/page.tsx b/src/app/(observer)/observer/page.tsx index 3dfd232..4df8d97 100644 --- a/src/app/(observer)/observer/page.tsx +++ b/src/app/(observer)/observer/page.tsx @@ -48,7 +48,7 @@ async function ObserverDashboardContent() { program: { select: { name: true, year: true } }, _count: { select: { - projects: true, + roundProjects: true, assignments: true, }, }, @@ -176,7 +176,7 @@ async function ObserverDashboardContent() {

-

{round._count.projects} projects

+

{round._count.roundProjects} projects

{round._count.assignments} assignments

diff --git a/src/app/(observer)/observer/reports/page.tsx b/src/app/(observer)/observer/reports/page.tsx index 1259943..dd12986 100644 --- a/src/app/(observer)/observer/reports/page.tsx +++ b/src/app/(observer)/observer/reports/page.tsx @@ -34,7 +34,7 @@ async function ReportsContent() { }, _count: { select: { - projects: true, + roundProjects: true, assignments: true, }, }, @@ -70,7 +70,7 @@ async function ReportsContent() { }) // Calculate totals - const totalProjects = roundStats.reduce((acc, r) => acc + r._count.projects, 0) + const totalProjects = roundStats.reduce((acc, r) => acc + r._count.roundProjects, 0) const totalAssignments = roundStats.reduce( (acc, r) => acc + r.totalAssignments, 0 @@ -176,7 +176,7 @@ async function ReportsContent() {
{round.program.name} - {round._count.projects} + {round._count.roundProjects}
@@ -237,7 +237,7 @@ async function ReportsContent() {

)}
- {round._count.projects} projects + {round._count.roundProjects} projects {round.completedEvaluations}/{round.totalAssignments} evaluations diff --git a/src/app/(public)/my-submission/[id]/submission-detail-client.tsx b/src/app/(public)/my-submission/[id]/submission-detail-client.tsx index 03e9da4..423c02f 100644 --- a/src/app/(public)/my-submission/[id]/submission-detail-client.tsx +++ b/src/app/(public)/my-submission/[id]/submission-detail-client.tsx @@ -132,7 +132,7 @@ export function SubmissionDetailClient() {

- {project.round.program.year} Edition - {project.round.name} + {project.roundProjects?.[0]?.round?.program?.year ? `${project.roundProjects[0].round.program.year} Edition` : ''}{project.roundProjects?.[0]?.round?.name ? ` - ${project.roundProjects[0].round.name}` : ''}

diff --git a/src/app/(public)/my-submission/my-submission-client.tsx b/src/app/(public)/my-submission/my-submission-client.tsx index 90e43f7..fcd4b45 100644 --- a/src/app/(public)/my-submission/my-submission-client.tsx +++ b/src/app/(public)/my-submission/my-submission-client.tsx @@ -131,18 +131,24 @@ export function MySubmissionClient() { ) : (
- {submissions.map((project) => ( + {submissions.map((project) => { + const latestRoundProject = project.roundProjects?.[0] + const projectStatus = latestRoundProject?.status ?? 'SUBMITTED' + const roundName = latestRoundProject?.round?.name + const programYear = latestRoundProject?.round?.program?.year + + return (
{project.title} - {project.round.program.year} Edition - {project.round.name} + {programYear ? `${programYear} Edition` : ''}{roundName ? ` - ${roundName}` : ''}
- - {project.status.replace('_', ' ')} + + {projectStatus.replace('_', ' ')}
@@ -197,22 +203,22 @@ export function MySubmissionClient() { status: 'UNDER_REVIEW', label: 'Under Review', date: null, - completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status), + completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(projectStatus), }, { status: 'SEMIFINALIST', label: 'Semi-finalist', date: null, - completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status), + completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(projectStatus), }, { status: 'FINALIST', label: 'Finalist', date: null, - completed: ['FINALIST', 'WINNER'].includes(project.status), + completed: ['FINALIST', 'WINNER'].includes(projectStatus), }, ]} - currentStatus={project.status} + currentStatus={projectStatus} className="mt-4" />
@@ -229,7 +235,8 @@ export function MySubmissionClient() {
- ))} + ) + })}
)}
diff --git a/src/components/admin/advance-projects-dialog.tsx b/src/components/admin/advance-projects-dialog.tsx new file mode 100644 index 0000000..0be887a --- /dev/null +++ b/src/components/admin/advance-projects-dialog.tsx @@ -0,0 +1,279 @@ +'use client' + +import { useState, useCallback, useEffect, useMemo } from 'react' +import { trpc } from '@/lib/trpc/client' +import { toast } from 'sonner' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Badge } from '@/components/ui/badge' +import { Label } from '@/components/ui/label' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { ArrowRightCircle, Loader2, Info } from 'lucide-react' + +interface AdvanceProjectsDialogProps { + roundId: string + programId: string + open: boolean + onOpenChange: (open: boolean) => void + onSuccess?: () => void +} + +export function AdvanceProjectsDialog({ + roundId, + programId, + open, + onOpenChange, + onSuccess, +}: AdvanceProjectsDialogProps) { + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [targetRoundId, setTargetRoundId] = useState('') + + const utils = trpc.useUtils() + + // Reset state when dialog opens + useEffect(() => { + if (open) { + setSelectedIds(new Set()) + setTargetRoundId('') + } + }, [open]) + + // Fetch rounds in program + const { data: roundsData } = trpc.round.list.useQuery( + { programId }, + { enabled: open } + ) + + // Fetch projects in current round + const { data: projectsData, isLoading } = trpc.project.list.useQuery( + { roundId, page: 1, perPage: 200 }, + { enabled: open } + ) + + // Auto-select next round by sortOrder + const otherRounds = useMemo(() => { + if (!roundsData) return [] + return roundsData + .filter((r) => r.id !== roundId) + .sort((a, b) => a.sortOrder - b.sortOrder) + }, [roundsData, roundId]) + + const currentRound = useMemo(() => { + return roundsData?.find((r) => r.id === roundId) + }, [roundsData, roundId]) + + // Auto-select next round in sort order + useEffect(() => { + if (open && otherRounds.length > 0 && !targetRoundId && currentRound) { + const nextRound = otherRounds.find( + (r) => r.sortOrder > currentRound.sortOrder + ) + setTargetRoundId(nextRound?.id || otherRounds[0].id) + } + }, [open, otherRounds, targetRoundId, currentRound]) + + const advanceMutation = trpc.round.advanceProjects.useMutation({ + onSuccess: (result) => { + const targetName = otherRounds.find((r) => r.id === targetRoundId)?.name + toast.success( + `${result.advanced} project${result.advanced !== 1 ? 's' : ''} advanced to ${targetName}` + ) + utils.round.get.invalidate() + utils.project.list.invalidate() + onSuccess?.() + onOpenChange(false) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + const projects = projectsData?.projects ?? [] + + const toggleProject = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + + const toggleAll = useCallback(() => { + if (selectedIds.size === projects.length) { + setSelectedIds(new Set()) + } else { + setSelectedIds(new Set(projects.map((p) => p.id))) + } + }, [selectedIds.size, projects]) + + const handleAdvance = () => { + if (selectedIds.size === 0 || !targetRoundId) return + advanceMutation.mutate({ + fromRoundId: roundId, + toRoundId: targetRoundId, + projectIds: Array.from(selectedIds), + }) + } + + const targetRoundName = otherRounds.find((r) => r.id === targetRoundId)?.name + + return ( + + + + + + Advance Projects + + + Select projects to advance to the next round. Projects will remain + visible in the current round. + + + +
+ + {otherRounds.length === 0 ? ( +

+ No other rounds available in this program. Create another round first. +

+ ) : ( + + )} +
+ +
+ + + Projects will be copied to the target round with "Submitted" status. + They will remain in the current round with their existing status. + +
+ +
+ {isLoading ? ( +
+ +
+ ) : projects.length === 0 ? ( +
+

No projects in this round

+

+ Assign projects to this round first. +

+
+ ) : ( +
+
+ 0} + onCheckedChange={toggleAll} + /> + + {selectedIds.size} of {projects.length} selected + +
+ +
+ + + + + Project + Team + Status + + + + {projects.map((project) => ( + toggleProject(project.id)} + > + + toggleProject(project.id)} + onClick={(e) => e.stopPropagation()} + /> + + + {project.title} + + + {project.teamName || '—'} + + + + {(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')} + + + + ))} + +
+
+
+ )} +
+ + + + + +
+
+ ) +} diff --git a/src/components/admin/assign-projects-dialog.tsx b/src/components/admin/assign-projects-dialog.tsx new file mode 100644 index 0000000..84afd91 --- /dev/null +++ b/src/components/admin/assign-projects-dialog.tsx @@ -0,0 +1,230 @@ +'use client' + +import { useState, useCallback, useEffect } from 'react' +import { trpc } from '@/lib/trpc/client' +import { toast } from 'sonner' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { Badge } from '@/components/ui/badge' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Search, Loader2, Plus, Package } from 'lucide-react' + +interface AssignProjectsDialogProps { + roundId: string + programId: string + open: boolean + onOpenChange: (open: boolean) => void + onSuccess?: () => void +} + +export function AssignProjectsDialog({ + roundId, + programId, + open, + onOpenChange, + onSuccess, +}: AssignProjectsDialogProps) { + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [search, setSearch] = useState('') + const [debouncedSearch, setDebouncedSearch] = useState('') + + const utils = trpc.useUtils() + + // Debounce search + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(search), 300) + return () => clearTimeout(timer) + }, [search]) + + // Reset state when dialog opens + useEffect(() => { + if (open) { + setSelectedIds(new Set()) + setSearch('') + setDebouncedSearch('') + } + }, [open]) + + const { data, isLoading } = trpc.project.list.useQuery( + { + programId, + notInRoundId: roundId, + search: debouncedSearch || undefined, + page: 1, + perPage: 100, + }, + { enabled: open } + ) + + const assignMutation = trpc.round.assignProjects.useMutation({ + onSuccess: (result) => { + toast.success(`${result.assigned} project${result.assigned !== 1 ? 's' : ''} assigned to round`) + utils.round.get.invalidate({ id: roundId }) + utils.project.list.invalidate() + onSuccess?.() + onOpenChange(false) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + const projects = data?.projects ?? [] + + const toggleProject = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + + const toggleAll = useCallback(() => { + if (selectedIds.size === projects.length) { + setSelectedIds(new Set()) + } else { + setSelectedIds(new Set(projects.map((p) => p.id))) + } + }, [selectedIds.size, projects]) + + const handleAssign = () => { + if (selectedIds.size === 0) return + assignMutation.mutate({ + roundId, + projectIds: Array.from(selectedIds), + }) + } + + return ( + + + + + + Assign Projects to Round + + + Select projects from the program to add to this round. + + + +
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ {isLoading ? ( +
+ +
+ ) : projects.length === 0 ? ( +
+ +

No available projects

+

+ All program projects are already in this round. +

+
+ ) : ( +
+
+
+ 0} + onCheckedChange={toggleAll} + /> + + {selectedIds.size} of {projects.length} selected + +
+
+ +
+ + + + + Project + Team + Country + + + + {projects.map((project) => ( + toggleProject(project.id)} + > + + toggleProject(project.id)} + onClick={(e) => e.stopPropagation()} + /> + + + {project.title} + + + {project.teamName || '—'} + + + {project.country ? ( + + {project.country} + + ) : '—'} + + + ))} + +
+
+
+ )} +
+ + + + + +
+
+ ) +} diff --git a/src/components/admin/remove-projects-dialog.tsx b/src/components/admin/remove-projects-dialog.tsx new file mode 100644 index 0000000..7c25041 --- /dev/null +++ b/src/components/admin/remove-projects-dialog.tsx @@ -0,0 +1,246 @@ +'use client' + +import { useState, useCallback, useEffect } from 'react' +import { trpc } from '@/lib/trpc/client' +import { toast } from 'sonner' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Badge } from '@/components/ui/badge' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Minus, Loader2, AlertTriangle } from 'lucide-react' + +interface RemoveProjectsDialogProps { + roundId: string + open: boolean + onOpenChange: (open: boolean) => void + onSuccess?: () => void +} + +export function RemoveProjectsDialog({ + roundId, + open, + onOpenChange, + onSuccess, +}: RemoveProjectsDialogProps) { + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [confirmOpen, setConfirmOpen] = useState(false) + + const utils = trpc.useUtils() + + // Reset state when dialog opens + useEffect(() => { + if (open) { + setSelectedIds(new Set()) + setConfirmOpen(false) + } + }, [open]) + + const { data, isLoading } = trpc.project.list.useQuery( + { roundId, page: 1, perPage: 200 }, + { enabled: open } + ) + + const removeMutation = trpc.round.removeProjects.useMutation({ + onSuccess: (result) => { + toast.success( + `${result.removed} project${result.removed !== 1 ? 's' : ''} removed from round` + ) + utils.round.get.invalidate({ id: roundId }) + utils.project.list.invalidate() + onSuccess?.() + onOpenChange(false) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + const projects = data?.projects ?? [] + + const toggleProject = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + + const toggleAll = useCallback(() => { + if (selectedIds.size === projects.length) { + setSelectedIds(new Set()) + } else { + setSelectedIds(new Set(projects.map((p) => p.id))) + } + }, [selectedIds.size, projects]) + + const handleRemove = () => { + if (selectedIds.size === 0) return + removeMutation.mutate({ + roundId, + projectIds: Array.from(selectedIds), + }) + setConfirmOpen(false) + } + + return ( + <> + + + + + + Remove Projects from Round + + + Select projects to remove from this round. The projects will remain + in the program and can be re-assigned later. + + + +
+ + + Removing projects from a round will also delete their jury + assignments and evaluations in this round. + +
+ +
+ {isLoading ? ( +
+ +
+ ) : projects.length === 0 ? ( +
+

No projects in this round

+

+ There are no projects to remove. +

+
+ ) : ( +
+
+ 0} + onCheckedChange={toggleAll} + /> + + {selectedIds.size} of {projects.length} selected + +
+ +
+ + + + + Project + Team + Status + + + + {projects.map((project) => ( + toggleProject(project.id)} + > + + toggleProject(project.id)} + onClick={(e) => e.stopPropagation()} + /> + + + {project.title} + + + {project.teamName || '—'} + + + + {(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')} + + + + ))} + +
+
+
+ )} +
+ + + + + +
+
+ + + + + Confirm Removal + + Are you sure you want to remove {selectedIds.size} project + {selectedIds.size !== 1 ? 's' : ''} from this round? Their + assignments and evaluations in this round will be deleted. The + projects will remain in the program. + + + + Cancel + + Remove Projects + + + + + + ) +} diff --git a/src/components/forms/csv-import-form.tsx b/src/components/forms/csv-import-form.tsx index 425c7c3..9e33c35 100644 --- a/src/components/forms/csv-import-form.tsx +++ b/src/components/forms/csv-import-form.tsx @@ -38,7 +38,8 @@ import { import { cn } from '@/lib/utils' interface CSVImportFormProps { - roundId: string + programId: string + roundId?: string roundName: string onSuccess?: () => void } @@ -72,7 +73,7 @@ interface MappedProject { metadataJson?: Record } -export function CSVImportForm({ roundId, roundName, onSuccess }: CSVImportFormProps) { +export function CSVImportForm({ programId, roundId, roundName, onSuccess }: CSVImportFormProps) { const router = useRouter() const [step, setStep] = useState('upload') const [file, setFile] = useState(null) @@ -224,6 +225,7 @@ export function CSVImportForm({ roundId, roundName, onSuccess }: CSVImportFormPr try { await importMutation.mutateAsync({ + programId, roundId, projects: valid, }) diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts index fb481ce..1974f85 100644 --- a/src/server/routers/analytics.ts +++ b/src/server/routers/analytics.ts @@ -148,13 +148,17 @@ export const analyticsRouter = router({ getProjectRankings: adminProcedure .input(z.object({ roundId: z.string(), limit: z.number().optional() })) .query(async ({ ctx, input }) => { - const projects = await ctx.prisma.project.findMany({ + const roundProjects = await ctx.prisma.roundProject.findMany({ where: { roundId: input.roundId }, include: { - assignments: { + project: { include: { - evaluation: { - select: { criterionScoresJson: true, status: true }, + assignments: { + include: { + evaluation: { + select: { criterionScoresJson: true, status: true }, + }, + }, }, }, }, @@ -162,8 +166,9 @@ export const analyticsRouter = router({ }) // Calculate average scores - const rankings = projects - .map((project) => { + const rankings = roundProjects + .map((rp) => { + const project = rp.project const allScores: number[] = [] project.assignments.forEach((assignment) => { @@ -195,7 +200,7 @@ export const analyticsRouter = router({ id: project.id, title: project.title, teamName: project.teamName, - status: project.status, + status: rp.status, averageScore, evaluationCount: allScores.length, } @@ -212,15 +217,15 @@ export const analyticsRouter = router({ getStatusBreakdown: adminProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { - const projects = await ctx.prisma.project.groupBy({ + const roundProjects = await ctx.prisma.roundProject.groupBy({ by: ['status'], where: { roundId: input.roundId }, _count: true, }) - return projects.map((p) => ({ - status: p.status, - count: p._count, + return roundProjects.map((rp) => ({ + status: rp.status, + count: rp._count, })) }), @@ -237,7 +242,7 @@ export const analyticsRouter = router({ jurorCount, statusCounts, ] = await Promise.all([ - ctx.prisma.project.count({ where: { roundId: input.roundId } }), + ctx.prisma.roundProject.count({ where: { roundId: input.roundId } }), ctx.prisma.assignment.count({ where: { roundId: input.roundId } }), ctx.prisma.evaluation.count({ where: { @@ -249,7 +254,7 @@ export const analyticsRouter = router({ by: ['userId'], where: { roundId: input.roundId }, }), - ctx.prisma.project.groupBy({ + ctx.prisma.roundProject.groupBy({ by: ['status'], where: { roundId: input.roundId }, _count: true, @@ -348,8 +353,8 @@ export const analyticsRouter = router({ ) .query(async ({ ctx, input }) => { const where = input.roundId - ? { roundId: input.roundId } - : { round: { programId: input.programId } } + ? { roundProjects: { some: { roundId: input.roundId } } } + : { programId: input.programId } const distribution = await ctx.prisma.project.groupBy({ by: ['country'], diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index 5d159c8..756f762 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -62,7 +62,7 @@ export const applicantRouter = router({ const project = await ctx.prisma.project.findFirst({ where: { - roundId: input.roundId, + roundProjects: { some: { roundId: input.roundId } }, OR: [ { submittedByUserId: ctx.user.id }, { @@ -74,9 +74,14 @@ export const applicantRouter = router({ }, include: { files: true, - round: { + roundProjects: { + where: { roundId: input.roundId }, include: { - program: { select: { name: true, year: true } }, + round: { + include: { + program: { select: { name: true, year: true } }, + }, + }, }, }, teamMembers: { @@ -171,26 +176,47 @@ export const applicantRouter = router({ ...data, metadataJson: metadataJson as unknown ?? undefined, submittedAt: submit && !existing.submittedAt ? now : existing.submittedAt, - status: submit ? 'SUBMITTED' : existing.status, }, }) + // Update RoundProject status if submitting + if (submit) { + await ctx.prisma.roundProject.updateMany({ + where: { projectId: projectId }, + data: { status: 'SUBMITTED' }, + }) + } + return project } else { - // Create new + // Get the round to find the programId + const roundForCreate = await ctx.prisma.round.findUniqueOrThrow({ + where: { id: roundId }, + select: { programId: true }, + }) + + // Create new project const project = await ctx.prisma.project.create({ data: { - roundId, + programId: roundForCreate.programId, ...data, metadataJson: metadataJson as unknown ?? undefined, submittedByUserId: ctx.user.id, submittedByEmail: ctx.user.email, submissionSource: 'MANUAL', - status: 'SUBMITTED', submittedAt: submit ? now : null, }, }) + // Create RoundProject entry + await ctx.prisma.roundProject.create({ + data: { + roundId, + projectId: project.id, + status: 'SUBMITTED', + }, + }) + // Audit log await ctx.prisma.auditLog.create({ data: { @@ -386,10 +412,15 @@ export const applicantRouter = router({ ], }, include: { - round: { + roundProjects: { include: { - program: { select: { name: true, year: true } }, + round: { + include: { + program: { select: { name: true, year: true } }, + }, + }, }, + orderBy: { addedAt: 'desc' }, }, files: true, teamMembers: { @@ -409,6 +440,10 @@ export const applicantRouter = router({ }) } + // Get the latest round project status + const latestRoundProject = project.roundProjects[0] + const currentStatus = latestRoundProject?.status ?? 'SUBMITTED' + // Build timeline const timeline = [ { @@ -426,27 +461,27 @@ export const applicantRouter = router({ { status: 'UNDER_REVIEW', label: 'Under Review', - date: project.status === 'SUBMITTED' && project.submittedAt ? project.submittedAt : null, - completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status), + date: currentStatus === 'SUBMITTED' && project.submittedAt ? project.submittedAt : null, + completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus), }, { status: 'SEMIFINALIST', label: 'Semi-finalist', date: null, // Would need status change tracking - completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status), + completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus), }, { status: 'FINALIST', label: 'Finalist', date: null, - completed: ['FINALIST', 'WINNER'].includes(project.status), + completed: ['FINALIST', 'WINNER'].includes(currentStatus), }, ] return { project, timeline, - currentStatus: project.status, + currentStatus, } }), @@ -474,10 +509,15 @@ export const applicantRouter = router({ ], }, include: { - round: { + roundProjects: { include: { - program: { select: { name: true, year: true } }, + round: { + include: { + program: { select: { name: true, year: true } }, + }, + }, }, + orderBy: { addedAt: 'desc' }, }, files: true, teamMembers: { diff --git a/src/server/routers/application.ts b/src/server/routers/application.ts index 0e4116a..816f5eb 100644 --- a/src/server/routers/application.ts +++ b/src/server/routers/application.ts @@ -186,7 +186,7 @@ export const applicationRouter = router({ // Check if email already submitted for this round const existingProject = await ctx.prisma.project.findFirst({ where: { - roundId, + roundProjects: { some: { roundId } }, submittedByEmail: data.contactEmail, }, }) @@ -218,11 +218,10 @@ export const applicationRouter = router({ // Create the project const project = await ctx.prisma.project.create({ data: { - roundId, + programId: round.programId, title: data.projectName, teamName: data.teamName, description: data.description, - status: 'SUBMITTED', competitionCategory: data.competitionCategory, oceanIssue: data.oceanIssue, country: data.country, @@ -242,6 +241,15 @@ export const applicationRouter = router({ }, }) + // Create RoundProject entry + await ctx.prisma.roundProject.create({ + data: { + roundId, + projectId: project.id, + status: 'SUBMITTED', + }, + }) + // Create team lead membership await ctx.prisma.teamMember.create({ data: { @@ -320,7 +328,7 @@ export const applicationRouter = router({ .query(async ({ ctx, input }) => { const existing = await ctx.prisma.project.findFirst({ where: { - roundId: input.roundId, + roundProjects: { some: { roundId: input.roundId } }, submittedByEmail: input.email, }, }) diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts index 6204188..672a10d 100644 --- a/src/server/routers/assignment.ts +++ b/src/server/routers/assignment.ts @@ -286,12 +286,16 @@ export const assignmentRouter = router({ where: { roundId: input.roundId }, _count: true, }), - ctx.prisma.project.findMany({ + ctx.prisma.roundProject.findMany({ where: { roundId: input.roundId }, - select: { - id: true, - title: true, - _count: { select: { assignments: true } }, + include: { + project: { + select: { + id: true, + title: true, + _count: { select: { assignments: true } }, + }, + }, }, }), ]) @@ -302,7 +306,7 @@ export const assignmentRouter = router({ }) const projectsWithFullCoverage = projectCoverage.filter( - (p) => p._count.assignments >= round.requiredReviews + (rp) => rp.project._count.assignments >= round.requiredReviews ).length return { @@ -354,15 +358,20 @@ export const assignmentRouter = router({ }) // Get all projects that need more assignments - const projects = await ctx.prisma.project.findMany({ + const roundProjectEntries = await ctx.prisma.roundProject.findMany({ where: { roundId: input.roundId }, - select: { - id: true, - title: true, - tags: true, - _count: { select: { assignments: true } }, + include: { + project: { + select: { + id: true, + title: true, + tags: true, + _count: { select: { assignments: true } }, + }, + }, }, }) + const projects = roundProjectEntries.map((rp) => rp.project) // Get existing assignments to avoid duplicates const existingAssignments = await ctx.prisma.assignment.findMany({ @@ -482,17 +491,22 @@ export const assignmentRouter = router({ }) // Get all projects in the round - const projects = await ctx.prisma.project.findMany({ + const roundProjectEntries = await ctx.prisma.roundProject.findMany({ where: { roundId: input.roundId }, - select: { - id: true, - title: true, - description: true, - tags: true, - teamName: true, - _count: { select: { assignments: true } }, + include: { + project: { + select: { + id: true, + title: true, + description: true, + tags: true, + teamName: true, + _count: { select: { assignments: true } }, + }, + }, }, }) + const projects = roundProjectEntries.map((rp) => rp.project) // Get existing assignments const existingAssignments = await ctx.prisma.assignment.findMany({ diff --git a/src/server/routers/export.ts b/src/server/routers/export.ts index 29bf832..547d793 100644 --- a/src/server/routers/export.ts +++ b/src/server/routers/export.ts @@ -103,21 +103,26 @@ export const exportRouter = router({ projectScores: adminProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { - const projects = await ctx.prisma.project.findMany({ + const roundProjectEntries = await ctx.prisma.roundProject.findMany({ where: { roundId: input.roundId }, include: { - assignments: { + project: { include: { - evaluation: { - where: { status: 'SUBMITTED' }, + assignments: { + include: { + evaluation: { + where: { status: 'SUBMITTED' }, + }, + }, }, }, }, }, - orderBy: { title: 'asc' }, + orderBy: { project: { title: 'asc' } }, }) - const data = projects.map((p) => { + const data = roundProjectEntries.map((rp) => { + const p = rp.project const evaluations = p.assignments .map((a) => a.evaluation) .filter((e) => e !== null) @@ -133,7 +138,7 @@ export const exportRouter = router({ return { title: p.title, teamName: p.teamName, - status: p.status, + status: rp.status, tags: p.tags.join(', '), totalEvaluations: evaluations.length, averageScore: diff --git a/src/server/routers/filtering.ts b/src/server/routers/filtering.ts index 5c80328..086a36f 100644 --- a/src/server/routers/filtering.ts +++ b/src/server/routers/filtering.ts @@ -147,14 +147,19 @@ export const filteringRouter = router({ } // Get projects in this round - const projects = await ctx.prisma.project.findMany({ + const roundProjectEntries = await ctx.prisma.roundProject.findMany({ where: { roundId: input.roundId }, include: { - files: { - select: { id: true, fileName: true, fileType: true }, + project: { + include: { + files: { + select: { id: true, fileName: true, fileType: true }, + }, + }, }, }, }) + const projects = roundProjectEntries.map((rp) => rp.project) if (projects.length === 0) { throw new TRPCError({ @@ -250,7 +255,6 @@ export const filteringRouter = router({ id: true, title: true, teamName: true, - status: true, competitionCategory: true, country: true, }, @@ -390,13 +394,13 @@ export const filteringRouter = router({ .filter((r) => (r.finalOutcome || r.outcome) === 'PASSED') .map((r) => r.projectId) - // Update project statuses + // Update RoundProject statuses await ctx.prisma.$transaction([ // Filtered out projects get REJECTED status (data preserved) ...(filteredOutIds.length > 0 ? [ - ctx.prisma.project.updateMany({ - where: { id: { in: filteredOutIds } }, + ctx.prisma.roundProject.updateMany({ + where: { roundId: input.roundId, projectId: { in: filteredOutIds } }, data: { status: 'REJECTED' }, }), ] @@ -404,8 +408,8 @@ export const filteringRouter = router({ // Passed projects get ELIGIBLE status ...(passedIds.length > 0 ? [ - ctx.prisma.project.updateMany({ - where: { id: { in: passedIds } }, + ctx.prisma.roundProject.updateMany({ + where: { roundId: input.roundId, projectId: { in: passedIds } }, data: { status: 'ELIGIBLE' }, }), ] @@ -454,9 +458,9 @@ export const filteringRouter = router({ }, }) - // Restore project status - await ctx.prisma.project.update({ - where: { id: input.projectId }, + // Restore RoundProject status + await ctx.prisma.roundProject.updateMany({ + where: { roundId: input.roundId, projectId: input.projectId }, data: { status: 'ELIGIBLE' }, }) @@ -500,8 +504,8 @@ export const filteringRouter = router({ }, }) ), - ctx.prisma.project.updateMany({ - where: { id: { in: input.projectIds } }, + ctx.prisma.roundProject.updateMany({ + where: { roundId: input.roundId, projectId: { in: input.projectIds } }, data: { status: 'ELIGIBLE' }, }), ]) diff --git a/src/server/routers/learningResource.ts b/src/server/routers/learningResource.ts index 0ccff3b..32bd86c 100644 --- a/src/server/routers/learningResource.ts +++ b/src/server/routers/learningResource.ts @@ -80,18 +80,27 @@ export const learningResourceRouter = router({ const assignments = await ctx.prisma.assignment.findMany({ where: { userId: ctx.user.id }, include: { - project: { select: { status: true } }, + project: { + select: { + roundProjects: { + select: { status: true }, + orderBy: { addedAt: 'desc' }, + take: 1, + }, + }, + }, }, }) // Determine highest cohort level let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL' for (const assignment of assignments) { - if (assignment.project.status === 'FINALIST') { + const rpStatus = assignment.project.roundProjects[0]?.status + if (rpStatus === 'FINALIST') { userCohortLevel = 'FINALIST' break } - if (assignment.project.status === 'SEMIFINALIST') { + if (rpStatus === 'SEMIFINALIST') { userCohortLevel = 'SEMIFINALIST' } } @@ -155,17 +164,26 @@ export const learningResourceRouter = router({ const assignments = await ctx.prisma.assignment.findMany({ where: { userId: ctx.user.id }, include: { - project: { select: { status: true } }, + project: { + select: { + roundProjects: { + select: { status: true }, + orderBy: { addedAt: 'desc' as const }, + take: 1, + }, + }, + }, }, }) let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL' for (const assignment of assignments) { - if (assignment.project.status === 'FINALIST') { + const rpStatus = assignment.project.roundProjects[0]?.status + if (rpStatus === 'FINALIST') { userCohortLevel = 'FINALIST' break } - if (assignment.project.status === 'SEMIFINALIST') { + if (rpStatus === 'SEMIFINALIST') { userCohortLevel = 'SEMIFINALIST' } } @@ -220,16 +238,27 @@ export const learningResourceRouter = router({ // Check cohort level access const assignments = await ctx.prisma.assignment.findMany({ where: { userId: ctx.user.id }, - include: { project: { select: { status: true } } }, + include: { + project: { + select: { + roundProjects: { + select: { status: true }, + orderBy: { addedAt: 'desc' as const }, + take: 1, + }, + }, + }, + }, }) let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL' for (const assignment of assignments) { - if (assignment.project.status === 'FINALIST') { + const rpStatus = assignment.project.roundProjects[0]?.status + if (rpStatus === 'FINALIST') { userCohortLevel = 'FINALIST' break } - if (assignment.project.status === 'SEMIFINALIST') { + if (rpStatus === 'SEMIFINALIST') { userCohortLevel = 'SEMIFINALIST' } } diff --git a/src/server/routers/live-voting.ts b/src/server/routers/live-voting.ts index 34d4657..2ac71c9 100644 --- a/src/server/routers/live-voting.ts +++ b/src/server/routers/live-voting.ts @@ -15,9 +15,13 @@ export const liveVotingRouter = router({ round: { include: { program: { select: { name: true, year: true } }, - projects: { + roundProjects: { where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } }, - select: { id: true, title: true, teamName: true }, + include: { + project: { + select: { id: true, title: true, teamName: true }, + }, + }, }, }, }, @@ -34,9 +38,13 @@ export const liveVotingRouter = router({ round: { include: { program: { select: { name: true, year: true } }, - projects: { + roundProjects: { where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } }, - select: { id: true, title: true, teamName: true }, + include: { + project: { + select: { id: true, title: true, teamName: true }, + }, + }, }, }, }, diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index f06c06d..9e57cda 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -333,7 +333,7 @@ export const mentorRouter = router({ // Get projects without mentors const projects = await ctx.prisma.project.findMany({ where: { - roundId: input.roundId, + roundProjects: { some: { roundId: input.roundId } }, mentorAssignment: null, wantsMentorship: true, }, @@ -431,10 +431,17 @@ export const mentorRouter = router({ include: { project: { include: { - round: { + program: { select: { name: true, year: true } }, + roundProjects: { include: { - program: { select: { name: true, year: true } }, + round: { + include: { + program: { select: { name: true, year: true } }, + }, + }, }, + orderBy: { addedAt: 'desc' }, + take: 1, }, teamMembers: { include: { @@ -477,10 +484,17 @@ export const mentorRouter = router({ const project = await ctx.prisma.project.findUniqueOrThrow({ where: { id: input.projectId }, include: { - round: { + program: { select: { id: true, name: true, year: true } }, + roundProjects: { include: { - program: { select: { id: true, name: true, year: true } }, + round: { + include: { + program: { select: { id: true, name: true, year: true } }, + }, + }, }, + orderBy: { addedAt: 'desc' }, + take: 1, }, teamMembers: { include: { @@ -528,7 +542,7 @@ export const mentorRouter = router({ ) .query(async ({ ctx, input }) => { const where = { - ...(input.roundId && { project: { roundId: input.roundId } }), + ...(input.roundId && { project: { roundProjects: { some: { roundId: input.roundId } } } }), ...(input.mentorId && { mentorId: input.mentorId }), } @@ -541,9 +555,12 @@ export const mentorRouter = router({ id: true, title: true, teamName: true, - status: true, oceanIssue: true, competitionCategory: true, + roundProjects: { + select: { status: true }, + take: 1, + }, }, }, mentor: { diff --git a/src/server/routers/notion-import.ts b/src/server/routers/notion-import.ts index 10f4dc1..f7d6f7d 100644 --- a/src/server/routers/notion-import.ts +++ b/src/server/routers/notion-import.ts @@ -171,9 +171,9 @@ export const notionImportRouter = router({ } // Create project - await ctx.prisma.project.create({ + const createdProject = await ctx.prisma.project.create({ data: { - roundId: round.id, + programId: round.programId, title: title.trim(), teamName: typeof teamName === 'string' ? teamName.trim() : null, description: typeof description === 'string' ? description : null, @@ -183,6 +183,14 @@ export const notionImportRouter = router({ notionPageId: record.id, notionDatabaseId: input.databaseId, } as Prisma.InputJsonValue, + }, + }) + + // Create RoundProject entry + await ctx.prisma.roundProject.create({ + data: { + roundId: round.id, + projectId: createdProject.id, status: 'SUBMITTED', }, }) diff --git a/src/server/routers/program.ts b/src/server/routers/program.ts index 5dec129..fa4b1ec 100644 --- a/src/server/routers/program.ts +++ b/src/server/routers/program.ts @@ -40,7 +40,7 @@ export const programRouter = router({ orderBy: { createdAt: 'asc' }, include: { _count: { - select: { projects: true, assignments: true }, + select: { roundProjects: true, assignments: true }, }, }, }, diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index 57af479..11afca1 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -12,6 +12,7 @@ export const projectRouter = router({ list: protectedProcedure .input( z.object({ + programId: z.string().optional(), roundId: z.string().optional(), status: z .enum([ @@ -33,6 +34,8 @@ export const projectRouter = router({ 'REJECTED', ]) ).optional(), + notInRoundId: z.string().optional(), // Exclude projects already in this round + unassignedOnly: z.boolean().optional(), // Projects not in any round search: z.string().optional(), tags: z.array(z.string()).optional(), competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(), @@ -52,7 +55,7 @@ export const projectRouter = router({ ) .query(async ({ ctx, input }) => { const { - roundId, status, statuses, search, tags, + programId, roundId, notInRoundId, status, statuses, unassignedOnly, search, tags, competitionCategory, oceanIssue, country, wantsMentorship, hasFiles, hasAssignments, page, perPage, @@ -62,12 +65,51 @@ export const projectRouter = router({ // Build where clause const where: Record = {} - if (roundId) where.roundId = roundId - if (statuses && statuses.length > 0) { - where.status = { in: statuses } - } else if (status) { - where.status = status + if (programId) where.programId = programId + + // Filter by round via RoundProject join + if (roundId) { + where.roundProjects = { some: { roundId } } } + + // Exclude projects already in a specific round + if (notInRoundId) { + where.roundProjects = { + ...(where.roundProjects as Record || {}), + none: { roundId: notInRoundId }, + } + } + + // Filter by unassigned (not in any round) + if (unassignedOnly) { + where.roundProjects = { none: {} } + } + + // Status filter via RoundProject + if (roundId && (statuses?.length || status)) { + const statusValues = statuses?.length ? statuses : status ? [status] : [] + if (statusValues.length > 0) { + where.roundProjects = { + some: { + roundId, + status: { in: statusValues }, + }, + } + } + } else if (statuses?.length || status) { + // Status filter without specific round — match any round with that status + const statusValues = statuses?.length ? statuses : status ? [status] : [] + if (statusValues.length > 0) { + where.roundProjects = { + ...(where.roundProjects as Record || {}), + some: { + ...((where.roundProjects as Record)?.some as Record || {}), + status: { in: statusValues }, + }, + } + } + } + if (tags && tags.length > 0) { where.tags = { hasSome: tags } } @@ -90,7 +132,6 @@ 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) || {}), some: { userId: ctx.user.id }, @@ -105,8 +146,16 @@ export const projectRouter = router({ orderBy: { createdAt: 'desc' }, include: { files: true, - round: { - select: { id: true, name: true, program: { select: { name: true, year: true } } }, + program: { + select: { id: true, name: true, year: true }, + }, + roundProjects: { + include: { + round: { + select: { id: true, name: true, sortOrder: true }, + }, + }, + orderBy: { addedAt: 'desc' }, }, _count: { select: { assignments: true } }, }, @@ -130,8 +179,8 @@ export const projectRouter = router({ .query(async ({ ctx }) => { const [rounds, countries, categories, issues] = await Promise.all([ ctx.prisma.round.findMany({ - select: { id: true, name: true, program: { select: { name: true, year: true } } }, - orderBy: { createdAt: 'desc' }, + select: { id: true, name: true, sortOrder: true, program: { select: { name: true, year: true } } }, + orderBy: [{ program: { year: 'desc' } }, { sortOrder: 'asc' }], }), ctx.prisma.project.findMany({ where: { country: { not: null } }, @@ -175,7 +224,17 @@ export const projectRouter = router({ where: { id: input.id }, include: { files: true, - round: true, + program: { + select: { id: true, name: true, year: true }, + }, + roundProjects: { + include: { + round: { + select: { id: true, name: true, sortOrder: true, status: true }, + }, + }, + orderBy: { round: { sortOrder: 'asc' } }, + }, teamMembers: { include: { user: { @@ -244,11 +303,13 @@ export const projectRouter = router({ /** * Create a single project (admin only) + * Projects belong to a program. Optionally assign to a round immediately. */ create: adminProcedure .input( z.object({ - roundId: z.string(), + programId: z.string(), + roundId: z.string().optional(), title: z.string().min(1).max(500), teamName: z.string().optional(), description: z.string().optional(), @@ -257,7 +318,7 @@ export const projectRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const { metadataJson, ...rest } = input + const { metadataJson, roundId, ...rest } = input const project = await ctx.prisma.project.create({ data: { ...rest, @@ -265,6 +326,17 @@ export const projectRouter = router({ }, }) + // If roundId provided, also create RoundProject entry + if (roundId) { + await ctx.prisma.roundProject.create({ + data: { + roundId, + projectId: project.id, + status: 'SUBMITTED', + }, + }) + } + // Audit log await ctx.prisma.auditLog.create({ data: { @@ -272,7 +344,7 @@ export const projectRouter = router({ action: 'CREATE', entityType: 'Project', entityId: project.id, - detailsJson: { title: input.title, roundId: input.roundId }, + detailsJson: { title: input.title, programId: input.programId, roundId }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, @@ -283,6 +355,7 @@ export const projectRouter = router({ /** * Update a project (admin only) + * Status updates require a roundId context since status is per-round. */ update: adminProcedure .input( @@ -291,6 +364,8 @@ export const projectRouter = router({ title: z.string().min(1).max(500).optional(), teamName: z.string().optional().nullable(), description: z.string().optional().nullable(), + // Status update requires roundId + roundId: z.string().optional(), status: z .enum([ 'SUBMITTED', @@ -306,7 +381,7 @@ export const projectRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const { id, metadataJson, ...data } = input + const { id, metadataJson, status, roundId, ...data } = input const project = await ctx.prisma.project.update({ where: { id }, @@ -316,6 +391,14 @@ export const projectRouter = router({ }, }) + // Update status on RoundProject if both status and roundId provided + if (status && roundId) { + await ctx.prisma.roundProject.updateMany({ + where: { projectId: id, roundId }, + data: { status }, + }) + } + // Audit log await ctx.prisma.auditLog.create({ data: { @@ -323,7 +406,7 @@ export const projectRouter = router({ action: 'UPDATE', entityType: 'Project', entityId: id, - detailsJson: { ...data, metadataJson } as Prisma.InputJsonValue, + detailsJson: { ...data, status, roundId, metadataJson } as Prisma.InputJsonValue, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, @@ -360,11 +443,13 @@ export const projectRouter = router({ /** * Import projects from CSV data (admin only) + * Projects belong to a program. Optionally assign to a round. */ importCSV: adminProcedure .input( z.object({ - roundId: z.string(), + programId: z.string(), + roundId: z.string().optional(), projects: z.array( z.object({ title: z.string().min(1), @@ -377,20 +462,53 @@ export const projectRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - // Verify round exists - await ctx.prisma.round.findUniqueOrThrow({ - where: { id: input.roundId }, + // Verify program exists + await ctx.prisma.program.findUniqueOrThrow({ + where: { id: input.programId }, }) - const created = await ctx.prisma.project.createMany({ - data: input.projects.map((p) => { + // Verify round exists and belongs to program if provided + if (input.roundId) { + const round = await ctx.prisma.round.findUniqueOrThrow({ + where: { id: input.roundId }, + }) + if (round.programId !== input.programId) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Round does not belong to the selected program', + }) + } + } + + // Create projects in a transaction + const result = await ctx.prisma.$transaction(async (tx) => { + // Create all projects + const projectData = input.projects.map((p) => { const { metadataJson, ...rest } = p return { ...rest, - roundId: input.roundId, + programId: input.programId, metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, } - }), + }) + + const created = await tx.project.createManyAndReturn({ + data: projectData, + select: { id: true }, + }) + + // If roundId provided, create RoundProject entries + if (input.roundId) { + await tx.roundProject.createMany({ + data: created.map((p) => ({ + roundId: input.roundId!, + projectId: p.id, + status: 'SUBMITTED' as const, + })), + }) + } + + return { imported: created.length } }) // Audit log @@ -399,23 +517,30 @@ export const projectRouter = router({ userId: ctx.user.id, action: 'IMPORT', entityType: 'Project', - detailsJson: { roundId: input.roundId, count: created.count }, + detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) - return { imported: created.count } + return result }), /** * Get all unique tags used in projects */ getTags: protectedProcedure - .input(z.object({ roundId: z.string().optional() })) + .input(z.object({ + roundId: z.string().optional(), + programId: z.string().optional(), + })) .query(async ({ ctx, input }) => { + const where: Record = {} + if (input.programId) where.programId = input.programId + if (input.roundId) where.roundProjects = { some: { roundId: input.roundId } } + const projects = await ctx.prisma.project.findMany({ - where: input.roundId ? { roundId: input.roundId } : undefined, + where: Object.keys(where).length > 0 ? where : undefined, select: { tags: true }, }) @@ -427,11 +552,13 @@ export const projectRouter = router({ /** * Update project status in bulk (admin only) + * Status is per-round, so roundId is required. */ bulkUpdateStatus: adminProcedure .input( z.object({ ids: z.array(z.string()), + roundId: z.string(), status: z.enum([ 'SUBMITTED', 'ELIGIBLE', @@ -443,8 +570,11 @@ export const projectRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const updated = await ctx.prisma.project.updateMany({ - where: { id: { in: input.ids } }, + const updated = await ctx.prisma.roundProject.updateMany({ + where: { + projectId: { in: input.ids }, + roundId: input.roundId, + }, data: { status: input.status }, }) @@ -454,7 +584,7 @@ export const projectRouter = router({ userId: ctx.user.id, action: 'BULK_UPDATE_STATUS', entityType: 'Project', - detailsJson: { ids: input.ids, status: input.status }, + detailsJson: { ids: input.ids, roundId: input.roundId, status: input.status }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, @@ -462,4 +592,53 @@ export const projectRouter = router({ return { updated: updated.count } }), + + /** + * List projects in a program's pool (not assigned to any round) + */ + listPool: adminProcedure + .input( + z.object({ + programId: z.string(), + search: z.string().optional(), + page: z.number().int().min(1).default(1), + perPage: z.number().int().min(1).max(100).default(50), + }) + ) + .query(async ({ ctx, input }) => { + const { programId, search, page, perPage } = input + const skip = (page - 1) * perPage + + const where: Record = { + programId, + roundProjects: { none: {} }, + } + + if (search) { + where.OR = [ + { title: { contains: search, mode: 'insensitive' } }, + { teamName: { contains: search, mode: 'insensitive' } }, + ] + } + + const [projects, total] = await Promise.all([ + ctx.prisma.project.findMany({ + where, + skip, + take: perPage, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + title: true, + teamName: true, + country: true, + competitionCategory: true, + createdAt: true, + }, + }), + ctx.prisma.project.count({ where }), + ]) + + return { projects, total, page, perPage, totalPages: Math.ceil(total / perPage) } + }), }) diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts index db16d1d..a43c7dc 100644 --- a/src/server/routers/round.ts +++ b/src/server/routers/round.ts @@ -12,10 +12,10 @@ export const roundRouter = router({ .query(async ({ ctx, input }) => { return ctx.prisma.round.findMany({ where: { programId: input.programId }, - orderBy: { createdAt: 'asc' }, + orderBy: { sortOrder: 'asc' }, include: { _count: { - select: { projects: true, assignments: true }, + select: { roundProjects: true, assignments: true }, }, }, }) @@ -32,7 +32,7 @@ export const roundRouter = router({ include: { program: true, _count: { - select: { projects: true, assignments: true }, + select: { roundProjects: true, assignments: true }, }, evaluationForms: { where: { isActive: true }, @@ -64,7 +64,10 @@ export const roundRouter = router({ z.object({ programId: z.string(), name: z.string().min(1).max(255), + roundType: z.enum(['FILTERING', 'EVALUATION', 'LIVE_EVENT']).default('EVALUATION'), requiredReviews: z.number().int().min(1).max(10).default(3), + sortOrder: z.number().int().optional(), + settingsJson: z.record(z.unknown()).optional(), votingStartAt: z.date().optional(), votingEndAt: z.date().optional(), }) @@ -80,8 +83,23 @@ export const roundRouter = router({ } } + // Auto-set sortOrder if not provided (append to end) + let sortOrder = input.sortOrder + if (sortOrder === undefined) { + const maxOrder = await ctx.prisma.round.aggregate({ + where: { programId: input.programId }, + _max: { sortOrder: true }, + }) + sortOrder = (maxOrder._max.sortOrder ?? -1) + 1 + } + + const { settingsJson, sortOrder: _so, ...rest } = input const round = await ctx.prisma.round.create({ - data: input, + data: { + ...rest, + sortOrder, + settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined, + }, }) // Audit log @@ -91,7 +109,7 @@ export const roundRouter = router({ action: 'CREATE', entityType: 'Round', entityId: round.id, - detailsJson: input, + detailsJson: { ...rest, settingsJson } as Prisma.InputJsonValue, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, @@ -234,7 +252,7 @@ export const roundRouter = router({ .query(async ({ ctx, input }) => { const [totalProjects, totalAssignments, completedAssignments] = await Promise.all([ - ctx.prisma.project.count({ where: { roundId: input.id } }), + ctx.prisma.roundProject.count({ where: { roundId: input.id } }), ctx.prisma.assignment.count({ where: { roundId: input.id } }), ctx.prisma.assignment.count({ where: { roundId: input.id, isCompleted: true }, @@ -365,7 +383,7 @@ export const roundRouter = router({ const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.id }, include: { - _count: { select: { projects: true, assignments: true } }, + _count: { select: { roundProjects: true, assignments: true } }, }, }) @@ -383,7 +401,7 @@ export const roundRouter = router({ detailsJson: { name: round.name, status: round.status, - projectsDeleted: round._count.projects, + projectsDeleted: round._count.roundProjects, assignmentsDeleted: round._count.assignments, }, ipAddress: ctx.ip, @@ -408,4 +426,202 @@ export const roundRouter = router({ }) return count > 0 }), + + /** + * Assign projects from the program pool to a round + */ + assignProjects: adminProcedure + .input( + z.object({ + roundId: z.string(), + projectIds: z.array(z.string()).min(1), + }) + ) + .mutation(async ({ ctx, input }) => { + // Verify round exists and get programId + const round = await ctx.prisma.round.findUniqueOrThrow({ + where: { id: input.roundId }, + }) + + // Verify all projects belong to the same program + const projects = await ctx.prisma.project.findMany({ + where: { id: { in: input.projectIds }, programId: round.programId }, + select: { id: true }, + }) + + if (projects.length !== input.projectIds.length) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Some projects do not belong to this program', + }) + } + + // Create RoundProject entries (skip duplicates) + const created = await ctx.prisma.roundProject.createMany({ + data: input.projectIds.map((projectId) => ({ + roundId: input.roundId, + projectId, + status: 'SUBMITTED' as const, + })), + skipDuplicates: true, + }) + + // Audit log + await ctx.prisma.auditLog.create({ + data: { + userId: ctx.user.id, + action: 'ASSIGN_PROJECTS_TO_ROUND', + entityType: 'Round', + entityId: input.roundId, + detailsJson: { projectCount: created.count }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }, + }) + + return { assigned: created.count } + }), + + /** + * Remove projects from a round + */ + removeProjects: adminProcedure + .input( + z.object({ + roundId: z.string(), + projectIds: z.array(z.string()).min(1), + }) + ) + .mutation(async ({ ctx, input }) => { + const deleted = await ctx.prisma.roundProject.deleteMany({ + where: { + roundId: input.roundId, + projectId: { in: input.projectIds }, + }, + }) + + // Audit log + await ctx.prisma.auditLog.create({ + data: { + userId: ctx.user.id, + action: 'REMOVE_PROJECTS_FROM_ROUND', + entityType: 'Round', + entityId: input.roundId, + detailsJson: { projectCount: deleted.count }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }, + }) + + return { removed: deleted.count } + }), + + /** + * Advance projects from one round to the next + * Creates new RoundProject entries in the target round (keeps them in source round too) + */ + advanceProjects: adminProcedure + .input( + z.object({ + fromRoundId: z.string(), + toRoundId: z.string(), + projectIds: z.array(z.string()).min(1), + }) + ) + .mutation(async ({ ctx, input }) => { + // Verify both rounds exist and belong to the same program + const [fromRound, toRound] = await Promise.all([ + ctx.prisma.round.findUniqueOrThrow({ where: { id: input.fromRoundId } }), + ctx.prisma.round.findUniqueOrThrow({ where: { id: input.toRoundId } }), + ]) + + if (fromRound.programId !== toRound.programId) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Rounds must belong to the same program', + }) + } + + // Verify all projects are in the source round + const sourceProjects = await ctx.prisma.roundProject.findMany({ + where: { + roundId: input.fromRoundId, + projectId: { in: input.projectIds }, + }, + select: { projectId: true }, + }) + + if (sourceProjects.length !== input.projectIds.length) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Some projects are not in the source round', + }) + } + + // Create entries in target round (skip duplicates) + const created = await ctx.prisma.roundProject.createMany({ + data: input.projectIds.map((projectId) => ({ + roundId: input.toRoundId, + projectId, + status: 'SUBMITTED' as const, + })), + skipDuplicates: true, + }) + + // Audit log + await ctx.prisma.auditLog.create({ + data: { + userId: ctx.user.id, + action: 'ADVANCE_PROJECTS', + entityType: 'Round', + entityId: input.toRoundId, + detailsJson: { + fromRoundId: input.fromRoundId, + toRoundId: input.toRoundId, + projectCount: created.count, + }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }, + }) + + return { advanced: created.count } + }), + + /** + * Reorder rounds within a program + */ + reorder: adminProcedure + .input( + z.object({ + programId: z.string(), + roundIds: z.array(z.string()).min(1), + }) + ) + .mutation(async ({ ctx, input }) => { + // Update sortOrder for each round based on array position + await ctx.prisma.$transaction( + input.roundIds.map((roundId, index) => + ctx.prisma.round.update({ + where: { id: roundId }, + data: { sortOrder: index }, + }) + ) + ) + + // Audit log + await ctx.prisma.auditLog.create({ + data: { + userId: ctx.user.id, + action: 'REORDER_ROUNDS', + entityType: 'Program', + entityId: input.programId, + detailsJson: { roundIds: input.roundIds }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }, + }) + + return { success: true } + }), }) diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts index a9d357e..cd6884d 100644 --- a/src/server/routers/specialAward.ts +++ b/src/server/routers/specialAward.ts @@ -237,22 +237,29 @@ export const specialAwardRouter = router({ const statusFilter = input.includeSubmitted ? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const) : (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const) - const projects = await ctx.prisma.project.findMany({ + const roundProjectEntries = await ctx.prisma.roundProject.findMany({ where: { round: { programId: award.programId }, status: { in: [...statusFilter] }, }, - select: { - id: true, - title: true, - description: true, - competitionCategory: true, - country: true, - geographicZone: true, - tags: true, - oceanIssue: true, + include: { + project: { + select: { + id: true, + title: true, + description: true, + competitionCategory: true, + country: true, + geographicZone: true, + tags: true, + oceanIssue: true, + }, + }, }, }) + // Deduplicate projects (same project may be in multiple rounds) + const projectMap = new Map(roundProjectEntries.map((rp) => [rp.project.id, rp.project])) + const projects = Array.from(projectMap.values()) if (projects.length === 0) { throw new TRPCError({ diff --git a/src/server/routers/typeform-import.ts b/src/server/routers/typeform-import.ts index 453efeb..2bfe802 100644 --- a/src/server/routers/typeform-import.ts +++ b/src/server/routers/typeform-import.ts @@ -199,9 +199,9 @@ export const typeformImportRouter = router({ } // Create project - await ctx.prisma.project.create({ + const createdProject = await ctx.prisma.project.create({ data: { - roundId: round.id, + programId: round.programId, title: String(title).trim(), teamName: typeof teamName === 'string' ? teamName.trim() : null, description: typeof description === 'string' ? description : null, @@ -211,6 +211,14 @@ export const typeformImportRouter = router({ typeformResponseId: response.response_id, typeformFormId: input.formId, } as Prisma.InputJsonValue, + }, + }) + + // Create RoundProject entry + await ctx.prisma.roundProject.create({ + data: { + roundId: round.id, + projectId: createdProject.id, status: 'SUBMITTED', }, })