Decouple projects from rounds with RoundProject join table
Build and Push Docker Image / build (push) Successful in 8m16s Details

Projects now exist at the program level instead of being locked to a
single round. A new RoundProject join table enables many-to-many
relationships with per-round status tracking. Rounds have sortOrder
for configurable progression paths.

- Add RoundProject model, programId on Project, sortOrder on Round
- Migration preserves existing data (roundId -> RoundProject entries)
- Update all routers to query through RoundProject join
- Add assign/remove/advance/reorder round endpoints
- Add Assign, Advance, Remove Projects dialogs on round detail page
- Add round reorder controls (up/down arrows) on rounds list
- Show all rounds on project detail page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-02 22:33:55 +01:00
parent 0d2bc4db7e
commit fd5e5222da
52 changed files with 1892 additions and 326 deletions

View File

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

View File

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

View File

@ -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({

View File

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

View File

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

View File

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

View File

@ -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.')

View File

@ -329,7 +329,7 @@ export default function MemberDetailPage() {
</TableCell>
<TableCell>
<Badge variant="secondary">
{assignment.project.status}
{assignment.project.roundProjects?.[0]?.status ?? 'SUBMITTED'}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">

View File

@ -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) {
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{round._count.projects} projects &middot; {round._count.assignments} assignments
{round._count.roundProjects} projects &middot; {round._count.assignments} assignments
{round.totalEvals > 0 && (
<> &middot; {round.evalPercent}% evaluated</>
)}
@ -459,10 +461,10 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
{truncate(project.title, 45)}
</p>
<Badge
variant={statusColors[project.status] || 'secondary'}
variant={statusColors[project.roundProjects[0]?.status ?? 'SUBMITTED'] || 'secondary'}
className="shrink-0 text-[10px] px-1.5 py-0"
>
{project.status.replace('_', ' ')}
{(project.roundProjects[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">

View File

@ -138,7 +138,7 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
{round.status}
</Badge>
</TableCell>
<TableCell>{round._count.projects}</TableCell>
<TableCell>{round._count.roundProjects}</TableCell>
<TableCell>{round._count.assignments}</TableCell>
<TableCell>{formatDateOnly(round.createdAt)}</TableCell>
</TableRow>

View File

@ -96,7 +96,7 @@ export default function ProjectAssignmentsPage() {
</div>
</div>
<Button asChild>
<Link href={`/admin/rounds/${project?.roundId}/assignments`}>
<Link href={`/admin/rounds/${project?.roundProjects?.[0]?.round?.id}/assignments`}>
<Plus className="mr-2 h-4 w-4" />
Manage in Round
</Link>

View File

@ -121,7 +121,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
// Fetch existing tags for suggestions
const { data: existingTags } = trpc.project.getTags.useQuery({
roundId: project?.roundId,
roundId: project?.roundProjects?.[0]?.round?.id,
})
// Mutations
@ -162,7 +162,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
title: project.title,
teamName: project.teamName || '',
description: project.description || '',
status: project.status as UpdateProjectForm['status'],
status: (project.roundProjects?.[0]?.status ?? 'SUBMITTED') as UpdateProjectForm['status'],
tags: project.tags || [],
})
}
@ -197,6 +197,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
teamName: data.teamName || null,
description: data.description || null,
status: data.status,
roundId: project?.roundProjects?.[0]?.round?.id,
tags: data.tags,
})
}

View File

@ -139,20 +139,29 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
fallback="initials"
/>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
{project.roundProjects?.length > 0 ? (
project.roundProjects.map((rp, i) => (
<span key={rp.round.id} className="flex items-center gap-1">
{i > 0 && <span className="text-muted-foreground/50">/</span>}
<Link
href={`/admin/rounds/${project.round.id}`}
href={`/admin/rounds/${rp.round.id}`}
className="hover:underline"
>
{project.round.name}
{rp.round.name}
</Link>
</span>
))
) : (
<span>No round</span>
)}
</div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">
{project.title}
</h1>
<Badge variant={statusColors[project.status] || 'secondary'}>
{project.status.replace('_', ' ')}
<Badge variant={statusColors[project.roundProjects?.[0]?.status ?? 'SUBMITTED'] || 'secondary'}>
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</div>
{project.teamName && (
@ -504,7 +513,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/rounds/${project.roundId}/assignments`}>
<Link href={`/admin/rounds/${project.roundProjects?.[0]?.round?.id}/assignments`}>
Manage
</Link>
</Button>

View File

@ -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() {
</TabsList>
<TabsContent value="csv" className="mt-4">
<CSVImportForm
programId={selectedRound.programId}
roundId={selectedRoundId}
roundName={selectedRound.name}
onSuccess={() => {

View File

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

View File

@ -350,9 +350,9 @@ export default function ProjectsPage() {
</TableCell>
<TableCell>
<div>
<p>{project.round.name}</p>
<p>{project.roundProjects?.[0]?.round?.name ?? '-'}</p>
<p className="text-sm text-muted-foreground">
{project.round.program?.name}
{project.program?.name}
</p>
</div>
</TableCell>
@ -365,9 +365,9 @@ export default function ProjectsPage() {
</TableCell>
<TableCell>
<Badge
variant={statusColors[project.status] || 'secondary'}
variant={statusColors[project.roundProjects?.[0]?.status ?? 'SUBMITTED'] || 'secondary'}
>
{project.status.replace('_', ' ')}
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="relative z-10 text-right">
@ -431,11 +431,11 @@ export default function ProjectsPage() {
</CardTitle>
<Badge
variant={
statusColors[project.status] || 'secondary'
statusColors[project.roundProjects?.[0]?.status ?? 'SUBMITTED'] || 'secondary'
}
className="shrink-0"
>
{project.status.replace('_', ' ')}
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</div>
<CardDescription>{project.teamName}</CardDescription>
@ -445,7 +445,7 @@ export default function ProjectsPage() {
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<span>{project.round.name}</span>
<span>{project.roundProjects?.[0]?.round?.name ?? '-'}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span>

View File

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

View File

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

View File

@ -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 }) {
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{round._count.projects}</div>
<div className="text-2xl font-bold">{round._count.roundProjects}</div>
<Button variant="link" size="sm" className="px-0" asChild>
<Link href={`/admin/projects?round=${round.id}`}>View projects</Link>
</Button>
@ -423,9 +432,43 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
View Projects
</Link>
</Button>
<Button variant="outline" onClick={() => setAssignOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Assign Projects
</Button>
<Button variant="outline" onClick={() => setAdvanceOpen(true)}>
<ArrowRightCircle className="mr-2 h-4 w-4" />
Advance Projects
</Button>
<Button variant="outline" onClick={() => setRemoveOpen(true)}>
<Minus className="mr-2 h-4 w-4" />
Remove Projects
</Button>
</div>
</CardContent>
</Card>
{/* Dialogs */}
<AssignProjectsDialog
roundId={roundId}
programId={round.program.id}
open={assignOpen}
onOpenChange={setAssignOpen}
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
/>
<AdvanceProjectsDialog
roundId={roundId}
programId={round.program.id}
open={advanceOpen}
onOpenChange={setAdvanceOpen}
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
/>
<RemoveProjectsDialog
roundId={roundId}
open={removeOpen}
onOpenChange={setRemoveOpen}
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
/>
</div>
)
}

View File

@ -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<Record<string, unknown>>({})
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() {
</CardContent>
</Card>
{/* Round Type & Settings */}
<RoundTypeSettings
roundType={roundType}
onRoundTypeChange={setRoundType}
settings={roundSettings}
onSettingsChange={setRoundSettings}
/>
<Card>
<CardHeader>
<CardTitle className="text-lg">Voting Window</CardTitle>

View File

@ -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() {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Order</TableHead>
<TableHead>Round</TableHead>
<TableHead>Status</TableHead>
<TableHead>Voting Window</TableHead>
@ -115,8 +118,15 @@ function RoundsContent() {
</TableRow>
</TableHeader>
<TableBody>
{program.rounds.map((round) => (
<RoundRow key={round.id} round={round} />
{program.rounds.map((round, index) => (
<RoundRow
key={round.id}
round={round}
index={index}
totalRounds={program.rounds.length}
allRoundIds={program.rounds.map((r) => r.id)}
programId={program.id}
/>
))}
</TableBody>
</Table>
@ -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 (
<TableRow>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={moveUp}
disabled={index === 0 || reorder.isPending}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={moveDown}
disabled={index === totalRounds - 1 || reorder.isPending}
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
</TableCell>
<TableCell>
<Link
href={`/admin/rounds/${round.id}`}
@ -242,7 +304,7 @@ function RoundRow({ round }: { round: any }) {
<TableCell>
<div className="flex items-center gap-1">
<FileText className="h-4 w-4 text-muted-foreground" />
{round._count?.projects || 0}
{round._count?.roundProjects || 0}
</div>
</TableCell>
<TableCell>
@ -325,9 +387,9 @@ function RoundRow({ round }: { round: any }) {
<AlertDialogTitle>Delete Round</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{round.name}&quot;? 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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View File

@ -56,7 +56,6 @@ async function AssignmentsContent({
title: true,
teamName: true,
description: true,
status: true,
files: {
select: {
id: true,

View File

@ -45,7 +45,6 @@ async function JuryDashboardContent() {
id: true,
title: true,
teamName: true,
status: true,
},
},
round: {

View File

@ -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 (
<div className="space-y-6">
@ -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

View File

@ -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 (
<div className="space-y-6">
@ -145,7 +148,7 @@ async function EvaluationContent({ projectId }: { projectId: string }) {
const criterionScores =
(evaluation.criterionScoresJson as unknown as Record<string, number>) || {}
const round = project.round
const round = assignment.round
return (
<div className="space-y-6">

View File

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

View File

@ -149,18 +149,24 @@ export default function MentorDashboard() {
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
{project.round.program.year} Edition
{project.program.year} Edition
</span>
{project.roundProjects?.[0]?.round && (
<>
<span></span>
<span>{project.round.name}</span>
<span>{project.roundProjects[0].round.name}</span>
</>
)}
</div>
<CardTitle className="flex items-center gap-2">
{project.title}
{project.roundProjects?.[0]?.status && (
<Badge
variant={statusColors[project.status] || 'secondary'}
variant={statusColors[project.roundProjects[0].status] || 'secondary'}
>
{project.status.replace('_', ' ')}
{project.roundProjects[0].status.replace('_', ' ')}
</Badge>
)}
</CardTitle>
{project.teamName && (
<CardDescription>{project.teamName}</CardDescription>

View File

@ -109,18 +109,24 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
{project.round.program.year} Edition
{project.program.year} Edition
</span>
{project.roundProjects?.[0]?.round && (
<>
<span></span>
<span>{project.round.name}</span>
<span>{project.roundProjects[0].round.name}</span>
</>
)}
</div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">
{project.title}
</h1>
<Badge variant={statusColors[project.status] || 'secondary'}>
{project.status.replace('_', ' ')}
{project.roundProjects?.[0]?.status && (
<Badge variant={statusColors[project.roundProjects[0].status] || 'secondary'}>
{project.roundProjects[0].status.replace('_', ' ')}
</Badge>
)}
</div>
{project.teamName && (
<p className="text-muted-foreground">{project.teamName}</p>

View File

@ -94,16 +94,22 @@ export default function MentorProjectsPage() {
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
{project.round.program.year} Edition
{project.program.year} Edition
</span>
{project.roundProjects?.[0]?.round && (
<>
<span></span>
<span>{project.round.name}</span>
<span>{project.roundProjects[0].round.name}</span>
</>
)}
</div>
<CardTitle className="flex items-center gap-2">
{project.title}
<Badge variant={statusColors[project.status] || 'secondary'}>
{project.status.replace('_', ' ')}
{project.roundProjects?.[0]?.status && (
<Badge variant={statusColors[project.roundProjects[0].status] || 'secondary'}>
{project.roundProjects[0].status.replace('_', ' ')}
</Badge>
)}
</CardTitle>
{project.teamName && (
<CardDescription>{project.teamName}</CardDescription>

View File

@ -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() {
</p>
</div>
<div className="text-right text-sm">
<p>{round._count.projects} projects</p>
<p>{round._count.roundProjects} projects</p>
<p className="text-muted-foreground">
{round._count.assignments} assignments
</p>

View File

@ -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() {
</div>
</TableCell>
<TableCell>{round.program.name}</TableCell>
<TableCell>{round._count.projects}</TableCell>
<TableCell>{round._count.roundProjects}</TableCell>
<TableCell>
<div className="min-w-[120px] space-y-1">
<div className="flex justify-between text-sm">
@ -237,7 +237,7 @@ async function ReportsContent() {
</p>
)}
<div className="flex items-center justify-between text-sm">
<span>{round._count.projects} projects</span>
<span>{round._count.roundProjects} projects</span>
<span className="text-muted-foreground">
{round.completedEvaluations}/{round.totalAssignments} evaluations
</span>

View File

@ -132,7 +132,7 @@ export function SubmissionDetailClient() {
</Badge>
</div>
<p className="text-muted-foreground">
{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}` : ''}
</p>
</div>
</div>

View File

@ -131,18 +131,24 @@ export function MySubmissionClient() {
</Card>
) : (
<div className="space-y-4">
{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 (
<Card key={project.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{project.title}</CardTitle>
<CardDescription>
{project.round.program.year} Edition - {project.round.name}
{programYear ? `${programYear} Edition` : ''}{roundName ? ` - ${roundName}` : ''}
</CardDescription>
</div>
<Badge variant={statusColors[project.status] || 'secondary'}>
{project.status.replace('_', ' ')}
<Badge variant={statusColors[projectStatus] || 'secondary'}>
{projectStatus.replace('_', ' ')}
</Badge>
</div>
</CardHeader>
@ -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"
/>
</div>
@ -229,7 +235,8 @@ export function MySubmissionClient() {
</div>
</CardContent>
</Card>
))}
)
})}
</div>
)}
</div>

View File

@ -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<Set<string>>(new Set())
const [targetRoundId, setTargetRoundId] = useState<string>('')
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ArrowRightCircle className="h-5 w-5" />
Advance Projects
</DialogTitle>
<DialogDescription>
Select projects to advance to the next round. Projects will remain
visible in the current round.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label>Target Round</Label>
{otherRounds.length === 0 ? (
<p className="text-sm text-muted-foreground">
No other rounds available in this program. Create another round first.
</p>
) : (
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select target round" />
</SelectTrigger>
<SelectContent>
{otherRounds.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name} (Order: {r.sortOrder})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="flex items-start gap-2 rounded-lg bg-blue-50 p-3 text-sm text-blue-800 dark:bg-blue-950/50 dark:text-blue-200">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<span>
Projects will be copied to the target round with &quot;Submitted&quot; status.
They will remain in the current round with their existing status.
</span>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="font-medium">No projects in this round</p>
<p className="text-sm text-muted-foreground">
Assign projects to this round first.
</p>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedIds.size === projects.length && projects.length > 0}
onCheckedChange={toggleAll}
/>
<span className="text-sm text-muted-foreground">
{selectedIds.size} of {projects.length} selected
</span>
</div>
<div className="rounded-lg border max-h-[300px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12" />
<TableHead>Project</TableHead>
<TableHead>Team</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => (
<TableRow
key={project.id}
className={selectedIds.has(project.id) ? 'bg-muted/50' : 'cursor-pointer'}
onClick={() => toggleProject(project.id)}
>
<TableCell>
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => toggleProject(project.id)}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
<TableCell className="font-medium">
{project.title}
</TableCell>
<TableCell className="text-muted-foreground">
{project.teamName || '—'}
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-xs">
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleAdvance}
disabled={
selectedIds.size === 0 ||
!targetRoundId ||
advanceMutation.isPending
}
>
{advanceMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ArrowRightCircle className="mr-2 h-4 w-4" />
)}
Advance Selected ({selectedIds.size})
{targetRoundName ? ` to ${targetRoundName}` : ''}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -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<Set<string>>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
Assign Projects to Round
</DialogTitle>
<DialogDescription>
Select projects from the program to add to this round.
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No available projects</p>
<p className="text-sm text-muted-foreground">
All program projects are already in this round.
</p>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedIds.size === projects.length && projects.length > 0}
onCheckedChange={toggleAll}
/>
<span className="text-sm text-muted-foreground">
{selectedIds.size} of {projects.length} selected
</span>
</div>
</div>
<div className="rounded-lg border max-h-[400px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12" />
<TableHead>Project</TableHead>
<TableHead>Team</TableHead>
<TableHead>Country</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => (
<TableRow
key={project.id}
className={selectedIds.has(project.id) ? 'bg-muted/50' : 'cursor-pointer'}
onClick={() => toggleProject(project.id)}
>
<TableCell>
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => toggleProject(project.id)}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
<TableCell className="font-medium">
{project.title}
</TableCell>
<TableCell className="text-muted-foreground">
{project.teamName || '—'}
</TableCell>
<TableCell>
{project.country ? (
<Badge variant="outline" className="text-xs">
{project.country}
</Badge>
) : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleAssign}
disabled={selectedIds.size === 0 || assignMutation.isPending}
>
{assignMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Assign Selected ({selectedIds.size})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -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<Set<string>>(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 (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Minus className="h-5 w-5" />
Remove Projects from Round
</DialogTitle>
<DialogDescription>
Select projects to remove from this round. The projects will remain
in the program and can be re-assigned later.
</DialogDescription>
</DialogHeader>
<div className="flex items-start gap-2 rounded-lg bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-950/50 dark:text-amber-200">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<span>
Removing projects from a round will also delete their jury
assignments and evaluations in this round.
</span>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="font-medium">No projects in this round</p>
<p className="text-sm text-muted-foreground">
There are no projects to remove.
</p>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedIds.size === projects.length && projects.length > 0}
onCheckedChange={toggleAll}
/>
<span className="text-sm text-muted-foreground">
{selectedIds.size} of {projects.length} selected
</span>
</div>
<div className="rounded-lg border max-h-[350px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12" />
<TableHead>Project</TableHead>
<TableHead>Team</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => (
<TableRow
key={project.id}
className={selectedIds.has(project.id) ? 'bg-muted/50' : 'cursor-pointer'}
onClick={() => toggleProject(project.id)}
>
<TableCell>
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => toggleProject(project.id)}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
<TableCell className="font-medium">
{project.title}
</TableCell>
<TableCell className="text-muted-foreground">
{project.teamName || '—'}
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-xs">
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => setConfirmOpen(true)}
disabled={selectedIds.size === 0 || removeMutation.isPending}
>
{removeMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Minus className="mr-2 h-4 w-4" />
)}
Remove Selected ({selectedIds.size})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Removal</AlertDialogTitle>
<AlertDialogDescription>
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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRemove}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove Projects
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -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<string, unknown>
}
export function CSVImportForm({ roundId, roundName, onSuccess }: CSVImportFormProps) {
export function CSVImportForm({ programId, roundId, roundName, onSuccess }: CSVImportFormProps) {
const router = useRouter()
const [step, setStep] = useState<Step>('upload')
const [file, setFile] = useState<File | null>(null)
@ -224,6 +225,7 @@ export function CSVImportForm({ roundId, roundName, onSuccess }: CSVImportFormPr
try {
await importMutation.mutateAsync({
programId,
roundId,
projects: valid,
})

View File

@ -148,8 +148,10 @@ 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: {
project: {
include: {
assignments: {
include: {
@ -159,11 +161,14 @@ 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'],

View File

@ -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,11 +74,16 @@ export const applicantRouter = router({
},
include: {
files: true,
roundProjects: {
where: { roundId: input.roundId },
include: {
round: {
include: {
program: { select: { name: true, year: true } },
},
},
},
},
teamMembers: {
include: {
user: {
@ -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: {
@ -385,12 +411,17 @@ export const applicantRouter = router({
},
],
},
include: {
roundProjects: {
include: {
round: {
include: {
program: { select: { name: true, year: true } },
},
},
},
orderBy: { addedAt: 'desc' },
},
files: true,
teamMembers: {
include: {
@ -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,
}
}),
@ -473,12 +508,17 @@ export const applicantRouter = router({
},
],
},
include: {
roundProjects: {
include: {
round: {
include: {
program: { select: { name: true, year: true } },
},
},
},
orderBy: { addedAt: 'desc' },
},
files: true,
teamMembers: {
include: {

View File

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

View File

@ -286,13 +286,17 @@ export const assignmentRouter = router({
where: { roundId: input.roundId },
_count: true,
}),
ctx.prisma.project.findMany({
ctx.prisma.roundProject.findMany({
where: { roundId: input.roundId },
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 },
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,8 +491,10 @@ 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 },
include: {
project: {
select: {
id: true,
title: true,
@ -492,7 +503,10 @@ export const assignmentRouter = router({
teamName: true,
_count: { select: { assignments: true } },
},
},
},
})
const projects = roundProjectEntries.map((rp) => rp.project)
// Get existing assignments
const existingAssignments = await ctx.prisma.assignment.findMany({

View File

@ -103,8 +103,10 @@ 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: {
project: {
include: {
assignments: {
include: {
@ -114,10 +116,13 @@ export const exportRouter = router({
},
},
},
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:

View File

@ -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: {
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' },
}),
])

View File

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

View File

@ -15,13 +15,17 @@ export const liveVotingRouter = router({
round: {
include: {
program: { select: { name: true, year: true } },
projects: {
roundProjects: {
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
include: {
project: {
select: { id: true, title: true, teamName: true },
},
},
},
},
},
},
})
if (!session) {
@ -34,13 +38,17 @@ export const liveVotingRouter = router({
round: {
include: {
program: { select: { name: true, year: true } },
projects: {
roundProjects: {
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
include: {
project: {
select: { id: true, title: true, teamName: true },
},
},
},
},
},
},
})
}

View File

@ -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,
},
@ -430,12 +430,19 @@ export const mentorRouter = router({
where: { mentorId: ctx.user.id },
include: {
project: {
include: {
program: { select: { name: true, year: true } },
roundProjects: {
include: {
round: {
include: {
program: { select: { name: true, year: true } },
},
},
},
orderBy: { addedAt: 'desc' },
take: 1,
},
teamMembers: {
include: {
user: { select: { id: true, name: true, email: true } },
@ -476,12 +483,19 @@ export const mentorRouter = router({
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: {
program: { select: { id: true, name: true, year: true } },
roundProjects: {
include: {
round: {
include: {
program: { select: { id: true, name: true, year: true } },
},
},
},
orderBy: { addedAt: 'desc' },
take: 1,
},
teamMembers: {
include: {
user: {
@ -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: {

View File

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

View File

@ -40,7 +40,7 @@ export const programRouter = router({
orderBy: { createdAt: 'asc' },
include: {
_count: {
select: { projects: true, assignments: true },
select: { roundProjects: true, assignments: true },
},
},
},

View File

@ -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<string, unknown> = {}
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<string, unknown> || {}),
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<string, unknown> || {}),
some: {
...((where.roundProjects as Record<string, unknown>)?.some as Record<string, unknown> || {}),
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<string, unknown>) || {}),
some: { userId: ctx.user.id },
@ -105,8 +146,16 @@ export const projectRouter = router({
orderBy: { createdAt: 'desc' },
include: {
files: true,
program: {
select: { id: true, name: true, year: true },
},
roundProjects: {
include: {
round: {
select: { id: true, name: true, program: { select: { name: true, year: true } } },
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<string, unknown> = {}
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<string, unknown> = {
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) }
}),
})

View File

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

View File

@ -237,11 +237,13 @@ 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] },
},
include: {
project: {
select: {
id: true,
title: true,
@ -252,7 +254,12 @@ export const specialAwardRouter = router({
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({

View File

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