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({ const rounds = await prisma.round.findMany({
include: { include: {
_count: { select: { projects: true } } _count: { select: { roundProjects: true } }
} }
}) })
for (const r of rounds) { for (const r of rounds) {
console.log(`Round: ${r.name} (id: ${r.id})`) 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 // Check if projects have programId set
const projectsWithRound = await prisma.project.findMany({ const sampleProjects = await prisma.project.findMany({
select: { id: true, title: true, roundId: true }, select: { id: true, title: true, programId: true },
take: 5 take: 5
}) })
console.log('\nSample projects:') console.log('\nSample projects:')
for (const p of projectsWithRound) { for (const p of sampleProjects) {
console.log(` ${p.title}: roundId=${p.roundId}`) console.log(` ${p.title}: programId=${p.programId}`)
} }
} }

View File

@ -10,18 +10,18 @@ async function cleanup() {
id: true, id: true,
name: true, name: true,
slug: true, slug: true,
projects: { select: { id: true, title: true } }, roundProjects: { select: { id: true, projectId: true, project: { select: { id: true, title: true } } } },
_count: { select: { projects: true } } _count: { select: { roundProjects: true } }
} }
}) })
console.log(`Found ${rounds.length} rounds:`) console.log(`Found ${rounds.length} rounds:`)
for (const round of 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) // 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) { if (dummyRounds.length > 0) {
console.log(`\nDeleting ${dummyRounds.length} dummy round(s)...`) console.log(`\nDeleting ${dummyRounds.length} dummy round(s)...`)
@ -29,10 +29,16 @@ async function cleanup() {
for (const round of dummyRounds) { for (const round of dummyRounds) {
console.log(`\nProcessing: ${round.name}`) 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) { 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({ const teamDeleted = await prisma.teamMember.deleteMany({
where: { projectId: { in: projectIds } } where: { projectId: { in: projectIds } }
}) })

View File

@ -8,15 +8,15 @@ async function cleanup() {
// Find and delete the dummy round // Find and delete the dummy round
const dummyRound = await prisma.round.findFirst({ const dummyRound = await prisma.round.findFirst({
where: { slug: 'round-1-2026' }, where: { slug: 'round-1-2026' },
include: { projects: true } include: { roundProjects: { include: { project: true } } }
}) })
if (dummyRound) { if (dummyRound) {
console.log(`Found dummy round: ${dummyRound.name}`) 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 // 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 // Delete team members for these projects
if (projectIds.length > 0) { if (projectIds.length > 0) {
@ -25,11 +25,11 @@ async function cleanup() {
}) })
console.log(`Deleted ${teamDeleted.count} team members`) console.log(`Deleted ${teamDeleted.count} team members`)
// Disconnect projects from round first // Delete round-project associations
await prisma.round.update({ await prisma.roundProject.deleteMany({
where: { id: dummyRound.id }, where: { roundId: dummyRound.id }
data: { projects: { disconnect: projectIds.map(id => ({ id })) } }
}) })
console.log(`Deleted round-project associations`)
// Delete the projects // Delete the projects
const projDeleted = await prisma.project.deleteMany({ 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 // Relations
rounds Round[] rounds Round[]
projects Project[]
learningResources LearningResource[] learningResources LearningResource[]
partners Partner[] partners Partner[]
applicationForms ApplicationForm[] applicationForms ApplicationForm[]
@ -351,6 +352,7 @@ model Round {
slug String? @unique // URL-friendly identifier for public submissions slug String? @unique // URL-friendly identifier for public submissions
status RoundStatus @default(DRAFT) status RoundStatus @default(DRAFT)
roundType RoundType @default(EVALUATION) roundType RoundType @default(EVALUATION)
sortOrder Int @default(0) // Progression order within program
// Submission window (for applicant portal) // Submission window (for applicant portal)
submissionDeadline DateTime? // Deadline for project submissions submissionDeadline DateTime? // Deadline for project submissions
@ -375,7 +377,7 @@ model Round {
// Relations // Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade) program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
projects Project[] roundProjects RoundProject[]
assignments Assignment[] assignments Assignment[]
evaluationForms EvaluationForm[] evaluationForms EvaluationForm[]
gracePeriods GracePeriod[] gracePeriods GracePeriod[]
@ -419,13 +421,12 @@ model EvaluationForm {
model Project { model Project {
id String @id @default(cuid()) id String @id @default(cuid())
roundId String programId String
// Core fields // Core fields
title String title String
teamName String? teamName String?
description String? @db.Text description String? @db.Text
status ProjectStatus @default(SUBMITTED)
// Competition category // Competition category
competitionCategory CompetitionCategory? competitionCategory CompetitionCategory?
@ -474,7 +475,8 @@ model Project {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Relations // Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
roundProjects RoundProject[]
files ProjectFile[] files ProjectFile[]
assignments Assignment[] assignments Assignment[]
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull) submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
@ -485,8 +487,7 @@ model Project {
awardVotes AwardVote[] awardVotes AwardVote[]
wonAwards SpecialAward[] @relation("AwardWinner") wonAwards SpecialAward[] @relation("AwardWinner")
@@index([roundId]) @@index([programId])
@@index([status])
@@index([tags]) @@index([tags])
@@index([submissionSource]) @@index([submissionSource])
@@index([submittedByUserId]) @@index([submittedByUserId])
@ -495,6 +496,23 @@ model Project {
@@index([country]) @@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 { model ProjectFile {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String projectId String

View File

@ -321,7 +321,7 @@ async function main() {
// Check if project already exists // Check if project already exists
const existingProject = await prisma.project.findFirst({ const existingProject = await prisma.project.findFirst({
where: { where: {
roundId: round.id, programId: program.id,
OR: [ OR: [
{ title: projectName }, { title: projectName },
{ submittedByEmail: email }, { submittedByEmail: email },
@ -365,10 +365,9 @@ async function main() {
// Create project // Create project
const project = await prisma.project.create({ const project = await prisma.project.create({
data: { data: {
roundId: round.id, programId: program.id,
title: projectName, title: projectName,
description: row['Comment ']?.trim() || null, description: row['Comment ']?.trim() || null,
status: 'SUBMITTED',
competitionCategory: mapCategory(row['Category']), competitionCategory: mapCategory(row['Category']),
oceanIssue: mapOceanIssue(row['Issue']), oceanIssue: mapOceanIssue(row['Issue']),
country: extractCountry(row['Country']), 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 // Create team lead membership
await prisma.teamMember.create({ await prisma.teamMember.create({
data: { data: {
@ -466,7 +474,7 @@ async function main() {
console.log('\nBackfilling missing country codes...\n') console.log('\nBackfilling missing country codes...\n')
let backfilled = 0 let backfilled = 0
const nullCountryProjects = await prisma.project.findMany({ 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 }, 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`) console.log(`Voting window: ${votingStart.toISOString()}${votingEnd.toISOString()}\n`)
// Get some projects to assign // Get some projects to assign (via RoundProject)
const projects = await prisma.project.findMany({ const roundProjects = await prisma.roundProject.findMany({
where: { roundId: round.id }, where: { roundId: round.id },
take: 8, take: 8,
orderBy: { createdAt: 'desc' }, orderBy: { addedAt: 'desc' },
select: { id: true, title: true }, select: { project: { select: { id: true, title: true } } },
}) })
const projects = roundProjects.map(rp => rp.project)
if (projects.length === 0) { if (projects.length === 0) {
console.error('No projects found! Run seed-candidatures first.') console.error('No projects found! Run seed-candidatures first.')

View File

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

View File

@ -112,11 +112,11 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
where: { programId: editionId }, where: { programId: editionId },
}), }),
prisma.project.count({ prisma.project.count({
where: { round: { programId: editionId } }, where: { programId: editionId },
}), }),
prisma.project.count({ prisma.project.count({
where: { where: {
round: { programId: editionId }, programId: editionId,
createdAt: { gte: sevenDaysAgo }, createdAt: { gte: sevenDaysAgo },
}, },
}), }),
@ -149,7 +149,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
include: { include: {
_count: { _count: {
select: { select: {
projects: true, roundProjects: true,
assignments: true, assignments: true,
}, },
}, },
@ -161,31 +161,33 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
}, },
}), }),
prisma.project.findMany({ prisma.project.findMany({
where: { round: { programId: editionId } }, where: { programId: editionId },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 8, take: 8,
select: { select: {
id: true, id: true,
title: true, title: true,
teamName: true, teamName: true,
status: true,
country: true, country: true,
competitionCategory: true, competitionCategory: true,
oceanIssue: true, oceanIssue: true,
logoKey: true, logoKey: true,
createdAt: true, createdAt: true,
submittedAt: true, submittedAt: true,
round: { select: { name: true } }, roundProjects: {
select: { status: true, round: { select: { name: true } } },
take: 1,
},
}, },
}), }),
prisma.project.groupBy({ prisma.project.groupBy({
by: ['competitionCategory'], by: ['competitionCategory'],
where: { round: { programId: editionId } }, where: { programId: editionId },
_count: true, _count: true,
}), }),
prisma.project.groupBy({ prisma.project.groupBy({
by: ['oceanIssue'], by: ['oceanIssue'],
where: { round: { programId: editionId } }, where: { programId: editionId },
_count: true, _count: true,
}), }),
]) ])
@ -392,7 +394,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
</Badge> </Badge>
</div> </div>
<p className="text-sm text-muted-foreground"> <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 && ( {round.totalEvals > 0 && (
<> &middot; {round.evalPercent}% evaluated</> <> &middot; {round.evalPercent}% evaluated</>
)} )}
@ -459,10 +461,10 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
{truncate(project.title, 45)} {truncate(project.title, 45)}
</p> </p>
<Badge <Badge
variant={statusColors[project.status] || 'secondary'} variant={statusColors[project.roundProjects[0]?.status ?? 'SUBMITTED'] || 'secondary'}
className="shrink-0 text-[10px] px-1.5 py-0" className="shrink-0 text-[10px] px-1.5 py-0"
> >
{project.status.replace('_', ' ')} {(project.roundProjects[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge> </Badge>
</div> </div>
<p className="text-xs text-muted-foreground mt-0.5"> <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} {round.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>{round._count.projects}</TableCell> <TableCell>{round._count.roundProjects}</TableCell>
<TableCell>{round._count.assignments}</TableCell> <TableCell>{round._count.assignments}</TableCell>
<TableCell>{formatDateOnly(round.createdAt)}</TableCell> <TableCell>{formatDateOnly(round.createdAt)}</TableCell>
</TableRow> </TableRow>

View File

@ -96,7 +96,7 @@ export default function ProjectAssignmentsPage() {
</div> </div>
</div> </div>
<Button asChild> <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" /> <Plus className="mr-2 h-4 w-4" />
Manage in Round Manage in Round
</Link> </Link>

View File

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

View File

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

View File

@ -43,6 +43,7 @@ function ImportPageContent() {
const rounds = programs?.flatMap((p) => const rounds = programs?.flatMap((p) =>
(p.rounds || []).map((r) => ({ (p.rounds || []).map((r) => ({
...r, ...r,
programId: p.id,
programName: `${p.year} Edition`, programName: `${p.year} Edition`,
})) }))
) || [] ) || []
@ -170,6 +171,7 @@ function ImportPageContent() {
</TabsList> </TabsList>
<TabsContent value="csv" className="mt-4"> <TabsContent value="csv" className="mt-4">
<CSVImportForm <CSVImportForm
programId={selectedRound.programId}
roundId={selectedRoundId} roundId={selectedRoundId}
roundName={selectedRound.name} roundName={selectedRound.name}
onSuccess={() => { onSuccess={() => {

View File

@ -73,6 +73,7 @@ function NewProjectPageContent() {
const rounds = programs?.flatMap((p) => const rounds = programs?.flatMap((p) =>
(p.rounds || []).map((r) => ({ (p.rounds || []).map((r) => ({
...r, ...r,
programId: p.id,
programName: `${p.year} Edition`, programName: `${p.year} Edition`,
})) }))
) || [] ) || []
@ -117,6 +118,7 @@ function NewProjectPageContent() {
}) })
createProject.mutate({ createProject.mutate({
programId: selectedRound!.programId,
roundId: selectedRoundId, roundId: selectedRoundId,
title: title.trim(), title: title.trim(),
teamName: teamName.trim() || undefined, teamName: teamName.trim() || undefined,

View File

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

View File

@ -72,7 +72,7 @@ export interface ProjectFilters {
} }
interface FilterOptions { 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[] countries: string[]
categories: Array<{ value: string; count: number }> categories: Array<{ value: string; count: number }>
issues: 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) { if (storedOrder.length > 0) {
setProjectOrder(storedOrder) setProjectOrder(storedOrder)
} else { } else {
setProjectOrder(sessionData.round.projects.map((p) => p.id)) setProjectOrder(sessionData.round.roundProjects.map((rp) => rp.project.id))
} }
} }
}, [sessionData]) }, [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 const sortedProjects = projectOrder
.map((id) => projects.find((p) => p.id === id)) .map((id) => projects.find((p) => p.id === id))
.filter((p): p is Project => !!p) .filter((p): p is Project => !!p)

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { Suspense, use } from 'react' import { Suspense, use, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
@ -44,8 +44,14 @@ import {
Filter, Filter,
Trash2, Trash2,
Loader2, Loader2,
Plus,
ArrowRightCircle,
Minus,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' 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' import { format, formatDistanceToNow, isPast, isFuture } from 'date-fns'
interface PageProps { interface PageProps {
@ -54,6 +60,9 @@ interface PageProps {
function RoundDetailContent({ roundId }: { roundId: string }) { function RoundDetailContent({ roundId }: { roundId: string }) {
const router = useRouter() 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: round, isLoading } = trpc.round.get.useQuery({ id: roundId })
const { data: progress } = trpc.round.getProgress.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" /> <FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <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> <Button variant="link" size="sm" className="px-0" asChild>
<Link href={`/admin/projects?round=${round.id}`}>View projects</Link> <Link href={`/admin/projects?round=${round.id}`}>View projects</Link>
</Button> </Button>
@ -423,9 +432,43 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
View Projects View Projects
</Link> </Link>
</Button> </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> </div>
</CardContent> </CardContent>
</Card> </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> </div>
) )
} }

View File

@ -16,7 +16,6 @@ import {
} from '@/components/ui/card' } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { import {
Select, Select,
SelectContent, SelectContent,
@ -34,6 +33,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
import { ArrowLeft, Loader2, AlertCircle } from 'lucide-react' import { ArrowLeft, Loader2, AlertCircle } from 'lucide-react'
const createRoundSchema = z.object({ const createRoundSchema = z.object({
@ -58,6 +58,8 @@ function CreateRoundContent() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const programIdParam = searchParams.get('program') 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() const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery()
@ -82,7 +84,9 @@ function CreateRoundContent() {
await createRound.mutateAsync({ await createRound.mutateAsync({
programId: data.programId, programId: data.programId,
name: data.name, name: data.name,
roundType,
requiredReviews: data.requiredReviews, requiredReviews: data.requiredReviews,
settingsJson: roundSettings,
votingStartAt: data.votingStartAt ? new Date(data.votingStartAt) : undefined, votingStartAt: data.votingStartAt ? new Date(data.votingStartAt) : undefined,
votingEndAt: data.votingEndAt ? new Date(data.votingEndAt) : undefined, votingEndAt: data.votingEndAt ? new Date(data.votingEndAt) : undefined,
}) })
@ -218,6 +222,14 @@ function CreateRoundContent() {
</CardContent> </CardContent>
</Card> </Card>
{/* Round Type & Settings */}
<RoundTypeSettings
roundType={roundType}
onRoundTypeChange={setRoundType}
settings={roundSettings}
onSettingsChange={setRoundSettings}
/>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Voting Window</CardTitle> <CardTitle className="text-lg">Voting Window</CardTitle>

View File

@ -52,6 +52,8 @@ import {
Archive, Archive,
Trash2, Trash2,
Loader2, Loader2,
ChevronUp,
ChevronDown,
} from 'lucide-react' } from 'lucide-react'
import { format, isPast, isFuture } from 'date-fns' import { format, isPast, isFuture } from 'date-fns'
@ -106,6 +108,7 @@ function RoundsContent() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-20">Order</TableHead>
<TableHead>Round</TableHead> <TableHead>Round</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Voting Window</TableHead> <TableHead>Voting Window</TableHead>
@ -115,8 +118,15 @@ function RoundsContent() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{program.rounds.map((round) => ( {program.rounds.map((round, index) => (
<RoundRow key={round.id} round={round} /> <RoundRow
key={round.id}
round={round}
index={index}
totalRounds={program.rounds.length}
allRoundIds={program.rounds.map((r) => r.id)}
programId={program.id}
/>
))} ))}
</TableBody> </TableBody>
</Table> </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 utils = trpc.useUtils()
const [showDeleteDialog, setShowDeleteDialog] = useState(false) 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({ const updateStatus = trpc.round.updateStatus.useMutation({
onSuccess: () => { onSuccess: () => {
utils.program.list.invalidate() utils.program.list.invalidate()
@ -229,6 +269,28 @@ function RoundRow({ round }: { round: any }) {
return ( return (
<TableRow> <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> <TableCell>
<Link <Link
href={`/admin/rounds/${round.id}`} href={`/admin/rounds/${round.id}`}
@ -242,7 +304,7 @@ function RoundRow({ round }: { round: any }) {
<TableCell> <TableCell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FileText className="h-4 w-4 text-muted-foreground" /> <FileText className="h-4 w-4 text-muted-foreground" />
{round._count?.projects || 0} {round._count?.roundProjects || 0}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
@ -325,9 +387,9 @@ function RoundRow({ round }: { round: any }) {
<AlertDialogTitle>Delete Round</AlertDialogTitle> <AlertDialogTitle>Delete Round</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete &quot;{round.name}&quot;? This will Are you sure you want to delete &quot;{round.name}&quot;? This will
permanently delete all {round._count?.projects || 0} projects,{' '} remove {round._count?.roundProjects || 0} project assignments,{' '}
{round._count?.assignments || 0} assignments, and all evaluations {round._count?.assignments || 0} reviewer assignments, and all evaluations
in this round. This action cannot be undone. in this round. The projects themselves will remain in the program. This action cannot be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>

View File

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

View File

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

View File

@ -34,11 +34,18 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
redirect('/login') redirect('/login')
} }
// Get project with assignment info for this user // Check if user is assigned to this project
const project = await prisma.project.findUnique({ const assignment = await prisma.assignment.findFirst({
where: { id: projectId }, where: {
projectId,
userId,
},
include: { include: {
files: true, evaluation: {
include: {
form: true,
},
},
round: { round: {
include: { include: {
program: { 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) { if (!project) {
notFound() 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) { if (!assignment) {
return ( return (
<div className="space-y-6"> <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() const now = new Date()
// Check voting window // Check voting window

View File

@ -49,10 +49,18 @@ async function EvaluationContent({ projectId }: { projectId: string }) {
redirect('/login') redirect('/login')
} }
// Get project with assignment info for this user // Check if user is assigned to this project
const project = await prisma.project.findUnique({ const assignment = await prisma.assignment.findFirst({
where: { id: projectId }, where: {
projectId,
userId,
},
include: { include: {
evaluation: {
include: {
form: true,
},
},
round: { round: {
include: { include: {
program: { 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) { if (!project) {
notFound() 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) { if (!assignment) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -145,7 +148,7 @@ async function EvaluationContent({ projectId }: { projectId: string }) {
const criterionScores = const criterionScores =
(evaluation.criterionScoresJson as unknown as Record<string, number>) || {} (evaluation.criterionScoresJson as unknown as Record<string, number>) || {}
const round = project.round const round = assignment.round
return ( return (
<div className="space-y-6"> <div className="space-y-6">

View File

@ -43,11 +43,14 @@ async function ProjectContent({ projectId }: { projectId: string }) {
redirect('/login') redirect('/login')
} }
// Get project with assignment info for this user // Check if user is assigned to this project
const project = await prisma.project.findUnique({ const assignment = await prisma.assignment.findFirst({
where: { id: projectId }, where: {
projectId,
userId,
},
include: { include: {
files: true, evaluation: true,
round: { round: {
include: { include: {
program: { 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) { if (!project) {
notFound() notFound()
} }
// Check if user is assigned to this project
const assignment = await prisma.assignment.findFirst({
where: {
projectId,
userId,
},
include: {
evaluation: true,
},
})
if (!assignment) { if (!assignment) {
// User is not assigned to this project // User is not assigned to this project
return ( return (
@ -99,7 +99,7 @@ async function ProjectContent({ projectId }: { projectId: string }) {
} }
const evaluation = assignment.evaluation const evaluation = assignment.evaluation
const round = project.round const round = assignment.round
const now = new Date() const now = new Date()
// Check voting window // Check voting window

View File

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

View File

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

View File

@ -48,7 +48,7 @@ async function ObserverDashboardContent() {
program: { select: { name: true, year: true } }, program: { select: { name: true, year: true } },
_count: { _count: {
select: { select: {
projects: true, roundProjects: true,
assignments: true, assignments: true,
}, },
}, },
@ -176,7 +176,7 @@ async function ObserverDashboardContent() {
</p> </p>
</div> </div>
<div className="text-right text-sm"> <div className="text-right text-sm">
<p>{round._count.projects} projects</p> <p>{round._count.roundProjects} projects</p>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{round._count.assignments} assignments {round._count.assignments} assignments
</p> </p>

View File

@ -34,7 +34,7 @@ async function ReportsContent() {
}, },
_count: { _count: {
select: { select: {
projects: true, roundProjects: true,
assignments: true, assignments: true,
}, },
}, },
@ -70,7 +70,7 @@ async function ReportsContent() {
}) })
// Calculate totals // 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( const totalAssignments = roundStats.reduce(
(acc, r) => acc + r.totalAssignments, (acc, r) => acc + r.totalAssignments,
0 0
@ -176,7 +176,7 @@ async function ReportsContent() {
</div> </div>
</TableCell> </TableCell>
<TableCell>{round.program.name}</TableCell> <TableCell>{round.program.name}</TableCell>
<TableCell>{round._count.projects}</TableCell> <TableCell>{round._count.roundProjects}</TableCell>
<TableCell> <TableCell>
<div className="min-w-[120px] space-y-1"> <div className="min-w-[120px] space-y-1">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
@ -237,7 +237,7 @@ async function ReportsContent() {
</p> </p>
)} )}
<div className="flex items-center justify-between text-sm"> <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"> <span className="text-muted-foreground">
{round.completedEvaluations}/{round.totalAssignments} evaluations {round.completedEvaluations}/{round.totalAssignments} evaluations
</span> </span>

View File

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

View File

@ -131,18 +131,24 @@ export function MySubmissionClient() {
</Card> </Card>
) : ( ) : (
<div className="space-y-4"> <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}> <Card key={project.id}>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<CardTitle className="text-lg">{project.title}</CardTitle> <CardTitle className="text-lg">{project.title}</CardTitle>
<CardDescription> <CardDescription>
{project.round.program.year} Edition - {project.round.name} {programYear ? `${programYear} Edition` : ''}{roundName ? ` - ${roundName}` : ''}
</CardDescription> </CardDescription>
</div> </div>
<Badge variant={statusColors[project.status] || 'secondary'}> <Badge variant={statusColors[projectStatus] || 'secondary'}>
{project.status.replace('_', ' ')} {projectStatus.replace('_', ' ')}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
@ -197,22 +203,22 @@ export function MySubmissionClient() {
status: 'UNDER_REVIEW', status: 'UNDER_REVIEW',
label: 'Under Review', label: 'Under Review',
date: null, date: null,
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status), completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(projectStatus),
}, },
{ {
status: 'SEMIFINALIST', status: 'SEMIFINALIST',
label: 'Semi-finalist', label: 'Semi-finalist',
date: null, date: null,
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status), completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(projectStatus),
}, },
{ {
status: 'FINALIST', status: 'FINALIST',
label: 'Finalist', label: 'Finalist',
date: null, date: null,
completed: ['FINALIST', 'WINNER'].includes(project.status), completed: ['FINALIST', 'WINNER'].includes(projectStatus),
}, },
]} ]}
currentStatus={project.status} currentStatus={projectStatus}
className="mt-4" className="mt-4"
/> />
</div> </div>
@ -229,7 +235,8 @@ export function MySubmissionClient() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
))} )
})}
</div> </div>
)} )}
</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' import { cn } from '@/lib/utils'
interface CSVImportFormProps { interface CSVImportFormProps {
roundId: string programId: string
roundId?: string
roundName: string roundName: string
onSuccess?: () => void onSuccess?: () => void
} }
@ -72,7 +73,7 @@ interface MappedProject {
metadataJson?: Record<string, unknown> metadataJson?: Record<string, unknown>
} }
export function CSVImportForm({ roundId, roundName, onSuccess }: CSVImportFormProps) { export function CSVImportForm({ programId, roundId, roundName, onSuccess }: CSVImportFormProps) {
const router = useRouter() const router = useRouter()
const [step, setStep] = useState<Step>('upload') const [step, setStep] = useState<Step>('upload')
const [file, setFile] = useState<File | null>(null) const [file, setFile] = useState<File | null>(null)
@ -224,6 +225,7 @@ export function CSVImportForm({ roundId, roundName, onSuccess }: CSVImportFormPr
try { try {
await importMutation.mutateAsync({ await importMutation.mutateAsync({
programId,
roundId, roundId,
projects: valid, projects: valid,
}) })

View File

@ -148,8 +148,10 @@ export const analyticsRouter = router({
getProjectRankings: adminProcedure getProjectRankings: adminProcedure
.input(z.object({ roundId: z.string(), limit: z.number().optional() })) .input(z.object({ roundId: z.string(), limit: z.number().optional() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({ const roundProjects = await ctx.prisma.roundProject.findMany({
where: { roundId: input.roundId }, where: { roundId: input.roundId },
include: {
project: {
include: { include: {
assignments: { assignments: {
include: { include: {
@ -159,11 +161,14 @@ export const analyticsRouter = router({
}, },
}, },
}, },
},
},
}) })
// Calculate average scores // Calculate average scores
const rankings = projects const rankings = roundProjects
.map((project) => { .map((rp) => {
const project = rp.project
const allScores: number[] = [] const allScores: number[] = []
project.assignments.forEach((assignment) => { project.assignments.forEach((assignment) => {
@ -195,7 +200,7 @@ export const analyticsRouter = router({
id: project.id, id: project.id,
title: project.title, title: project.title,
teamName: project.teamName, teamName: project.teamName,
status: project.status, status: rp.status,
averageScore, averageScore,
evaluationCount: allScores.length, evaluationCount: allScores.length,
} }
@ -212,15 +217,15 @@ export const analyticsRouter = router({
getStatusBreakdown: adminProcedure getStatusBreakdown: adminProcedure
.input(z.object({ roundId: z.string() })) .input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.groupBy({ const roundProjects = await ctx.prisma.roundProject.groupBy({
by: ['status'], by: ['status'],
where: { roundId: input.roundId }, where: { roundId: input.roundId },
_count: true, _count: true,
}) })
return projects.map((p) => ({ return roundProjects.map((rp) => ({
status: p.status, status: rp.status,
count: p._count, count: rp._count,
})) }))
}), }),
@ -237,7 +242,7 @@ export const analyticsRouter = router({
jurorCount, jurorCount,
statusCounts, statusCounts,
] = await Promise.all([ ] = 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.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.evaluation.count({ ctx.prisma.evaluation.count({
where: { where: {
@ -249,7 +254,7 @@ export const analyticsRouter = router({
by: ['userId'], by: ['userId'],
where: { roundId: input.roundId }, where: { roundId: input.roundId },
}), }),
ctx.prisma.project.groupBy({ ctx.prisma.roundProject.groupBy({
by: ['status'], by: ['status'],
where: { roundId: input.roundId }, where: { roundId: input.roundId },
_count: true, _count: true,
@ -348,8 +353,8 @@ export const analyticsRouter = router({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const where = input.roundId const where = input.roundId
? { roundId: input.roundId } ? { roundProjects: { some: { roundId: input.roundId } } }
: { round: { programId: input.programId } } : { programId: input.programId }
const distribution = await ctx.prisma.project.groupBy({ const distribution = await ctx.prisma.project.groupBy({
by: ['country'], by: ['country'],

View File

@ -62,7 +62,7 @@ export const applicantRouter = router({
const project = await ctx.prisma.project.findFirst({ const project = await ctx.prisma.project.findFirst({
where: { where: {
roundId: input.roundId, roundProjects: { some: { roundId: input.roundId } },
OR: [ OR: [
{ submittedByUserId: ctx.user.id }, { submittedByUserId: ctx.user.id },
{ {
@ -74,11 +74,16 @@ export const applicantRouter = router({
}, },
include: { include: {
files: true, files: true,
roundProjects: {
where: { roundId: input.roundId },
include: {
round: { round: {
include: { include: {
program: { select: { name: true, year: true } }, program: { select: { name: true, year: true } },
}, },
}, },
},
},
teamMembers: { teamMembers: {
include: { include: {
user: { user: {
@ -171,26 +176,47 @@ export const applicantRouter = router({
...data, ...data,
metadataJson: metadataJson as unknown ?? undefined, metadataJson: metadataJson as unknown ?? undefined,
submittedAt: submit && !existing.submittedAt ? now : existing.submittedAt, 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 return project
} else { } 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({ const project = await ctx.prisma.project.create({
data: { data: {
roundId, programId: roundForCreate.programId,
...data, ...data,
metadataJson: metadataJson as unknown ?? undefined, metadataJson: metadataJson as unknown ?? undefined,
submittedByUserId: ctx.user.id, submittedByUserId: ctx.user.id,
submittedByEmail: ctx.user.email, submittedByEmail: ctx.user.email,
submissionSource: 'MANUAL', submissionSource: 'MANUAL',
status: 'SUBMITTED',
submittedAt: submit ? now : null, submittedAt: submit ? now : null,
}, },
}) })
// Create RoundProject entry
await ctx.prisma.roundProject.create({
data: {
roundId,
projectId: project.id,
status: 'SUBMITTED',
},
})
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await ctx.prisma.auditLog.create({
data: { data: {
@ -385,12 +411,17 @@ export const applicantRouter = router({
}, },
], ],
}, },
include: {
roundProjects: {
include: { include: {
round: { round: {
include: { include: {
program: { select: { name: true, year: true } }, program: { select: { name: true, year: true } },
}, },
}, },
},
orderBy: { addedAt: 'desc' },
},
files: true, files: true,
teamMembers: { teamMembers: {
include: { 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 // Build timeline
const timeline = [ const timeline = [
{ {
@ -426,27 +461,27 @@ export const applicantRouter = router({
{ {
status: 'UNDER_REVIEW', status: 'UNDER_REVIEW',
label: 'Under Review', label: 'Under Review',
date: project.status === 'SUBMITTED' && project.submittedAt ? project.submittedAt : null, date: currentStatus === 'SUBMITTED' && project.submittedAt ? project.submittedAt : null,
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status), completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus),
}, },
{ {
status: 'SEMIFINALIST', status: 'SEMIFINALIST',
label: 'Semi-finalist', label: 'Semi-finalist',
date: null, // Would need status change tracking date: null, // Would need status change tracking
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status), completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus),
}, },
{ {
status: 'FINALIST', status: 'FINALIST',
label: 'Finalist', label: 'Finalist',
date: null, date: null,
completed: ['FINALIST', 'WINNER'].includes(project.status), completed: ['FINALIST', 'WINNER'].includes(currentStatus),
}, },
] ]
return { return {
project, project,
timeline, timeline,
currentStatus: project.status, currentStatus,
} }
}), }),
@ -473,12 +508,17 @@ export const applicantRouter = router({
}, },
], ],
}, },
include: {
roundProjects: {
include: { include: {
round: { round: {
include: { include: {
program: { select: { name: true, year: true } }, program: { select: { name: true, year: true } },
}, },
}, },
},
orderBy: { addedAt: 'desc' },
},
files: true, files: true,
teamMembers: { teamMembers: {
include: { include: {

View File

@ -186,7 +186,7 @@ export const applicationRouter = router({
// Check if email already submitted for this round // Check if email already submitted for this round
const existingProject = await ctx.prisma.project.findFirst({ const existingProject = await ctx.prisma.project.findFirst({
where: { where: {
roundId, roundProjects: { some: { roundId } },
submittedByEmail: data.contactEmail, submittedByEmail: data.contactEmail,
}, },
}) })
@ -218,11 +218,10 @@ export const applicationRouter = router({
// Create the project // Create the project
const project = await ctx.prisma.project.create({ const project = await ctx.prisma.project.create({
data: { data: {
roundId, programId: round.programId,
title: data.projectName, title: data.projectName,
teamName: data.teamName, teamName: data.teamName,
description: data.description, description: data.description,
status: 'SUBMITTED',
competitionCategory: data.competitionCategory, competitionCategory: data.competitionCategory,
oceanIssue: data.oceanIssue, oceanIssue: data.oceanIssue,
country: data.country, 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 // Create team lead membership
await ctx.prisma.teamMember.create({ await ctx.prisma.teamMember.create({
data: { data: {
@ -320,7 +328,7 @@ export const applicationRouter = router({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const existing = await ctx.prisma.project.findFirst({ const existing = await ctx.prisma.project.findFirst({
where: { where: {
roundId: input.roundId, roundProjects: { some: { roundId: input.roundId } },
submittedByEmail: input.email, submittedByEmail: input.email,
}, },
}) })

View File

@ -286,13 +286,17 @@ export const assignmentRouter = router({
where: { roundId: input.roundId }, where: { roundId: input.roundId },
_count: true, _count: true,
}), }),
ctx.prisma.project.findMany({ ctx.prisma.roundProject.findMany({
where: { roundId: input.roundId }, where: { roundId: input.roundId },
include: {
project: {
select: { select: {
id: true, id: true,
title: true, title: true,
_count: { select: { assignments: true } }, _count: { select: { assignments: true } },
}, },
},
},
}), }),
]) ])
@ -302,7 +306,7 @@ export const assignmentRouter = router({
}) })
const projectsWithFullCoverage = projectCoverage.filter( const projectsWithFullCoverage = projectCoverage.filter(
(p) => p._count.assignments >= round.requiredReviews (rp) => rp.project._count.assignments >= round.requiredReviews
).length ).length
return { return {
@ -354,15 +358,20 @@ export const assignmentRouter = router({
}) })
// Get all projects that need more assignments // 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 }, where: { roundId: input.roundId },
include: {
project: {
select: { select: {
id: true, id: true,
title: true, title: true,
tags: true, tags: true,
_count: { select: { assignments: true } }, _count: { select: { assignments: true } },
}, },
},
},
}) })
const projects = roundProjectEntries.map((rp) => rp.project)
// Get existing assignments to avoid duplicates // Get existing assignments to avoid duplicates
const existingAssignments = await ctx.prisma.assignment.findMany({ const existingAssignments = await ctx.prisma.assignment.findMany({
@ -482,8 +491,10 @@ export const assignmentRouter = router({
}) })
// Get all projects in the round // Get all projects in the round
const projects = await ctx.prisma.project.findMany({ const roundProjectEntries = await ctx.prisma.roundProject.findMany({
where: { roundId: input.roundId }, where: { roundId: input.roundId },
include: {
project: {
select: { select: {
id: true, id: true,
title: true, title: true,
@ -492,7 +503,10 @@ export const assignmentRouter = router({
teamName: true, teamName: true,
_count: { select: { assignments: true } }, _count: { select: { assignments: true } },
}, },
},
},
}) })
const projects = roundProjectEntries.map((rp) => rp.project)
// Get existing assignments // Get existing assignments
const existingAssignments = await ctx.prisma.assignment.findMany({ const existingAssignments = await ctx.prisma.assignment.findMany({

View File

@ -103,8 +103,10 @@ export const exportRouter = router({
projectScores: adminProcedure projectScores: adminProcedure
.input(z.object({ roundId: z.string() })) .input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({ const roundProjectEntries = await ctx.prisma.roundProject.findMany({
where: { roundId: input.roundId }, where: { roundId: input.roundId },
include: {
project: {
include: { include: {
assignments: { assignments: {
include: { 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 const evaluations = p.assignments
.map((a) => a.evaluation) .map((a) => a.evaluation)
.filter((e) => e !== null) .filter((e) => e !== null)
@ -133,7 +138,7 @@ export const exportRouter = router({
return { return {
title: p.title, title: p.title,
teamName: p.teamName, teamName: p.teamName,
status: p.status, status: rp.status,
tags: p.tags.join(', '), tags: p.tags.join(', '),
totalEvaluations: evaluations.length, totalEvaluations: evaluations.length,
averageScore: averageScore:

View File

@ -147,14 +147,19 @@ export const filteringRouter = router({
} }
// Get projects in this round // Get projects in this round
const projects = await ctx.prisma.project.findMany({ const roundProjectEntries = await ctx.prisma.roundProject.findMany({
where: { roundId: input.roundId }, where: { roundId: input.roundId },
include: {
project: {
include: { include: {
files: { files: {
select: { id: true, fileName: true, fileType: true }, select: { id: true, fileName: true, fileType: true },
}, },
}, },
},
},
}) })
const projects = roundProjectEntries.map((rp) => rp.project)
if (projects.length === 0) { if (projects.length === 0) {
throw new TRPCError({ throw new TRPCError({
@ -250,7 +255,6 @@ export const filteringRouter = router({
id: true, id: true,
title: true, title: true,
teamName: true, teamName: true,
status: true,
competitionCategory: true, competitionCategory: true,
country: true, country: true,
}, },
@ -390,13 +394,13 @@ export const filteringRouter = router({
.filter((r) => (r.finalOutcome || r.outcome) === 'PASSED') .filter((r) => (r.finalOutcome || r.outcome) === 'PASSED')
.map((r) => r.projectId) .map((r) => r.projectId)
// Update project statuses // Update RoundProject statuses
await ctx.prisma.$transaction([ await ctx.prisma.$transaction([
// Filtered out projects get REJECTED status (data preserved) // Filtered out projects get REJECTED status (data preserved)
...(filteredOutIds.length > 0 ...(filteredOutIds.length > 0
? [ ? [
ctx.prisma.project.updateMany({ ctx.prisma.roundProject.updateMany({
where: { id: { in: filteredOutIds } }, where: { roundId: input.roundId, projectId: { in: filteredOutIds } },
data: { status: 'REJECTED' }, data: { status: 'REJECTED' },
}), }),
] ]
@ -404,8 +408,8 @@ export const filteringRouter = router({
// Passed projects get ELIGIBLE status // Passed projects get ELIGIBLE status
...(passedIds.length > 0 ...(passedIds.length > 0
? [ ? [
ctx.prisma.project.updateMany({ ctx.prisma.roundProject.updateMany({
where: { id: { in: passedIds } }, where: { roundId: input.roundId, projectId: { in: passedIds } },
data: { status: 'ELIGIBLE' }, data: { status: 'ELIGIBLE' },
}), }),
] ]
@ -454,9 +458,9 @@ export const filteringRouter = router({
}, },
}) })
// Restore project status // Restore RoundProject status
await ctx.prisma.project.update({ await ctx.prisma.roundProject.updateMany({
where: { id: input.projectId }, where: { roundId: input.roundId, projectId: input.projectId },
data: { status: 'ELIGIBLE' }, data: { status: 'ELIGIBLE' },
}) })
@ -500,8 +504,8 @@ export const filteringRouter = router({
}, },
}) })
), ),
ctx.prisma.project.updateMany({ ctx.prisma.roundProject.updateMany({
where: { id: { in: input.projectIds } }, where: { roundId: input.roundId, projectId: { in: input.projectIds } },
data: { status: 'ELIGIBLE' }, data: { status: 'ELIGIBLE' },
}), }),
]) ])

View File

@ -80,18 +80,27 @@ export const learningResourceRouter = router({
const assignments = await ctx.prisma.assignment.findMany({ const assignments = await ctx.prisma.assignment.findMany({
where: { userId: ctx.user.id }, where: { userId: ctx.user.id },
include: { include: {
project: { select: { status: true } }, project: {
select: {
roundProjects: {
select: { status: true },
orderBy: { addedAt: 'desc' },
take: 1,
},
},
},
}, },
}) })
// Determine highest cohort level // Determine highest cohort level
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL' let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) { for (const assignment of assignments) {
if (assignment.project.status === 'FINALIST') { const rpStatus = assignment.project.roundProjects[0]?.status
if (rpStatus === 'FINALIST') {
userCohortLevel = 'FINALIST' userCohortLevel = 'FINALIST'
break break
} }
if (assignment.project.status === 'SEMIFINALIST') { if (rpStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST' userCohortLevel = 'SEMIFINALIST'
} }
} }
@ -155,17 +164,26 @@ export const learningResourceRouter = router({
const assignments = await ctx.prisma.assignment.findMany({ const assignments = await ctx.prisma.assignment.findMany({
where: { userId: ctx.user.id }, where: { userId: ctx.user.id },
include: { include: {
project: { select: { status: true } }, project: {
select: {
roundProjects: {
select: { status: true },
orderBy: { addedAt: 'desc' as const },
take: 1,
},
},
},
}, },
}) })
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL' let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) { for (const assignment of assignments) {
if (assignment.project.status === 'FINALIST') { const rpStatus = assignment.project.roundProjects[0]?.status
if (rpStatus === 'FINALIST') {
userCohortLevel = 'FINALIST' userCohortLevel = 'FINALIST'
break break
} }
if (assignment.project.status === 'SEMIFINALIST') { if (rpStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST' userCohortLevel = 'SEMIFINALIST'
} }
} }
@ -220,16 +238,27 @@ export const learningResourceRouter = router({
// Check cohort level access // Check cohort level access
const assignments = await ctx.prisma.assignment.findMany({ const assignments = await ctx.prisma.assignment.findMany({
where: { userId: ctx.user.id }, 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' let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) { for (const assignment of assignments) {
if (assignment.project.status === 'FINALIST') { const rpStatus = assignment.project.roundProjects[0]?.status
if (rpStatus === 'FINALIST') {
userCohortLevel = 'FINALIST' userCohortLevel = 'FINALIST'
break break
} }
if (assignment.project.status === 'SEMIFINALIST') { if (rpStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST' userCohortLevel = 'SEMIFINALIST'
} }
} }

View File

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

View File

@ -333,7 +333,7 @@ export const mentorRouter = router({
// Get projects without mentors // Get projects without mentors
const projects = await ctx.prisma.project.findMany({ const projects = await ctx.prisma.project.findMany({
where: { where: {
roundId: input.roundId, roundProjects: { some: { roundId: input.roundId } },
mentorAssignment: null, mentorAssignment: null,
wantsMentorship: true, wantsMentorship: true,
}, },
@ -430,12 +430,19 @@ export const mentorRouter = router({
where: { mentorId: ctx.user.id }, where: { mentorId: ctx.user.id },
include: { include: {
project: { project: {
include: {
program: { select: { name: true, year: true } },
roundProjects: {
include: { include: {
round: { round: {
include: { include: {
program: { select: { name: true, year: true } }, program: { select: { name: true, year: true } },
}, },
}, },
},
orderBy: { addedAt: 'desc' },
take: 1,
},
teamMembers: { teamMembers: {
include: { include: {
user: { select: { id: true, name: true, email: true } }, user: { select: { id: true, name: true, email: true } },
@ -476,12 +483,19 @@ export const mentorRouter = router({
const project = await ctx.prisma.project.findUniqueOrThrow({ const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId }, where: { id: input.projectId },
include: {
program: { select: { id: true, name: true, year: true } },
roundProjects: {
include: { include: {
round: { round: {
include: { include: {
program: { select: { id: true, name: true, year: true } }, program: { select: { id: true, name: true, year: true } },
}, },
}, },
},
orderBy: { addedAt: 'desc' },
take: 1,
},
teamMembers: { teamMembers: {
include: { include: {
user: { user: {
@ -528,7 +542,7 @@ export const mentorRouter = router({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const where = { const where = {
...(input.roundId && { project: { roundId: input.roundId } }), ...(input.roundId && { project: { roundProjects: { some: { roundId: input.roundId } } } }),
...(input.mentorId && { mentorId: input.mentorId }), ...(input.mentorId && { mentorId: input.mentorId }),
} }
@ -541,9 +555,12 @@ export const mentorRouter = router({
id: true, id: true,
title: true, title: true,
teamName: true, teamName: true,
status: true,
oceanIssue: true, oceanIssue: true,
competitionCategory: true, competitionCategory: true,
roundProjects: {
select: { status: true },
take: 1,
},
}, },
}, },
mentor: { mentor: {

View File

@ -171,9 +171,9 @@ export const notionImportRouter = router({
} }
// Create project // Create project
await ctx.prisma.project.create({ const createdProject = await ctx.prisma.project.create({
data: { data: {
roundId: round.id, programId: round.programId,
title: title.trim(), title: title.trim(),
teamName: typeof teamName === 'string' ? teamName.trim() : null, teamName: typeof teamName === 'string' ? teamName.trim() : null,
description: typeof description === 'string' ? description : null, description: typeof description === 'string' ? description : null,
@ -183,6 +183,14 @@ export const notionImportRouter = router({
notionPageId: record.id, notionPageId: record.id,
notionDatabaseId: input.databaseId, notionDatabaseId: input.databaseId,
} as Prisma.InputJsonValue, } as Prisma.InputJsonValue,
},
})
// Create RoundProject entry
await ctx.prisma.roundProject.create({
data: {
roundId: round.id,
projectId: createdProject.id,
status: 'SUBMITTED', status: 'SUBMITTED',
}, },
}) })

View File

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

View File

@ -12,6 +12,7 @@ export const projectRouter = router({
list: protectedProcedure list: protectedProcedure
.input( .input(
z.object({ z.object({
programId: z.string().optional(),
roundId: z.string().optional(), roundId: z.string().optional(),
status: z status: z
.enum([ .enum([
@ -33,6 +34,8 @@ export const projectRouter = router({
'REJECTED', 'REJECTED',
]) ])
).optional(), ).optional(),
notInRoundId: z.string().optional(), // Exclude projects already in this round
unassignedOnly: z.boolean().optional(), // Projects not in any round
search: z.string().optional(), search: z.string().optional(),
tags: z.array(z.string()).optional(), tags: z.array(z.string()).optional(),
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(), competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
@ -52,7 +55,7 @@ export const projectRouter = router({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const { const {
roundId, status, statuses, search, tags, programId, roundId, notInRoundId, status, statuses, unassignedOnly, search, tags,
competitionCategory, oceanIssue, country, competitionCategory, oceanIssue, country,
wantsMentorship, hasFiles, hasAssignments, wantsMentorship, hasFiles, hasAssignments,
page, perPage, page, perPage,
@ -62,12 +65,51 @@ export const projectRouter = router({
// Build where clause // Build where clause
const where: Record<string, unknown> = {} const where: Record<string, unknown> = {}
if (roundId) where.roundId = roundId if (programId) where.programId = programId
if (statuses && statuses.length > 0) {
where.status = { in: statuses } // Filter by round via RoundProject join
} else if (status) { if (roundId) {
where.status = status 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) { if (tags && tags.length > 0) {
where.tags = { hasSome: tags } where.tags = { hasSome: tags }
} }
@ -90,7 +132,6 @@ export const projectRouter = router({
// Jury members can only see assigned projects // Jury members can only see assigned projects
if (ctx.user.role === 'JURY_MEMBER') { if (ctx.user.role === 'JURY_MEMBER') {
// If hasAssignments filter is already set, combine with jury filter
where.assignments = { where.assignments = {
...((where.assignments as Record<string, unknown>) || {}), ...((where.assignments as Record<string, unknown>) || {}),
some: { userId: ctx.user.id }, some: { userId: ctx.user.id },
@ -105,8 +146,16 @@ export const projectRouter = router({
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: {
files: true, files: true,
program: {
select: { id: true, name: true, year: true },
},
roundProjects: {
include: {
round: { 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 } }, _count: { select: { assignments: true } },
}, },
@ -130,8 +179,8 @@ export const projectRouter = router({
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const [rounds, countries, categories, issues] = await Promise.all([ const [rounds, countries, categories, issues] = await Promise.all([
ctx.prisma.round.findMany({ ctx.prisma.round.findMany({
select: { id: true, name: true, program: { select: { name: true, year: true } } }, select: { id: true, name: true, sortOrder: true, program: { select: { name: true, year: true } } },
orderBy: { createdAt: 'desc' }, orderBy: [{ program: { year: 'desc' } }, { sortOrder: 'asc' }],
}), }),
ctx.prisma.project.findMany({ ctx.prisma.project.findMany({
where: { country: { not: null } }, where: { country: { not: null } },
@ -175,7 +224,17 @@ export const projectRouter = router({
where: { id: input.id }, where: { id: input.id },
include: { include: {
files: true, 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: { teamMembers: {
include: { include: {
user: { user: {
@ -244,11 +303,13 @@ export const projectRouter = router({
/** /**
* Create a single project (admin only) * Create a single project (admin only)
* Projects belong to a program. Optionally assign to a round immediately.
*/ */
create: adminProcedure create: adminProcedure
.input( .input(
z.object({ z.object({
roundId: z.string(), programId: z.string(),
roundId: z.string().optional(),
title: z.string().min(1).max(500), title: z.string().min(1).max(500),
teamName: z.string().optional(), teamName: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
@ -257,7 +318,7 @@ export const projectRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { metadataJson, ...rest } = input const { metadataJson, roundId, ...rest } = input
const project = await ctx.prisma.project.create({ const project = await ctx.prisma.project.create({
data: { data: {
...rest, ...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 // Audit log
await ctx.prisma.auditLog.create({ await ctx.prisma.auditLog.create({
data: { data: {
@ -272,7 +344,7 @@ export const projectRouter = router({
action: 'CREATE', action: 'CREATE',
entityType: 'Project', entityType: 'Project',
entityId: project.id, entityId: project.id,
detailsJson: { title: input.title, roundId: input.roundId }, detailsJson: { title: input.title, programId: input.programId, roundId },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, },
@ -283,6 +355,7 @@ export const projectRouter = router({
/** /**
* Update a project (admin only) * Update a project (admin only)
* Status updates require a roundId context since status is per-round.
*/ */
update: adminProcedure update: adminProcedure
.input( .input(
@ -291,6 +364,8 @@ export const projectRouter = router({
title: z.string().min(1).max(500).optional(), title: z.string().min(1).max(500).optional(),
teamName: z.string().optional().nullable(), teamName: z.string().optional().nullable(),
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
// Status update requires roundId
roundId: z.string().optional(),
status: z status: z
.enum([ .enum([
'SUBMITTED', 'SUBMITTED',
@ -306,7 +381,7 @@ export const projectRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { id, metadataJson, ...data } = input const { id, metadataJson, status, roundId, ...data } = input
const project = await ctx.prisma.project.update({ const project = await ctx.prisma.project.update({
where: { id }, 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 // Audit log
await ctx.prisma.auditLog.create({ await ctx.prisma.auditLog.create({
data: { data: {
@ -323,7 +406,7 @@ export const projectRouter = router({
action: 'UPDATE', action: 'UPDATE',
entityType: 'Project', entityType: 'Project',
entityId: id, entityId: id,
detailsJson: { ...data, metadataJson } as Prisma.InputJsonValue, detailsJson: { ...data, status, roundId, metadataJson } as Prisma.InputJsonValue,
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, },
@ -360,11 +443,13 @@ export const projectRouter = router({
/** /**
* Import projects from CSV data (admin only) * Import projects from CSV data (admin only)
* Projects belong to a program. Optionally assign to a round.
*/ */
importCSV: adminProcedure importCSV: adminProcedure
.input( .input(
z.object({ z.object({
roundId: z.string(), programId: z.string(),
roundId: z.string().optional(),
projects: z.array( projects: z.array(
z.object({ z.object({
title: z.string().min(1), title: z.string().min(1),
@ -377,20 +462,53 @@ export const projectRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Verify round exists // Verify program exists
await ctx.prisma.round.findUniqueOrThrow({ await ctx.prisma.program.findUniqueOrThrow({
where: { id: input.roundId }, where: { id: input.programId },
}) })
const created = await ctx.prisma.project.createMany({ // Verify round exists and belongs to program if provided
data: input.projects.map((p) => { 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 const { metadataJson, ...rest } = p
return { return {
...rest, ...rest,
roundId: input.roundId, programId: input.programId,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, 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 // Audit log
@ -399,23 +517,30 @@ export const projectRouter = router({
userId: ctx.user.id, userId: ctx.user.id,
action: 'IMPORT', action: 'IMPORT',
entityType: 'Project', entityType: 'Project',
detailsJson: { roundId: input.roundId, count: created.count }, detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, },
}) })
return { imported: created.count } return result
}), }),
/** /**
* Get all unique tags used in projects * Get all unique tags used in projects
*/ */
getTags: protectedProcedure getTags: protectedProcedure
.input(z.object({ roundId: z.string().optional() })) .input(z.object({
roundId: z.string().optional(),
programId: z.string().optional(),
}))
.query(async ({ ctx, input }) => { .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({ 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 }, select: { tags: true },
}) })
@ -427,11 +552,13 @@ export const projectRouter = router({
/** /**
* Update project status in bulk (admin only) * Update project status in bulk (admin only)
* Status is per-round, so roundId is required.
*/ */
bulkUpdateStatus: adminProcedure bulkUpdateStatus: adminProcedure
.input( .input(
z.object({ z.object({
ids: z.array(z.string()), ids: z.array(z.string()),
roundId: z.string(),
status: z.enum([ status: z.enum([
'SUBMITTED', 'SUBMITTED',
'ELIGIBLE', 'ELIGIBLE',
@ -443,8 +570,11 @@ export const projectRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const updated = await ctx.prisma.project.updateMany({ const updated = await ctx.prisma.roundProject.updateMany({
where: { id: { in: input.ids } }, where: {
projectId: { in: input.ids },
roundId: input.roundId,
},
data: { status: input.status }, data: { status: input.status },
}) })
@ -454,7 +584,7 @@ export const projectRouter = router({
userId: ctx.user.id, userId: ctx.user.id,
action: 'BULK_UPDATE_STATUS', action: 'BULK_UPDATE_STATUS',
entityType: 'Project', entityType: 'Project',
detailsJson: { ids: input.ids, status: input.status }, detailsJson: { ids: input.ids, roundId: input.roundId, status: input.status },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, },
@ -462,4 +592,53 @@ export const projectRouter = router({
return { updated: updated.count } 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 }) => { .query(async ({ ctx, input }) => {
return ctx.prisma.round.findMany({ return ctx.prisma.round.findMany({
where: { programId: input.programId }, where: { programId: input.programId },
orderBy: { createdAt: 'asc' }, orderBy: { sortOrder: 'asc' },
include: { include: {
_count: { _count: {
select: { projects: true, assignments: true }, select: { roundProjects: true, assignments: true },
}, },
}, },
}) })
@ -32,7 +32,7 @@ export const roundRouter = router({
include: { include: {
program: true, program: true,
_count: { _count: {
select: { projects: true, assignments: true }, select: { roundProjects: true, assignments: true },
}, },
evaluationForms: { evaluationForms: {
where: { isActive: true }, where: { isActive: true },
@ -64,7 +64,10 @@ export const roundRouter = router({
z.object({ z.object({
programId: z.string(), programId: z.string(),
name: z.string().min(1).max(255), 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), 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(), votingStartAt: z.date().optional(),
votingEndAt: 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({ const round = await ctx.prisma.round.create({
data: input, data: {
...rest,
sortOrder,
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
},
}) })
// Audit log // Audit log
@ -91,7 +109,7 @@ export const roundRouter = router({
action: 'CREATE', action: 'CREATE',
entityType: 'Round', entityType: 'Round',
entityId: round.id, entityId: round.id,
detailsJson: input, detailsJson: { ...rest, settingsJson } as Prisma.InputJsonValue,
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, },
@ -234,7 +252,7 @@ export const roundRouter = router({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const [totalProjects, totalAssignments, completedAssignments] = const [totalProjects, totalAssignments, completedAssignments] =
await Promise.all([ 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 } }),
ctx.prisma.assignment.count({ ctx.prisma.assignment.count({
where: { roundId: input.id, isCompleted: true }, where: { roundId: input.id, isCompleted: true },
@ -365,7 +383,7 @@ export const roundRouter = router({
const round = await ctx.prisma.round.findUniqueOrThrow({ const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.id }, where: { id: input.id },
include: { include: {
_count: { select: { projects: true, assignments: true } }, _count: { select: { roundProjects: true, assignments: true } },
}, },
}) })
@ -383,7 +401,7 @@ export const roundRouter = router({
detailsJson: { detailsJson: {
name: round.name, name: round.name,
status: round.status, status: round.status,
projectsDeleted: round._count.projects, projectsDeleted: round._count.roundProjects,
assignmentsDeleted: round._count.assignments, assignmentsDeleted: round._count.assignments,
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
@ -408,4 +426,202 @@ export const roundRouter = router({
}) })
return count > 0 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 const statusFilter = input.includeSubmitted
? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const) ? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
: (['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: { where: {
round: { programId: award.programId }, round: { programId: award.programId },
status: { in: [...statusFilter] }, status: { in: [...statusFilter] },
}, },
include: {
project: {
select: { select: {
id: true, id: true,
title: true, title: true,
@ -252,7 +254,12 @@ export const specialAwardRouter = router({
tags: true, tags: true,
oceanIssue: 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) { if (projects.length === 0) {
throw new TRPCError({ throw new TRPCError({

View File

@ -199,9 +199,9 @@ export const typeformImportRouter = router({
} }
// Create project // Create project
await ctx.prisma.project.create({ const createdProject = await ctx.prisma.project.create({
data: { data: {
roundId: round.id, programId: round.programId,
title: String(title).trim(), title: String(title).trim(),
teamName: typeof teamName === 'string' ? teamName.trim() : null, teamName: typeof teamName === 'string' ? teamName.trim() : null,
description: typeof description === 'string' ? description : null, description: typeof description === 'string' ? description : null,
@ -211,6 +211,14 @@ export const typeformImportRouter = router({
typeformResponseId: response.response_id, typeformResponseId: response.response_id,
typeformFormId: input.formId, typeformFormId: input.formId,
} as Prisma.InputJsonValue, } as Prisma.InputJsonValue,
},
})
// Create RoundProject entry
await ctx.prisma.roundProject.create({
data: {
roundId: round.id,
projectId: createdProject.id,
status: 'SUBMITTED', status: 'SUBMITTED',
}, },
}) })