Decouple projects from rounds with RoundProject join table
Build and Push Docker Image / build (push) Successful in 8m16s
Details
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:
parent
0d2bc4db7e
commit
fd5e5222da
|
|
@ -8,23 +8,23 @@ async function check() {
|
|||
|
||||
const rounds = await prisma.round.findMany({
|
||||
include: {
|
||||
_count: { select: { projects: true } }
|
||||
_count: { select: { roundProjects: true } }
|
||||
}
|
||||
})
|
||||
|
||||
for (const r of rounds) {
|
||||
console.log(`Round: ${r.name} (id: ${r.id})`)
|
||||
console.log(` Projects: ${r._count.projects}`)
|
||||
console.log(` Projects: ${r._count.roundProjects}`)
|
||||
}
|
||||
|
||||
// Check if projects have roundId set
|
||||
const projectsWithRound = await prisma.project.findMany({
|
||||
select: { id: true, title: true, roundId: true },
|
||||
// Check if projects have programId set
|
||||
const sampleProjects = await prisma.project.findMany({
|
||||
select: { id: true, title: true, programId: true },
|
||||
take: 5
|
||||
})
|
||||
console.log('\nSample projects:')
|
||||
for (const p of projectsWithRound) {
|
||||
console.log(` ${p.title}: roundId=${p.roundId}`)
|
||||
for (const p of sampleProjects) {
|
||||
console.log(` ${p.title}: programId=${p.programId}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,18 +10,18 @@ async function cleanup() {
|
|||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
projects: { select: { id: true, title: true } },
|
||||
_count: { select: { projects: true } }
|
||||
roundProjects: { select: { id: true, projectId: true, project: { select: { id: true, title: true } } } },
|
||||
_count: { select: { roundProjects: true } }
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`Found ${rounds.length} rounds:`)
|
||||
for (const round of rounds) {
|
||||
console.log(`- ${round.name} (slug: ${round.slug}): ${round._count.projects} projects`)
|
||||
console.log(`- ${round.name} (slug: ${round.slug}): ${round._count.roundProjects} projects`)
|
||||
}
|
||||
|
||||
// Find rounds with 9 or fewer projects (dummy data)
|
||||
const dummyRounds = rounds.filter(r => r._count.projects <= 9)
|
||||
const dummyRounds = rounds.filter(r => r._count.roundProjects <= 9)
|
||||
|
||||
if (dummyRounds.length > 0) {
|
||||
console.log(`\nDeleting ${dummyRounds.length} dummy round(s)...`)
|
||||
|
|
@ -29,10 +29,16 @@ async function cleanup() {
|
|||
for (const round of dummyRounds) {
|
||||
console.log(`\nProcessing: ${round.name}`)
|
||||
|
||||
const projectIds = round.projects.map(p => p.id)
|
||||
const projectIds = round.roundProjects.map(rp => rp.projectId)
|
||||
|
||||
if (projectIds.length > 0) {
|
||||
// Delete team members first
|
||||
// Delete round-project associations first
|
||||
const rpDeleted = await prisma.roundProject.deleteMany({
|
||||
where: { roundId: round.id }
|
||||
})
|
||||
console.log(` Deleted ${rpDeleted.count} round-project associations`)
|
||||
|
||||
// Delete team members
|
||||
const teamDeleted = await prisma.teamMember.deleteMany({
|
||||
where: { projectId: { in: projectIds } }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,15 +8,15 @@ async function cleanup() {
|
|||
// Find and delete the dummy round
|
||||
const dummyRound = await prisma.round.findFirst({
|
||||
where: { slug: 'round-1-2026' },
|
||||
include: { projects: true }
|
||||
include: { roundProjects: { include: { project: true } } }
|
||||
})
|
||||
|
||||
if (dummyRound) {
|
||||
console.log(`Found dummy round: ${dummyRound.name}`)
|
||||
console.log(`Projects in round: ${dummyRound.projects.length}`)
|
||||
console.log(`Projects in round: ${dummyRound.roundProjects.length}`)
|
||||
|
||||
// Get project IDs to delete
|
||||
const projectIds = dummyRound.projects.map(p => p.id)
|
||||
const projectIds = dummyRound.roundProjects.map(rp => rp.projectId)
|
||||
|
||||
// Delete team members for these projects
|
||||
if (projectIds.length > 0) {
|
||||
|
|
@ -25,11 +25,11 @@ async function cleanup() {
|
|||
})
|
||||
console.log(`Deleted ${teamDeleted.count} team members`)
|
||||
|
||||
// Disconnect projects from round first
|
||||
await prisma.round.update({
|
||||
where: { id: dummyRound.id },
|
||||
data: { projects: { disconnect: projectIds.map(id => ({ id })) } }
|
||||
// Delete round-project associations
|
||||
await prisma.roundProject.deleteMany({
|
||||
where: { roundId: dummyRound.id }
|
||||
})
|
||||
console.log(`Deleted round-project associations`)
|
||||
|
||||
// Delete the projects
|
||||
const projDeleted = await prisma.project.deleteMany({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -335,6 +335,7 @@ model Program {
|
|||
|
||||
// Relations
|
||||
rounds Round[]
|
||||
projects Project[]
|
||||
learningResources LearningResource[]
|
||||
partners Partner[]
|
||||
applicationForms ApplicationForm[]
|
||||
|
|
@ -351,6 +352,7 @@ model Round {
|
|||
slug String? @unique // URL-friendly identifier for public submissions
|
||||
status RoundStatus @default(DRAFT)
|
||||
roundType RoundType @default(EVALUATION)
|
||||
sortOrder Int @default(0) // Progression order within program
|
||||
|
||||
// Submission window (for applicant portal)
|
||||
submissionDeadline DateTime? // Deadline for project submissions
|
||||
|
|
@ -375,7 +377,7 @@ model Round {
|
|||
|
||||
// Relations
|
||||
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||
projects Project[]
|
||||
roundProjects RoundProject[]
|
||||
assignments Assignment[]
|
||||
evaluationForms EvaluationForm[]
|
||||
gracePeriods GracePeriod[]
|
||||
|
|
@ -419,13 +421,12 @@ model EvaluationForm {
|
|||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
roundId String
|
||||
programId String
|
||||
|
||||
// Core fields
|
||||
title String
|
||||
teamName String?
|
||||
description String? @db.Text
|
||||
status ProjectStatus @default(SUBMITTED)
|
||||
|
||||
// Competition category
|
||||
competitionCategory CompetitionCategory?
|
||||
|
|
@ -474,7 +475,8 @@ model Project {
|
|||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||
roundProjects RoundProject[]
|
||||
files ProjectFile[]
|
||||
assignments Assignment[]
|
||||
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
|
||||
|
|
@ -485,8 +487,7 @@ model Project {
|
|||
awardVotes AwardVote[]
|
||||
wonAwards SpecialAward[] @relation("AwardWinner")
|
||||
|
||||
@@index([roundId])
|
||||
@@index([status])
|
||||
@@index([programId])
|
||||
@@index([tags])
|
||||
@@index([submissionSource])
|
||||
@@index([submittedByUserId])
|
||||
|
|
@ -495,6 +496,23 @@ model Project {
|
|||
@@index([country])
|
||||
}
|
||||
|
||||
model RoundProject {
|
||||
id String @id @default(cuid())
|
||||
roundId String
|
||||
projectId String
|
||||
status ProjectStatus @default(SUBMITTED)
|
||||
addedAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([roundId, projectId])
|
||||
@@index([roundId])
|
||||
@@index([projectId])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model ProjectFile {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
|
|
|
|||
|
|
@ -321,7 +321,7 @@ async function main() {
|
|||
// Check if project already exists
|
||||
const existingProject = await prisma.project.findFirst({
|
||||
where: {
|
||||
roundId: round.id,
|
||||
programId: program.id,
|
||||
OR: [
|
||||
{ title: projectName },
|
||||
{ submittedByEmail: email },
|
||||
|
|
@ -365,10 +365,9 @@ async function main() {
|
|||
// Create project
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
roundId: round.id,
|
||||
programId: program.id,
|
||||
title: projectName,
|
||||
description: row['Comment ']?.trim() || null,
|
||||
status: 'SUBMITTED',
|
||||
competitionCategory: mapCategory(row['Category']),
|
||||
oceanIssue: mapOceanIssue(row['Issue']),
|
||||
country: extractCountry(row['Country']),
|
||||
|
|
@ -392,6 +391,15 @@ async function main() {
|
|||
},
|
||||
})
|
||||
|
||||
// Create round-project association
|
||||
await prisma.roundProject.create({
|
||||
data: {
|
||||
roundId: round.id,
|
||||
projectId: project.id,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
|
||||
// Create team lead membership
|
||||
await prisma.teamMember.create({
|
||||
data: {
|
||||
|
|
@ -466,7 +474,7 @@ async function main() {
|
|||
console.log('\nBackfilling missing country codes...\n')
|
||||
let backfilled = 0
|
||||
const nullCountryProjects = await prisma.project.findMany({
|
||||
where: { roundId: round.id, country: null },
|
||||
where: { programId: program.id, country: null },
|
||||
select: { id: true, submittedByEmail: true, title: true },
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -64,13 +64,14 @@ async function main() {
|
|||
|
||||
console.log(`Voting window: ${votingStart.toISOString()} → ${votingEnd.toISOString()}\n`)
|
||||
|
||||
// Get some projects to assign
|
||||
const projects = await prisma.project.findMany({
|
||||
// Get some projects to assign (via RoundProject)
|
||||
const roundProjects = await prisma.roundProject.findMany({
|
||||
where: { roundId: round.id },
|
||||
take: 8,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, title: true },
|
||||
orderBy: { addedAt: 'desc' },
|
||||
select: { project: { select: { id: true, title: true } } },
|
||||
})
|
||||
const projects = roundProjects.map(rp => rp.project)
|
||||
|
||||
if (projects.length === 0) {
|
||||
console.error('No projects found! Run seed-candidatures first.')
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@ export default function MemberDetailPage() {
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{assignment.project.status}
|
||||
{assignment.project.roundProjects?.[0]?.status ?? 'SUBMITTED'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -112,11 +112,11 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
|
|||
where: { programId: editionId },
|
||||
}),
|
||||
prisma.project.count({
|
||||
where: { round: { programId: editionId } },
|
||||
where: { programId: editionId },
|
||||
}),
|
||||
prisma.project.count({
|
||||
where: {
|
||||
round: { programId: editionId },
|
||||
programId: editionId,
|
||||
createdAt: { gte: sevenDaysAgo },
|
||||
},
|
||||
}),
|
||||
|
|
@ -149,7 +149,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
|
|||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
projects: true,
|
||||
roundProjects: true,
|
||||
assignments: true,
|
||||
},
|
||||
},
|
||||
|
|
@ -161,31 +161,33 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
|
|||
},
|
||||
}),
|
||||
prisma.project.findMany({
|
||||
where: { round: { programId: editionId } },
|
||||
where: { programId: editionId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 8,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
status: true,
|
||||
country: true,
|
||||
competitionCategory: true,
|
||||
oceanIssue: true,
|
||||
logoKey: true,
|
||||
createdAt: true,
|
||||
submittedAt: true,
|
||||
round: { select: { name: true } },
|
||||
roundProjects: {
|
||||
select: { status: true, round: { select: { name: true } } },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.project.groupBy({
|
||||
by: ['competitionCategory'],
|
||||
where: { round: { programId: editionId } },
|
||||
where: { programId: editionId },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.project.groupBy({
|
||||
by: ['oceanIssue'],
|
||||
where: { round: { programId: editionId } },
|
||||
where: { programId: editionId },
|
||||
_count: true,
|
||||
}),
|
||||
])
|
||||
|
|
@ -392,7 +394,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
|
|||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{round._count.projects} projects · {round._count.assignments} assignments
|
||||
{round._count.roundProjects} projects · {round._count.assignments} assignments
|
||||
{round.totalEvals > 0 && (
|
||||
<> · {round.evalPercent}% evaluated</>
|
||||
)}
|
||||
|
|
@ -459,10 +461,10 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
|
|||
{truncate(project.title, 45)}
|
||||
</p>
|
||||
<Badge
|
||||
variant={statusColors[project.status] || 'secondary'}
|
||||
variant={statusColors[project.roundProjects[0]?.status ?? 'SUBMITTED'] || 'secondary'}
|
||||
className="shrink-0 text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{project.status.replace('_', ' ')}
|
||||
{(project.roundProjects[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
|||
{round.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{round._count.projects}</TableCell>
|
||||
<TableCell>{round._count.roundProjects}</TableCell>
|
||||
<TableCell>{round._count.assignments}</TableCell>
|
||||
<TableCell>{formatDateOnly(round.createdAt)}</TableCell>
|
||||
</TableRow>
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export default function ProjectAssignmentsPage() {
|
|||
</div>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href={`/admin/rounds/${project?.roundId}/assignments`}>
|
||||
<Link href={`/admin/rounds/${project?.roundProjects?.[0]?.round?.id}/assignments`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Manage in Round
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
|||
|
||||
// Fetch existing tags for suggestions
|
||||
const { data: existingTags } = trpc.project.getTags.useQuery({
|
||||
roundId: project?.roundId,
|
||||
roundId: project?.roundProjects?.[0]?.round?.id,
|
||||
})
|
||||
|
||||
// Mutations
|
||||
|
|
@ -162,7 +162,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
|||
title: project.title,
|
||||
teamName: project.teamName || '',
|
||||
description: project.description || '',
|
||||
status: project.status as UpdateProjectForm['status'],
|
||||
status: (project.roundProjects?.[0]?.status ?? 'SUBMITTED') as UpdateProjectForm['status'],
|
||||
tags: project.tags || [],
|
||||
})
|
||||
}
|
||||
|
|
@ -197,6 +197,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
|||
teamName: data.teamName || null,
|
||||
description: data.description || null,
|
||||
status: data.status,
|
||||
roundId: project?.roundProjects?.[0]?.round?.id,
|
||||
tags: data.tags,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,20 +139,29 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
fallback="initials"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
|
||||
{project.roundProjects?.length > 0 ? (
|
||||
project.roundProjects.map((rp, i) => (
|
||||
<span key={rp.round.id} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-muted-foreground/50">/</span>}
|
||||
<Link
|
||||
href={`/admin/rounds/${project.round.id}`}
|
||||
href={`/admin/rounds/${rp.round.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{project.round.name}
|
||||
{rp.round.name}
|
||||
</Link>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span>No round</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{project.title}
|
||||
</h1>
|
||||
<Badge variant={statusColors[project.status] || 'secondary'}>
|
||||
{project.status.replace('_', ' ')}
|
||||
<Badge variant={statusColors[project.roundProjects?.[0]?.status ?? 'SUBMITTED'] || 'secondary'}>
|
||||
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
{project.teamName && (
|
||||
|
|
@ -504,7 +513,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${project.roundId}/assignments`}>
|
||||
<Link href={`/admin/rounds/${project.roundProjects?.[0]?.round?.id}/assignments`}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ function ImportPageContent() {
|
|||
const rounds = programs?.flatMap((p) =>
|
||||
(p.rounds || []).map((r) => ({
|
||||
...r,
|
||||
programId: p.id,
|
||||
programName: `${p.year} Edition`,
|
||||
}))
|
||||
) || []
|
||||
|
|
@ -170,6 +171,7 @@ function ImportPageContent() {
|
|||
</TabsList>
|
||||
<TabsContent value="csv" className="mt-4">
|
||||
<CSVImportForm
|
||||
programId={selectedRound.programId}
|
||||
roundId={selectedRoundId}
|
||||
roundName={selectedRound.name}
|
||||
onSuccess={() => {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ function NewProjectPageContent() {
|
|||
const rounds = programs?.flatMap((p) =>
|
||||
(p.rounds || []).map((r) => ({
|
||||
...r,
|
||||
programId: p.id,
|
||||
programName: `${p.year} Edition`,
|
||||
}))
|
||||
) || []
|
||||
|
|
@ -117,6 +118,7 @@ function NewProjectPageContent() {
|
|||
})
|
||||
|
||||
createProject.mutate({
|
||||
programId: selectedRound!.programId,
|
||||
roundId: selectedRoundId,
|
||||
title: title.trim(),
|
||||
teamName: teamName.trim() || undefined,
|
||||
|
|
|
|||
|
|
@ -350,9 +350,9 @@ export default function ProjectsPage() {
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p>{project.round.name}</p>
|
||||
<p>{project.roundProjects?.[0]?.round?.name ?? '-'}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.round.program?.name}
|
||||
{project.program?.name}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
|
@ -365,9 +365,9 @@ export default function ProjectsPage() {
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={statusColors[project.status] || 'secondary'}
|
||||
variant={statusColors[project.roundProjects?.[0]?.status ?? 'SUBMITTED'] || 'secondary'}
|
||||
>
|
||||
{project.status.replace('_', ' ')}
|
||||
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="relative z-10 text-right">
|
||||
|
|
@ -431,11 +431,11 @@ export default function ProjectsPage() {
|
|||
</CardTitle>
|
||||
<Badge
|
||||
variant={
|
||||
statusColors[project.status] || 'secondary'
|
||||
statusColors[project.roundProjects?.[0]?.status ?? 'SUBMITTED'] || 'secondary'
|
||||
}
|
||||
className="shrink-0"
|
||||
>
|
||||
{project.status.replace('_', ' ')}
|
||||
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>{project.teamName}</CardDescription>
|
||||
|
|
@ -445,7 +445,7 @@ export default function ProjectsPage() {
|
|||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Round</span>
|
||||
<span>{project.round.name}</span>
|
||||
<span>{project.roundProjects?.[0]?.round?.name ?? '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Assignments</span>
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export interface ProjectFilters {
|
|||
}
|
||||
|
||||
interface FilterOptions {
|
||||
rounds: Array<{ id: string; name: string; program: { name: string; year: number } }>
|
||||
rounds: Array<{ id: string; name: string; sortOrder: number; program: { name: string; year: number } }>
|
||||
countries: string[]
|
||||
categories: Array<{ value: string; count: number }>
|
||||
issues: Array<{ value: string; count: number }>
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
|
|||
if (storedOrder.length > 0) {
|
||||
setProjectOrder(storedOrder)
|
||||
} else {
|
||||
setProjectOrder(sessionData.round.projects.map((p) => p.id))
|
||||
setProjectOrder(sessionData.round.roundProjects.map((rp) => rp.project.id))
|
||||
}
|
||||
}
|
||||
}, [sessionData])
|
||||
|
|
@ -253,7 +253,7 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
const projects = sessionData.round.projects
|
||||
const projects = sessionData.round.roundProjects.map((rp) => rp.project)
|
||||
const sortedProjects = projectOrder
|
||||
.map((id) => projects.find((p) => p.id === id))
|
||||
.filter((p): p is Project => !!p)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { Suspense, use } from 'react'
|
||||
import { Suspense, use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
|
@ -44,8 +44,14 @@ import {
|
|||
Filter,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Plus,
|
||||
ArrowRightCircle,
|
||||
Minus,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
||||
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
|
||||
import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog'
|
||||
import { format, formatDistanceToNow, isPast, isFuture } from 'date-fns'
|
||||
|
||||
interface PageProps {
|
||||
|
|
@ -54,6 +60,9 @@ interface PageProps {
|
|||
|
||||
function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
const router = useRouter()
|
||||
const [assignOpen, setAssignOpen] = useState(false)
|
||||
const [advanceOpen, setAdvanceOpen] = useState(false)
|
||||
const [removeOpen, setRemoveOpen] = useState(false)
|
||||
|
||||
const { data: round, isLoading } = trpc.round.get.useQuery({ id: roundId })
|
||||
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
|
||||
|
|
@ -235,7 +244,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{round._count.projects}</div>
|
||||
<div className="text-2xl font-bold">{round._count.roundProjects}</div>
|
||||
<Button variant="link" size="sm" className="px-0" asChild>
|
||||
<Link href={`/admin/projects?round=${round.id}`}>View projects</Link>
|
||||
</Button>
|
||||
|
|
@ -423,9 +432,43 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
View Projects
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setAssignOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Assign Projects
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setAdvanceOpen(true)}>
|
||||
<ArrowRightCircle className="mr-2 h-4 w-4" />
|
||||
Advance Projects
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setRemoveOpen(true)}>
|
||||
<Minus className="mr-2 h-4 w-4" />
|
||||
Remove Projects
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dialogs */}
|
||||
<AssignProjectsDialog
|
||||
roundId={roundId}
|
||||
programId={round.program.id}
|
||||
open={assignOpen}
|
||||
onOpenChange={setAssignOpen}
|
||||
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
|
||||
/>
|
||||
<AdvanceProjectsDialog
|
||||
roundId={roundId}
|
||||
programId={round.program.id}
|
||||
open={advanceOpen}
|
||||
onOpenChange={setAdvanceOpen}
|
||||
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
|
||||
/>
|
||||
<RemoveProjectsDialog
|
||||
roundId={roundId}
|
||||
open={removeOpen}
|
||||
onOpenChange={setRemoveOpen}
|
||||
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import {
|
|||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -34,6 +33,7 @@ import {
|
|||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
||||
import { ArrowLeft, Loader2, AlertCircle } from 'lucide-react'
|
||||
|
||||
const createRoundSchema = z.object({
|
||||
|
|
@ -58,6 +58,8 @@ function CreateRoundContent() {
|
|||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const programIdParam = searchParams.get('program')
|
||||
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
|
||||
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
|
||||
|
||||
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery()
|
||||
|
||||
|
|
@ -82,7 +84,9 @@ function CreateRoundContent() {
|
|||
await createRound.mutateAsync({
|
||||
programId: data.programId,
|
||||
name: data.name,
|
||||
roundType,
|
||||
requiredReviews: data.requiredReviews,
|
||||
settingsJson: roundSettings,
|
||||
votingStartAt: data.votingStartAt ? new Date(data.votingStartAt) : undefined,
|
||||
votingEndAt: data.votingEndAt ? new Date(data.votingEndAt) : undefined,
|
||||
})
|
||||
|
|
@ -218,6 +222,14 @@ function CreateRoundContent() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Round Type & Settings */}
|
||||
<RoundTypeSettings
|
||||
roundType={roundType}
|
||||
onRoundTypeChange={setRoundType}
|
||||
settings={roundSettings}
|
||||
onSettingsChange={setRoundSettings}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Voting Window</CardTitle>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ import {
|
|||
Archive,
|
||||
Trash2,
|
||||
Loader2,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import { format, isPast, isFuture } from 'date-fns'
|
||||
|
||||
|
|
@ -106,6 +108,7 @@ function RoundsContent() {
|
|||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">Order</TableHead>
|
||||
<TableHead>Round</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Voting Window</TableHead>
|
||||
|
|
@ -115,8 +118,15 @@ function RoundsContent() {
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{program.rounds.map((round) => (
|
||||
<RoundRow key={round.id} round={round} />
|
||||
{program.rounds.map((round, index) => (
|
||||
<RoundRow
|
||||
key={round.id}
|
||||
round={round}
|
||||
index={index}
|
||||
totalRounds={program.rounds.length}
|
||||
allRoundIds={program.rounds.map((r) => r.id)}
|
||||
programId={program.id}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
|
@ -133,10 +143,40 @@ function RoundsContent() {
|
|||
)
|
||||
}
|
||||
|
||||
function RoundRow({ round }: { round: any }) {
|
||||
function RoundRow({
|
||||
round,
|
||||
index,
|
||||
totalRounds,
|
||||
allRoundIds,
|
||||
programId,
|
||||
}: {
|
||||
round: any
|
||||
index: number
|
||||
totalRounds: number
|
||||
allRoundIds: string[]
|
||||
programId: string
|
||||
}) {
|
||||
const utils = trpc.useUtils()
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
|
||||
const reorder = trpc.round.reorder.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.program.list.invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
const moveUp = () => {
|
||||
const ids = [...allRoundIds]
|
||||
;[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]]
|
||||
reorder.mutate({ programId, roundIds: ids })
|
||||
}
|
||||
|
||||
const moveDown = () => {
|
||||
const ids = [...allRoundIds]
|
||||
;[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]]
|
||||
reorder.mutate({ programId, roundIds: ids })
|
||||
}
|
||||
|
||||
const updateStatus = trpc.round.updateStatus.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.program.list.invalidate()
|
||||
|
|
@ -229,6 +269,28 @@ function RoundRow({ round }: { round: any }) {
|
|||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={moveUp}
|
||||
disabled={index === 0 || reorder.isPending}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={moveDown}
|
||||
disabled={index === totalRounds - 1 || reorder.isPending}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/admin/rounds/${round.id}`}
|
||||
|
|
@ -242,7 +304,7 @@ function RoundRow({ round }: { round: any }) {
|
|||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
{round._count?.projects || 0}
|
||||
{round._count?.roundProjects || 0}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
|
@ -325,9 +387,9 @@ function RoundRow({ round }: { round: any }) {
|
|||
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{round.name}"? This will
|
||||
permanently delete all {round._count?.projects || 0} projects,{' '}
|
||||
{round._count?.assignments || 0} assignments, and all evaluations
|
||||
in this round. This action cannot be undone.
|
||||
remove {round._count?.roundProjects || 0} project assignments,{' '}
|
||||
{round._count?.assignments || 0} reviewer assignments, and all evaluations
|
||||
in this round. The projects themselves will remain in the program. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ async function AssignmentsContent({
|
|||
title: true,
|
||||
teamName: true,
|
||||
description: true,
|
||||
status: true,
|
||||
files: {
|
||||
select: {
|
||||
id: true,
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ async function JuryDashboardContent() {
|
|||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
round: {
|
||||
|
|
|
|||
|
|
@ -34,11 +34,18 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
|
|||
redirect('/login')
|
||||
}
|
||||
|
||||
// Get project with assignment info for this user
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
// Check if user is assigned to this project
|
||||
const assignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
files: true,
|
||||
evaluation: {
|
||||
include: {
|
||||
form: true,
|
||||
},
|
||||
},
|
||||
round: {
|
||||
include: {
|
||||
program: {
|
||||
|
|
@ -53,25 +60,18 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
|
|||
},
|
||||
})
|
||||
|
||||
// Get project details
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
files: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Check if user is assigned to this project
|
||||
const assignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
evaluation: {
|
||||
include: {
|
||||
form: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -95,7 +95,7 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
const round = project.round
|
||||
const round = assignment.round
|
||||
const now = new Date()
|
||||
|
||||
// Check voting window
|
||||
|
|
|
|||
|
|
@ -49,10 +49,18 @@ async function EvaluationContent({ projectId }: { projectId: string }) {
|
|||
redirect('/login')
|
||||
}
|
||||
|
||||
// Get project with assignment info for this user
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
// Check if user is assigned to this project
|
||||
const assignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
evaluation: {
|
||||
include: {
|
||||
form: true,
|
||||
},
|
||||
},
|
||||
round: {
|
||||
include: {
|
||||
program: {
|
||||
|
|
@ -67,25 +75,20 @@ async function EvaluationContent({ projectId }: { projectId: string }) {
|
|||
},
|
||||
})
|
||||
|
||||
// Get project details
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Check if user is assigned to this project
|
||||
const assignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
evaluation: {
|
||||
include: {
|
||||
form: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -145,7 +148,7 @@ async function EvaluationContent({ projectId }: { projectId: string }) {
|
|||
const criterionScores =
|
||||
(evaluation.criterionScoresJson as unknown as Record<string, number>) || {}
|
||||
|
||||
const round = project.round
|
||||
const round = assignment.round
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
|
|||
|
|
@ -43,11 +43,14 @@ async function ProjectContent({ projectId }: { projectId: string }) {
|
|||
redirect('/login')
|
||||
}
|
||||
|
||||
// Get project with assignment info for this user
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
// Check if user is assigned to this project
|
||||
const assignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
files: true,
|
||||
evaluation: true,
|
||||
round: {
|
||||
include: {
|
||||
program: {
|
||||
|
|
@ -62,21 +65,18 @@ async function ProjectContent({ projectId }: { projectId: string }) {
|
|||
},
|
||||
})
|
||||
|
||||
// Get project details
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
files: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Check if user is assigned to this project
|
||||
const assignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
evaluation: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!assignment) {
|
||||
// User is not assigned to this project
|
||||
return (
|
||||
|
|
@ -99,7 +99,7 @@ async function ProjectContent({ projectId }: { projectId: string }) {
|
|||
}
|
||||
|
||||
const evaluation = assignment.evaluation
|
||||
const round = project.round
|
||||
const round = assignment.round
|
||||
const now = new Date()
|
||||
|
||||
// Check voting window
|
||||
|
|
|
|||
|
|
@ -149,18 +149,24 @@ export default function MentorDashboard() {
|
|||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{project.round.program.year} Edition
|
||||
{project.program.year} Edition
|
||||
</span>
|
||||
{project.roundProjects?.[0]?.round && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{project.round.name}</span>
|
||||
<span>{project.roundProjects[0].round.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{project.title}
|
||||
{project.roundProjects?.[0]?.status && (
|
||||
<Badge
|
||||
variant={statusColors[project.status] || 'secondary'}
|
||||
variant={statusColors[project.roundProjects[0].status] || 'secondary'}
|
||||
>
|
||||
{project.status.replace('_', ' ')}
|
||||
{project.roundProjects[0].status.replace('_', ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
{project.teamName && (
|
||||
<CardDescription>{project.teamName}</CardDescription>
|
||||
|
|
|
|||
|
|
@ -109,18 +109,24 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{project.round.program.year} Edition
|
||||
{project.program.year} Edition
|
||||
</span>
|
||||
{project.roundProjects?.[0]?.round && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{project.round.name}</span>
|
||||
<span>{project.roundProjects[0].round.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{project.title}
|
||||
</h1>
|
||||
<Badge variant={statusColors[project.status] || 'secondary'}>
|
||||
{project.status.replace('_', ' ')}
|
||||
{project.roundProjects?.[0]?.status && (
|
||||
<Badge variant={statusColors[project.roundProjects[0].status] || 'secondary'}>
|
||||
{project.roundProjects[0].status.replace('_', ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{project.teamName && (
|
||||
<p className="text-muted-foreground">{project.teamName}</p>
|
||||
|
|
|
|||
|
|
@ -94,16 +94,22 @@ export default function MentorProjectsPage() {
|
|||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{project.round.program.year} Edition
|
||||
{project.program.year} Edition
|
||||
</span>
|
||||
{project.roundProjects?.[0]?.round && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{project.round.name}</span>
|
||||
<span>{project.roundProjects[0].round.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{project.title}
|
||||
<Badge variant={statusColors[project.status] || 'secondary'}>
|
||||
{project.status.replace('_', ' ')}
|
||||
{project.roundProjects?.[0]?.status && (
|
||||
<Badge variant={statusColors[project.roundProjects[0].status] || 'secondary'}>
|
||||
{project.roundProjects[0].status.replace('_', ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
{project.teamName && (
|
||||
<CardDescription>{project.teamName}</CardDescription>
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ async function ObserverDashboardContent() {
|
|||
program: { select: { name: true, year: true } },
|
||||
_count: {
|
||||
select: {
|
||||
projects: true,
|
||||
roundProjects: true,
|
||||
assignments: true,
|
||||
},
|
||||
},
|
||||
|
|
@ -176,7 +176,7 @@ async function ObserverDashboardContent() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<p>{round._count.projects} projects</p>
|
||||
<p>{round._count.roundProjects} projects</p>
|
||||
<p className="text-muted-foreground">
|
||||
{round._count.assignments} assignments
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ async function ReportsContent() {
|
|||
},
|
||||
_count: {
|
||||
select: {
|
||||
projects: true,
|
||||
roundProjects: true,
|
||||
assignments: true,
|
||||
},
|
||||
},
|
||||
|
|
@ -70,7 +70,7 @@ async function ReportsContent() {
|
|||
})
|
||||
|
||||
// Calculate totals
|
||||
const totalProjects = roundStats.reduce((acc, r) => acc + r._count.projects, 0)
|
||||
const totalProjects = roundStats.reduce((acc, r) => acc + r._count.roundProjects, 0)
|
||||
const totalAssignments = roundStats.reduce(
|
||||
(acc, r) => acc + r.totalAssignments,
|
||||
0
|
||||
|
|
@ -176,7 +176,7 @@ async function ReportsContent() {
|
|||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{round.program.name}</TableCell>
|
||||
<TableCell>{round._count.projects}</TableCell>
|
||||
<TableCell>{round._count.roundProjects}</TableCell>
|
||||
<TableCell>
|
||||
<div className="min-w-[120px] space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
|
|
@ -237,7 +237,7 @@ async function ReportsContent() {
|
|||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{round._count.projects} projects</span>
|
||||
<span>{round._count.roundProjects} projects</span>
|
||||
<span className="text-muted-foreground">
|
||||
{round.completedEvaluations}/{round.totalAssignments} evaluations
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ export function SubmissionDetailClient() {
|
|||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{project.round.program.year} Edition - {project.round.name}
|
||||
{project.roundProjects?.[0]?.round?.program?.year ? `${project.roundProjects[0].round.program.year} Edition` : ''}{project.roundProjects?.[0]?.round?.name ? ` - ${project.roundProjects[0].round.name}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -131,18 +131,24 @@ export function MySubmissionClient() {
|
|||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{submissions.map((project) => (
|
||||
{submissions.map((project) => {
|
||||
const latestRoundProject = project.roundProjects?.[0]
|
||||
const projectStatus = latestRoundProject?.status ?? 'SUBMITTED'
|
||||
const roundName = latestRoundProject?.round?.name
|
||||
const programYear = latestRoundProject?.round?.program?.year
|
||||
|
||||
return (
|
||||
<Card key={project.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{project.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{project.round.program.year} Edition - {project.round.name}
|
||||
{programYear ? `${programYear} Edition` : ''}{roundName ? ` - ${roundName}` : ''}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={statusColors[project.status] || 'secondary'}>
|
||||
{project.status.replace('_', ' ')}
|
||||
<Badge variant={statusColors[projectStatus] || 'secondary'}>
|
||||
{projectStatus.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
|
@ -197,22 +203,22 @@ export function MySubmissionClient() {
|
|||
status: 'UNDER_REVIEW',
|
||||
label: 'Under Review',
|
||||
date: null,
|
||||
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status),
|
||||
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(projectStatus),
|
||||
},
|
||||
{
|
||||
status: 'SEMIFINALIST',
|
||||
label: 'Semi-finalist',
|
||||
date: null,
|
||||
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status),
|
||||
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(projectStatus),
|
||||
},
|
||||
{
|
||||
status: 'FINALIST',
|
||||
label: 'Finalist',
|
||||
date: null,
|
||||
completed: ['FINALIST', 'WINNER'].includes(project.status),
|
||||
completed: ['FINALIST', 'WINNER'].includes(projectStatus),
|
||||
},
|
||||
]}
|
||||
currentStatus={project.status}
|
||||
currentStatus={projectStatus}
|
||||
className="mt-4"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -229,7 +235,8 @@ export function MySubmissionClient() {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 "Submitted" 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -38,7 +38,8 @@ import {
|
|||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CSVImportFormProps {
|
||||
roundId: string
|
||||
programId: string
|
||||
roundId?: string
|
||||
roundName: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
|
@ -72,7 +73,7 @@ interface MappedProject {
|
|||
metadataJson?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function CSVImportForm({ roundId, roundName, onSuccess }: CSVImportFormProps) {
|
||||
export function CSVImportForm({ programId, roundId, roundName, onSuccess }: CSVImportFormProps) {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState<Step>('upload')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
|
|
@ -224,6 +225,7 @@ export function CSVImportForm({ roundId, roundName, onSuccess }: CSVImportFormPr
|
|||
|
||||
try {
|
||||
await importMutation.mutateAsync({
|
||||
programId,
|
||||
roundId,
|
||||
projects: valid,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -148,8 +148,10 @@ export const analyticsRouter = router({
|
|||
getProjectRankings: adminProcedure
|
||||
.input(z.object({ roundId: z.string(), limit: z.number().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
const roundProjects = await ctx.prisma.roundProject.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
assignments: {
|
||||
include: {
|
||||
|
|
@ -159,11 +161,14 @@ export const analyticsRouter = router({
|
|||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Calculate average scores
|
||||
const rankings = projects
|
||||
.map((project) => {
|
||||
const rankings = roundProjects
|
||||
.map((rp) => {
|
||||
const project = rp.project
|
||||
const allScores: number[] = []
|
||||
|
||||
project.assignments.forEach((assignment) => {
|
||||
|
|
@ -195,7 +200,7 @@ export const analyticsRouter = router({
|
|||
id: project.id,
|
||||
title: project.title,
|
||||
teamName: project.teamName,
|
||||
status: project.status,
|
||||
status: rp.status,
|
||||
averageScore,
|
||||
evaluationCount: allScores.length,
|
||||
}
|
||||
|
|
@ -212,15 +217,15 @@ export const analyticsRouter = router({
|
|||
getStatusBreakdown: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.groupBy({
|
||||
const roundProjects = await ctx.prisma.roundProject.groupBy({
|
||||
by: ['status'],
|
||||
where: { roundId: input.roundId },
|
||||
_count: true,
|
||||
})
|
||||
|
||||
return projects.map((p) => ({
|
||||
status: p.status,
|
||||
count: p._count,
|
||||
return roundProjects.map((rp) => ({
|
||||
status: rp.status,
|
||||
count: rp._count,
|
||||
}))
|
||||
}),
|
||||
|
||||
|
|
@ -237,7 +242,7 @@ export const analyticsRouter = router({
|
|||
jurorCount,
|
||||
statusCounts,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.project.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.roundProject.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.evaluation.count({
|
||||
where: {
|
||||
|
|
@ -249,7 +254,7 @@ export const analyticsRouter = router({
|
|||
by: ['userId'],
|
||||
where: { roundId: input.roundId },
|
||||
}),
|
||||
ctx.prisma.project.groupBy({
|
||||
ctx.prisma.roundProject.groupBy({
|
||||
by: ['status'],
|
||||
where: { roundId: input.roundId },
|
||||
_count: true,
|
||||
|
|
@ -348,8 +353,8 @@ export const analyticsRouter = router({
|
|||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where = input.roundId
|
||||
? { roundId: input.roundId }
|
||||
: { round: { programId: input.programId } }
|
||||
? { roundProjects: { some: { roundId: input.roundId } } }
|
||||
: { programId: input.programId }
|
||||
|
||||
const distribution = await ctx.prisma.project.groupBy({
|
||||
by: ['country'],
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export const applicantRouter = router({
|
|||
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
roundProjects: { some: { roundId: input.roundId } },
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{
|
||||
|
|
@ -74,11 +74,16 @@ export const applicantRouter = router({
|
|||
},
|
||||
include: {
|
||||
files: true,
|
||||
roundProjects: {
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
teamMembers: {
|
||||
include: {
|
||||
user: {
|
||||
|
|
@ -171,26 +176,47 @@ export const applicantRouter = router({
|
|||
...data,
|
||||
metadataJson: metadataJson as unknown ?? undefined,
|
||||
submittedAt: submit && !existing.submittedAt ? now : existing.submittedAt,
|
||||
status: submit ? 'SUBMITTED' : existing.status,
|
||||
},
|
||||
})
|
||||
|
||||
// Update RoundProject status if submitting
|
||||
if (submit) {
|
||||
await ctx.prisma.roundProject.updateMany({
|
||||
where: { projectId: projectId },
|
||||
data: { status: 'SUBMITTED' },
|
||||
})
|
||||
}
|
||||
|
||||
return project
|
||||
} else {
|
||||
// Create new
|
||||
// Get the round to find the programId
|
||||
const roundForCreate = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { programId: true },
|
||||
})
|
||||
|
||||
// Create new project
|
||||
const project = await ctx.prisma.project.create({
|
||||
data: {
|
||||
roundId,
|
||||
programId: roundForCreate.programId,
|
||||
...data,
|
||||
metadataJson: metadataJson as unknown ?? undefined,
|
||||
submittedByUserId: ctx.user.id,
|
||||
submittedByEmail: ctx.user.email,
|
||||
submissionSource: 'MANUAL',
|
||||
status: 'SUBMITTED',
|
||||
submittedAt: submit ? now : null,
|
||||
},
|
||||
})
|
||||
|
||||
// Create RoundProject entry
|
||||
await ctx.prisma.roundProject.create({
|
||||
data: {
|
||||
roundId,
|
||||
projectId: project.id,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
|
|
@ -385,12 +411,17 @@ export const applicantRouter = router({
|
|||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
roundProjects: {
|
||||
include: {
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { addedAt: 'desc' },
|
||||
},
|
||||
files: true,
|
||||
teamMembers: {
|
||||
include: {
|
||||
|
|
@ -409,6 +440,10 @@ export const applicantRouter = router({
|
|||
})
|
||||
}
|
||||
|
||||
// Get the latest round project status
|
||||
const latestRoundProject = project.roundProjects[0]
|
||||
const currentStatus = latestRoundProject?.status ?? 'SUBMITTED'
|
||||
|
||||
// Build timeline
|
||||
const timeline = [
|
||||
{
|
||||
|
|
@ -426,27 +461,27 @@ export const applicantRouter = router({
|
|||
{
|
||||
status: 'UNDER_REVIEW',
|
||||
label: 'Under Review',
|
||||
date: project.status === 'SUBMITTED' && project.submittedAt ? project.submittedAt : null,
|
||||
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status),
|
||||
date: currentStatus === 'SUBMITTED' && project.submittedAt ? project.submittedAt : null,
|
||||
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus),
|
||||
},
|
||||
{
|
||||
status: 'SEMIFINALIST',
|
||||
label: 'Semi-finalist',
|
||||
date: null, // Would need status change tracking
|
||||
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status),
|
||||
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus),
|
||||
},
|
||||
{
|
||||
status: 'FINALIST',
|
||||
label: 'Finalist',
|
||||
date: null,
|
||||
completed: ['FINALIST', 'WINNER'].includes(project.status),
|
||||
completed: ['FINALIST', 'WINNER'].includes(currentStatus),
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
project,
|
||||
timeline,
|
||||
currentStatus: project.status,
|
||||
currentStatus,
|
||||
}
|
||||
}),
|
||||
|
||||
|
|
@ -473,12 +508,17 @@ export const applicantRouter = router({
|
|||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
roundProjects: {
|
||||
include: {
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { addedAt: 'desc' },
|
||||
},
|
||||
files: true,
|
||||
teamMembers: {
|
||||
include: {
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ export const applicationRouter = router({
|
|||
// Check if email already submitted for this round
|
||||
const existingProject = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
roundId,
|
||||
roundProjects: { some: { roundId } },
|
||||
submittedByEmail: data.contactEmail,
|
||||
},
|
||||
})
|
||||
|
|
@ -218,11 +218,10 @@ export const applicationRouter = router({
|
|||
// Create the project
|
||||
const project = await ctx.prisma.project.create({
|
||||
data: {
|
||||
roundId,
|
||||
programId: round.programId,
|
||||
title: data.projectName,
|
||||
teamName: data.teamName,
|
||||
description: data.description,
|
||||
status: 'SUBMITTED',
|
||||
competitionCategory: data.competitionCategory,
|
||||
oceanIssue: data.oceanIssue,
|
||||
country: data.country,
|
||||
|
|
@ -242,6 +241,15 @@ export const applicationRouter = router({
|
|||
},
|
||||
})
|
||||
|
||||
// Create RoundProject entry
|
||||
await ctx.prisma.roundProject.create({
|
||||
data: {
|
||||
roundId,
|
||||
projectId: project.id,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
|
||||
// Create team lead membership
|
||||
await ctx.prisma.teamMember.create({
|
||||
data: {
|
||||
|
|
@ -320,7 +328,7 @@ export const applicationRouter = router({
|
|||
.query(async ({ ctx, input }) => {
|
||||
const existing = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
roundProjects: { some: { roundId: input.roundId } },
|
||||
submittedByEmail: input.email,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -286,13 +286,17 @@ export const assignmentRouter = router({
|
|||
where: { roundId: input.roundId },
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.project.findMany({
|
||||
ctx.prisma.roundProject.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
_count: { select: { assignments: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
|
|
@ -302,7 +306,7 @@ export const assignmentRouter = router({
|
|||
})
|
||||
|
||||
const projectsWithFullCoverage = projectCoverage.filter(
|
||||
(p) => p._count.assignments >= round.requiredReviews
|
||||
(rp) => rp.project._count.assignments >= round.requiredReviews
|
||||
).length
|
||||
|
||||
return {
|
||||
|
|
@ -354,15 +358,20 @@ export const assignmentRouter = router({
|
|||
})
|
||||
|
||||
// Get all projects that need more assignments
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
tags: true,
|
||||
_count: { select: { assignments: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const projects = roundProjectEntries.map((rp) => rp.project)
|
||||
|
||||
// Get existing assignments to avoid duplicates
|
||||
const existingAssignments = await ctx.prisma.assignment.findMany({
|
||||
|
|
@ -482,8 +491,10 @@ export const assignmentRouter = router({
|
|||
})
|
||||
|
||||
// Get all projects in the round
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
|
|
@ -492,7 +503,10 @@ export const assignmentRouter = router({
|
|||
teamName: true,
|
||||
_count: { select: { assignments: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const projects = roundProjectEntries.map((rp) => rp.project)
|
||||
|
||||
// Get existing assignments
|
||||
const existingAssignments = await ctx.prisma.assignment.findMany({
|
||||
|
|
|
|||
|
|
@ -103,8 +103,10 @@ export const exportRouter = router({
|
|||
projectScores: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
assignments: {
|
||||
include: {
|
||||
|
|
@ -114,10 +116,13 @@ export const exportRouter = router({
|
|||
},
|
||||
},
|
||||
},
|
||||
orderBy: { title: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { project: { title: 'asc' } },
|
||||
})
|
||||
|
||||
const data = projects.map((p) => {
|
||||
const data = roundProjectEntries.map((rp) => {
|
||||
const p = rp.project
|
||||
const evaluations = p.assignments
|
||||
.map((a) => a.evaluation)
|
||||
.filter((e) => e !== null)
|
||||
|
|
@ -133,7 +138,7 @@ export const exportRouter = router({
|
|||
return {
|
||||
title: p.title,
|
||||
teamName: p.teamName,
|
||||
status: p.status,
|
||||
status: rp.status,
|
||||
tags: p.tags.join(', '),
|
||||
totalEvaluations: evaluations.length,
|
||||
averageScore:
|
||||
|
|
|
|||
|
|
@ -147,14 +147,19 @@ export const filteringRouter = router({
|
|||
}
|
||||
|
||||
// Get projects in this round
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
files: {
|
||||
select: { id: true, fileName: true, fileType: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const projects = roundProjectEntries.map((rp) => rp.project)
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw new TRPCError({
|
||||
|
|
@ -250,7 +255,6 @@ export const filteringRouter = router({
|
|||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
status: true,
|
||||
competitionCategory: true,
|
||||
country: true,
|
||||
},
|
||||
|
|
@ -390,13 +394,13 @@ export const filteringRouter = router({
|
|||
.filter((r) => (r.finalOutcome || r.outcome) === 'PASSED')
|
||||
.map((r) => r.projectId)
|
||||
|
||||
// Update project statuses
|
||||
// Update RoundProject statuses
|
||||
await ctx.prisma.$transaction([
|
||||
// Filtered out projects get REJECTED status (data preserved)
|
||||
...(filteredOutIds.length > 0
|
||||
? [
|
||||
ctx.prisma.project.updateMany({
|
||||
where: { id: { in: filteredOutIds } },
|
||||
ctx.prisma.roundProject.updateMany({
|
||||
where: { roundId: input.roundId, projectId: { in: filteredOutIds } },
|
||||
data: { status: 'REJECTED' },
|
||||
}),
|
||||
]
|
||||
|
|
@ -404,8 +408,8 @@ export const filteringRouter = router({
|
|||
// Passed projects get ELIGIBLE status
|
||||
...(passedIds.length > 0
|
||||
? [
|
||||
ctx.prisma.project.updateMany({
|
||||
where: { id: { in: passedIds } },
|
||||
ctx.prisma.roundProject.updateMany({
|
||||
where: { roundId: input.roundId, projectId: { in: passedIds } },
|
||||
data: { status: 'ELIGIBLE' },
|
||||
}),
|
||||
]
|
||||
|
|
@ -454,9 +458,9 @@ export const filteringRouter = router({
|
|||
},
|
||||
})
|
||||
|
||||
// Restore project status
|
||||
await ctx.prisma.project.update({
|
||||
where: { id: input.projectId },
|
||||
// Restore RoundProject status
|
||||
await ctx.prisma.roundProject.updateMany({
|
||||
where: { roundId: input.roundId, projectId: input.projectId },
|
||||
data: { status: 'ELIGIBLE' },
|
||||
})
|
||||
|
||||
|
|
@ -500,8 +504,8 @@ export const filteringRouter = router({
|
|||
},
|
||||
})
|
||||
),
|
||||
ctx.prisma.project.updateMany({
|
||||
where: { id: { in: input.projectIds } },
|
||||
ctx.prisma.roundProject.updateMany({
|
||||
where: { roundId: input.roundId, projectId: { in: input.projectIds } },
|
||||
data: { status: 'ELIGIBLE' },
|
||||
}),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -80,18 +80,27 @@ export const learningResourceRouter = router({
|
|||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: { userId: ctx.user.id },
|
||||
include: {
|
||||
project: { select: { status: true } },
|
||||
project: {
|
||||
select: {
|
||||
roundProjects: {
|
||||
select: { status: true },
|
||||
orderBy: { addedAt: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Determine highest cohort level
|
||||
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
|
||||
for (const assignment of assignments) {
|
||||
if (assignment.project.status === 'FINALIST') {
|
||||
const rpStatus = assignment.project.roundProjects[0]?.status
|
||||
if (rpStatus === 'FINALIST') {
|
||||
userCohortLevel = 'FINALIST'
|
||||
break
|
||||
}
|
||||
if (assignment.project.status === 'SEMIFINALIST') {
|
||||
if (rpStatus === 'SEMIFINALIST') {
|
||||
userCohortLevel = 'SEMIFINALIST'
|
||||
}
|
||||
}
|
||||
|
|
@ -155,17 +164,26 @@ export const learningResourceRouter = router({
|
|||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: { userId: ctx.user.id },
|
||||
include: {
|
||||
project: { select: { status: true } },
|
||||
project: {
|
||||
select: {
|
||||
roundProjects: {
|
||||
select: { status: true },
|
||||
orderBy: { addedAt: 'desc' as const },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
|
||||
for (const assignment of assignments) {
|
||||
if (assignment.project.status === 'FINALIST') {
|
||||
const rpStatus = assignment.project.roundProjects[0]?.status
|
||||
if (rpStatus === 'FINALIST') {
|
||||
userCohortLevel = 'FINALIST'
|
||||
break
|
||||
}
|
||||
if (assignment.project.status === 'SEMIFINALIST') {
|
||||
if (rpStatus === 'SEMIFINALIST') {
|
||||
userCohortLevel = 'SEMIFINALIST'
|
||||
}
|
||||
}
|
||||
|
|
@ -220,16 +238,27 @@ export const learningResourceRouter = router({
|
|||
// Check cohort level access
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: { userId: ctx.user.id },
|
||||
include: { project: { select: { status: true } } },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
roundProjects: {
|
||||
select: { status: true },
|
||||
orderBy: { addedAt: 'desc' as const },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
|
||||
for (const assignment of assignments) {
|
||||
if (assignment.project.status === 'FINALIST') {
|
||||
const rpStatus = assignment.project.roundProjects[0]?.status
|
||||
if (rpStatus === 'FINALIST') {
|
||||
userCohortLevel = 'FINALIST'
|
||||
break
|
||||
}
|
||||
if (assignment.project.status === 'SEMIFINALIST') {
|
||||
if (rpStatus === 'SEMIFINALIST') {
|
||||
userCohortLevel = 'SEMIFINALIST'
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,13 +15,17 @@ export const liveVotingRouter = router({
|
|||
round: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
projects: {
|
||||
roundProjects: {
|
||||
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
|
||||
include: {
|
||||
project: {
|
||||
select: { id: true, title: true, teamName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
|
|
@ -34,13 +38,17 @@ export const liveVotingRouter = router({
|
|||
round: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
projects: {
|
||||
roundProjects: {
|
||||
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
|
||||
include: {
|
||||
project: {
|
||||
select: { id: true, title: true, teamName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -333,7 +333,7 @@ export const mentorRouter = router({
|
|||
// Get projects without mentors
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
roundProjects: { some: { roundId: input.roundId } },
|
||||
mentorAssignment: null,
|
||||
wantsMentorship: true,
|
||||
},
|
||||
|
|
@ -430,12 +430,19 @@ export const mentorRouter = router({
|
|||
where: { mentorId: ctx.user.id },
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
roundProjects: {
|
||||
include: {
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { addedAt: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
teamMembers: {
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
|
|
@ -476,12 +483,19 @@ export const mentorRouter = router({
|
|||
|
||||
const project = await ctx.prisma.project.findUniqueOrThrow({
|
||||
where: { id: input.projectId },
|
||||
include: {
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
roundProjects: {
|
||||
include: {
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { addedAt: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
teamMembers: {
|
||||
include: {
|
||||
user: {
|
||||
|
|
@ -528,7 +542,7 @@ export const mentorRouter = router({
|
|||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where = {
|
||||
...(input.roundId && { project: { roundId: input.roundId } }),
|
||||
...(input.roundId && { project: { roundProjects: { some: { roundId: input.roundId } } } }),
|
||||
...(input.mentorId && { mentorId: input.mentorId }),
|
||||
}
|
||||
|
||||
|
|
@ -541,9 +555,12 @@ export const mentorRouter = router({
|
|||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
status: true,
|
||||
oceanIssue: true,
|
||||
competitionCategory: true,
|
||||
roundProjects: {
|
||||
select: { status: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
mentor: {
|
||||
|
|
|
|||
|
|
@ -171,9 +171,9 @@ export const notionImportRouter = router({
|
|||
}
|
||||
|
||||
// Create project
|
||||
await ctx.prisma.project.create({
|
||||
const createdProject = await ctx.prisma.project.create({
|
||||
data: {
|
||||
roundId: round.id,
|
||||
programId: round.programId,
|
||||
title: title.trim(),
|
||||
teamName: typeof teamName === 'string' ? teamName.trim() : null,
|
||||
description: typeof description === 'string' ? description : null,
|
||||
|
|
@ -183,6 +183,14 @@ export const notionImportRouter = router({
|
|||
notionPageId: record.id,
|
||||
notionDatabaseId: input.databaseId,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
// Create RoundProject entry
|
||||
await ctx.prisma.roundProject.create({
|
||||
data: {
|
||||
roundId: round.id,
|
||||
projectId: createdProject.id,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export const programRouter = router({
|
|||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true, assignments: true },
|
||||
select: { roundProjects: true, assignments: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export const projectRouter = router({
|
|||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
status: z
|
||||
.enum([
|
||||
|
|
@ -33,6 +34,8 @@ export const projectRouter = router({
|
|||
'REJECTED',
|
||||
])
|
||||
).optional(),
|
||||
notInRoundId: z.string().optional(), // Exclude projects already in this round
|
||||
unassignedOnly: z.boolean().optional(), // Projects not in any round
|
||||
search: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
||||
|
|
@ -52,7 +55,7 @@ export const projectRouter = router({
|
|||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const {
|
||||
roundId, status, statuses, search, tags,
|
||||
programId, roundId, notInRoundId, status, statuses, unassignedOnly, search, tags,
|
||||
competitionCategory, oceanIssue, country,
|
||||
wantsMentorship, hasFiles, hasAssignments,
|
||||
page, perPage,
|
||||
|
|
@ -62,12 +65,51 @@ export const projectRouter = router({
|
|||
// Build where clause
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (roundId) where.roundId = roundId
|
||||
if (statuses && statuses.length > 0) {
|
||||
where.status = { in: statuses }
|
||||
} else if (status) {
|
||||
where.status = status
|
||||
if (programId) where.programId = programId
|
||||
|
||||
// Filter by round via RoundProject join
|
||||
if (roundId) {
|
||||
where.roundProjects = { some: { roundId } }
|
||||
}
|
||||
|
||||
// Exclude projects already in a specific round
|
||||
if (notInRoundId) {
|
||||
where.roundProjects = {
|
||||
...(where.roundProjects as Record<string, unknown> || {}),
|
||||
none: { roundId: notInRoundId },
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by unassigned (not in any round)
|
||||
if (unassignedOnly) {
|
||||
where.roundProjects = { none: {} }
|
||||
}
|
||||
|
||||
// Status filter via RoundProject
|
||||
if (roundId && (statuses?.length || status)) {
|
||||
const statusValues = statuses?.length ? statuses : status ? [status] : []
|
||||
if (statusValues.length > 0) {
|
||||
where.roundProjects = {
|
||||
some: {
|
||||
roundId,
|
||||
status: { in: statusValues },
|
||||
},
|
||||
}
|
||||
}
|
||||
} else if (statuses?.length || status) {
|
||||
// Status filter without specific round — match any round with that status
|
||||
const statusValues = statuses?.length ? statuses : status ? [status] : []
|
||||
if (statusValues.length > 0) {
|
||||
where.roundProjects = {
|
||||
...(where.roundProjects as Record<string, unknown> || {}),
|
||||
some: {
|
||||
...((where.roundProjects as Record<string, unknown>)?.some as Record<string, unknown> || {}),
|
||||
status: { in: statusValues },
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tags && tags.length > 0) {
|
||||
where.tags = { hasSome: tags }
|
||||
}
|
||||
|
|
@ -90,7 +132,6 @@ export const projectRouter = router({
|
|||
|
||||
// Jury members can only see assigned projects
|
||||
if (ctx.user.role === 'JURY_MEMBER') {
|
||||
// If hasAssignments filter is already set, combine with jury filter
|
||||
where.assignments = {
|
||||
...((where.assignments as Record<string, unknown>) || {}),
|
||||
some: { userId: ctx.user.id },
|
||||
|
|
@ -105,8 +146,16 @@ export const projectRouter = router({
|
|||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
files: true,
|
||||
program: {
|
||||
select: { id: true, name: true, year: true },
|
||||
},
|
||||
roundProjects: {
|
||||
include: {
|
||||
round: {
|
||||
select: { id: true, name: true, program: { select: { name: true, year: true } } },
|
||||
select: { id: true, name: true, sortOrder: true },
|
||||
},
|
||||
},
|
||||
orderBy: { addedAt: 'desc' },
|
||||
},
|
||||
_count: { select: { assignments: true } },
|
||||
},
|
||||
|
|
@ -130,8 +179,8 @@ export const projectRouter = router({
|
|||
.query(async ({ ctx }) => {
|
||||
const [rounds, countries, categories, issues] = await Promise.all([
|
||||
ctx.prisma.round.findMany({
|
||||
select: { id: true, name: true, program: { select: { name: true, year: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, name: true, sortOrder: true, program: { select: { name: true, year: true } } },
|
||||
orderBy: [{ program: { year: 'desc' } }, { sortOrder: 'asc' }],
|
||||
}),
|
||||
ctx.prisma.project.findMany({
|
||||
where: { country: { not: null } },
|
||||
|
|
@ -175,7 +224,17 @@ export const projectRouter = router({
|
|||
where: { id: input.id },
|
||||
include: {
|
||||
files: true,
|
||||
round: true,
|
||||
program: {
|
||||
select: { id: true, name: true, year: true },
|
||||
},
|
||||
roundProjects: {
|
||||
include: {
|
||||
round: {
|
||||
select: { id: true, name: true, sortOrder: true, status: true },
|
||||
},
|
||||
},
|
||||
orderBy: { round: { sortOrder: 'asc' } },
|
||||
},
|
||||
teamMembers: {
|
||||
include: {
|
||||
user: {
|
||||
|
|
@ -244,11 +303,13 @@ export const projectRouter = router({
|
|||
|
||||
/**
|
||||
* Create a single project (admin only)
|
||||
* Projects belong to a program. Optionally assign to a round immediately.
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
programId: z.string(),
|
||||
roundId: z.string().optional(),
|
||||
title: z.string().min(1).max(500),
|
||||
teamName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
|
|
@ -257,7 +318,7 @@ export const projectRouter = router({
|
|||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { metadataJson, ...rest } = input
|
||||
const { metadataJson, roundId, ...rest } = input
|
||||
const project = await ctx.prisma.project.create({
|
||||
data: {
|
||||
...rest,
|
||||
|
|
@ -265,6 +326,17 @@ export const projectRouter = router({
|
|||
},
|
||||
})
|
||||
|
||||
// If roundId provided, also create RoundProject entry
|
||||
if (roundId) {
|
||||
await ctx.prisma.roundProject.create({
|
||||
data: {
|
||||
roundId,
|
||||
projectId: project.id,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
|
|
@ -272,7 +344,7 @@ export const projectRouter = router({
|
|||
action: 'CREATE',
|
||||
entityType: 'Project',
|
||||
entityId: project.id,
|
||||
detailsJson: { title: input.title, roundId: input.roundId },
|
||||
detailsJson: { title: input.title, programId: input.programId, roundId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
|
|
@ -283,6 +355,7 @@ export const projectRouter = router({
|
|||
|
||||
/**
|
||||
* Update a project (admin only)
|
||||
* Status updates require a roundId context since status is per-round.
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(
|
||||
|
|
@ -291,6 +364,8 @@ export const projectRouter = router({
|
|||
title: z.string().min(1).max(500).optional(),
|
||||
teamName: z.string().optional().nullable(),
|
||||
description: z.string().optional().nullable(),
|
||||
// Status update requires roundId
|
||||
roundId: z.string().optional(),
|
||||
status: z
|
||||
.enum([
|
||||
'SUBMITTED',
|
||||
|
|
@ -306,7 +381,7 @@ export const projectRouter = router({
|
|||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, metadataJson, ...data } = input
|
||||
const { id, metadataJson, status, roundId, ...data } = input
|
||||
|
||||
const project = await ctx.prisma.project.update({
|
||||
where: { id },
|
||||
|
|
@ -316,6 +391,14 @@ export const projectRouter = router({
|
|||
},
|
||||
})
|
||||
|
||||
// Update status on RoundProject if both status and roundId provided
|
||||
if (status && roundId) {
|
||||
await ctx.prisma.roundProject.updateMany({
|
||||
where: { projectId: id, roundId },
|
||||
data: { status },
|
||||
})
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
|
|
@ -323,7 +406,7 @@ export const projectRouter = router({
|
|||
action: 'UPDATE',
|
||||
entityType: 'Project',
|
||||
entityId: id,
|
||||
detailsJson: { ...data, metadataJson } as Prisma.InputJsonValue,
|
||||
detailsJson: { ...data, status, roundId, metadataJson } as Prisma.InputJsonValue,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
|
|
@ -360,11 +443,13 @@ export const projectRouter = router({
|
|||
|
||||
/**
|
||||
* Import projects from CSV data (admin only)
|
||||
* Projects belong to a program. Optionally assign to a round.
|
||||
*/
|
||||
importCSV: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
programId: z.string(),
|
||||
roundId: z.string().optional(),
|
||||
projects: z.array(
|
||||
z.object({
|
||||
title: z.string().min(1),
|
||||
|
|
@ -377,20 +462,53 @@ export const projectRouter = router({
|
|||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify round exists
|
||||
await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
// Verify program exists
|
||||
await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: input.programId },
|
||||
})
|
||||
|
||||
const created = await ctx.prisma.project.createMany({
|
||||
data: input.projects.map((p) => {
|
||||
// Verify round exists and belongs to program if provided
|
||||
if (input.roundId) {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
if (round.programId !== input.programId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Round does not belong to the selected program',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create projects in a transaction
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Create all projects
|
||||
const projectData = input.projects.map((p) => {
|
||||
const { metadataJson, ...rest } = p
|
||||
return {
|
||||
...rest,
|
||||
roundId: input.roundId,
|
||||
programId: input.programId,
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
const created = await tx.project.createManyAndReturn({
|
||||
data: projectData,
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
// If roundId provided, create RoundProject entries
|
||||
if (input.roundId) {
|
||||
await tx.roundProject.createMany({
|
||||
data: created.map((p) => ({
|
||||
roundId: input.roundId!,
|
||||
projectId: p.id,
|
||||
status: 'SUBMITTED' as const,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return { imported: created.length }
|
||||
})
|
||||
|
||||
// Audit log
|
||||
|
|
@ -399,23 +517,30 @@ export const projectRouter = router({
|
|||
userId: ctx.user.id,
|
||||
action: 'IMPORT',
|
||||
entityType: 'Project',
|
||||
detailsJson: { roundId: input.roundId, count: created.count },
|
||||
detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return { imported: created.count }
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get all unique tags used in projects
|
||||
*/
|
||||
getTags: protectedProcedure
|
||||
.input(z.object({ roundId: z.string().optional() }))
|
||||
.input(z.object({
|
||||
roundId: z.string().optional(),
|
||||
programId: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {}
|
||||
if (input.programId) where.programId = input.programId
|
||||
if (input.roundId) where.roundProjects = { some: { roundId: input.roundId } }
|
||||
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: input.roundId ? { roundId: input.roundId } : undefined,
|
||||
where: Object.keys(where).length > 0 ? where : undefined,
|
||||
select: { tags: true },
|
||||
})
|
||||
|
||||
|
|
@ -427,11 +552,13 @@ export const projectRouter = router({
|
|||
|
||||
/**
|
||||
* Update project status in bulk (admin only)
|
||||
* Status is per-round, so roundId is required.
|
||||
*/
|
||||
bulkUpdateStatus: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.array(z.string()),
|
||||
roundId: z.string(),
|
||||
status: z.enum([
|
||||
'SUBMITTED',
|
||||
'ELIGIBLE',
|
||||
|
|
@ -443,8 +570,11 @@ export const projectRouter = router({
|
|||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const updated = await ctx.prisma.project.updateMany({
|
||||
where: { id: { in: input.ids } },
|
||||
const updated = await ctx.prisma.roundProject.updateMany({
|
||||
where: {
|
||||
projectId: { in: input.ids },
|
||||
roundId: input.roundId,
|
||||
},
|
||||
data: { status: input.status },
|
||||
})
|
||||
|
||||
|
|
@ -454,7 +584,7 @@ export const projectRouter = router({
|
|||
userId: ctx.user.id,
|
||||
action: 'BULK_UPDATE_STATUS',
|
||||
entityType: 'Project',
|
||||
detailsJson: { ids: input.ids, status: input.status },
|
||||
detailsJson: { ids: input.ids, roundId: input.roundId, status: input.status },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
|
|
@ -462,4 +592,53 @@ export const projectRouter = router({
|
|||
|
||||
return { updated: updated.count }
|
||||
}),
|
||||
|
||||
/**
|
||||
* List projects in a program's pool (not assigned to any round)
|
||||
*/
|
||||
listPool: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
search: z.string().optional(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
perPage: z.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { programId, search, page, perPage } = input
|
||||
const skip = (page - 1) * perPage
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
programId,
|
||||
roundProjects: { none: {} },
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ teamName: { contains: search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
const [projects, total] = await Promise.all([
|
||||
ctx.prisma.project.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: perPage,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
country: true,
|
||||
competitionCategory: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
ctx.prisma.project.count({ where }),
|
||||
])
|
||||
|
||||
return { projects, total, page, perPage, totalPages: Math.ceil(total / perPage) }
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ export const roundRouter = router({
|
|||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.round.findMany({
|
||||
where: { programId: input.programId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true, assignments: true },
|
||||
select: { roundProjects: true, assignments: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -32,7 +32,7 @@ export const roundRouter = router({
|
|||
include: {
|
||||
program: true,
|
||||
_count: {
|
||||
select: { projects: true, assignments: true },
|
||||
select: { roundProjects: true, assignments: true },
|
||||
},
|
||||
evaluationForms: {
|
||||
where: { isActive: true },
|
||||
|
|
@ -64,7 +64,10 @@ export const roundRouter = router({
|
|||
z.object({
|
||||
programId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
roundType: z.enum(['FILTERING', 'EVALUATION', 'LIVE_EVENT']).default('EVALUATION'),
|
||||
requiredReviews: z.number().int().min(1).max(10).default(3),
|
||||
sortOrder: z.number().int().optional(),
|
||||
settingsJson: z.record(z.unknown()).optional(),
|
||||
votingStartAt: z.date().optional(),
|
||||
votingEndAt: z.date().optional(),
|
||||
})
|
||||
|
|
@ -80,8 +83,23 @@ export const roundRouter = router({
|
|||
}
|
||||
}
|
||||
|
||||
// Auto-set sortOrder if not provided (append to end)
|
||||
let sortOrder = input.sortOrder
|
||||
if (sortOrder === undefined) {
|
||||
const maxOrder = await ctx.prisma.round.aggregate({
|
||||
where: { programId: input.programId },
|
||||
_max: { sortOrder: true },
|
||||
})
|
||||
sortOrder = (maxOrder._max.sortOrder ?? -1) + 1
|
||||
}
|
||||
|
||||
const { settingsJson, sortOrder: _so, ...rest } = input
|
||||
const round = await ctx.prisma.round.create({
|
||||
data: input,
|
||||
data: {
|
||||
...rest,
|
||||
sortOrder,
|
||||
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
|
|
@ -91,7 +109,7 @@ export const roundRouter = router({
|
|||
action: 'CREATE',
|
||||
entityType: 'Round',
|
||||
entityId: round.id,
|
||||
detailsJson: input,
|
||||
detailsJson: { ...rest, settingsJson } as Prisma.InputJsonValue,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
|
|
@ -234,7 +252,7 @@ export const roundRouter = router({
|
|||
.query(async ({ ctx, input }) => {
|
||||
const [totalProjects, totalAssignments, completedAssignments] =
|
||||
await Promise.all([
|
||||
ctx.prisma.project.count({ where: { roundId: input.id } }),
|
||||
ctx.prisma.roundProject.count({ where: { roundId: input.id } }),
|
||||
ctx.prisma.assignment.count({ where: { roundId: input.id } }),
|
||||
ctx.prisma.assignment.count({
|
||||
where: { roundId: input.id, isCompleted: true },
|
||||
|
|
@ -365,7 +383,7 @@ export const roundRouter = router({
|
|||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
_count: { select: { projects: true, assignments: true } },
|
||||
_count: { select: { roundProjects: true, assignments: true } },
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -383,7 +401,7 @@ export const roundRouter = router({
|
|||
detailsJson: {
|
||||
name: round.name,
|
||||
status: round.status,
|
||||
projectsDeleted: round._count.projects,
|
||||
projectsDeleted: round._count.roundProjects,
|
||||
assignmentsDeleted: round._count.assignments,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
|
|
@ -408,4 +426,202 @@ export const roundRouter = router({
|
|||
})
|
||||
return count > 0
|
||||
}),
|
||||
|
||||
/**
|
||||
* Assign projects from the program pool to a round
|
||||
*/
|
||||
assignProjects: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
projectIds: z.array(z.string()).min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify round exists and get programId
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
|
||||
// Verify all projects belong to the same program
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: input.projectIds }, programId: round.programId },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (projects.length !== input.projectIds.length) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Some projects do not belong to this program',
|
||||
})
|
||||
}
|
||||
|
||||
// Create RoundProject entries (skip duplicates)
|
||||
const created = await ctx.prisma.roundProject.createMany({
|
||||
data: input.projectIds.map((projectId) => ({
|
||||
roundId: input.roundId,
|
||||
projectId,
|
||||
status: 'SUBMITTED' as const,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'ASSIGN_PROJECTS_TO_ROUND',
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: { projectCount: created.count },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return { assigned: created.count }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Remove projects from a round
|
||||
*/
|
||||
removeProjects: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
projectIds: z.array(z.string()).min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const deleted = await ctx.prisma.roundProject.deleteMany({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
projectId: { in: input.projectIds },
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'REMOVE_PROJECTS_FROM_ROUND',
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: { projectCount: deleted.count },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return { removed: deleted.count }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Advance projects from one round to the next
|
||||
* Creates new RoundProject entries in the target round (keeps them in source round too)
|
||||
*/
|
||||
advanceProjects: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
fromRoundId: z.string(),
|
||||
toRoundId: z.string(),
|
||||
projectIds: z.array(z.string()).min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify both rounds exist and belong to the same program
|
||||
const [fromRound, toRound] = await Promise.all([
|
||||
ctx.prisma.round.findUniqueOrThrow({ where: { id: input.fromRoundId } }),
|
||||
ctx.prisma.round.findUniqueOrThrow({ where: { id: input.toRoundId } }),
|
||||
])
|
||||
|
||||
if (fromRound.programId !== toRound.programId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Rounds must belong to the same program',
|
||||
})
|
||||
}
|
||||
|
||||
// Verify all projects are in the source round
|
||||
const sourceProjects = await ctx.prisma.roundProject.findMany({
|
||||
where: {
|
||||
roundId: input.fromRoundId,
|
||||
projectId: { in: input.projectIds },
|
||||
},
|
||||
select: { projectId: true },
|
||||
})
|
||||
|
||||
if (sourceProjects.length !== input.projectIds.length) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Some projects are not in the source round',
|
||||
})
|
||||
}
|
||||
|
||||
// Create entries in target round (skip duplicates)
|
||||
const created = await ctx.prisma.roundProject.createMany({
|
||||
data: input.projectIds.map((projectId) => ({
|
||||
roundId: input.toRoundId,
|
||||
projectId,
|
||||
status: 'SUBMITTED' as const,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'ADVANCE_PROJECTS',
|
||||
entityType: 'Round',
|
||||
entityId: input.toRoundId,
|
||||
detailsJson: {
|
||||
fromRoundId: input.fromRoundId,
|
||||
toRoundId: input.toRoundId,
|
||||
projectCount: created.count,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return { advanced: created.count }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Reorder rounds within a program
|
||||
*/
|
||||
reorder: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
roundIds: z.array(z.string()).min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Update sortOrder for each round based on array position
|
||||
await ctx.prisma.$transaction(
|
||||
input.roundIds.map((roundId, index) =>
|
||||
ctx.prisma.round.update({
|
||||
where: { id: roundId },
|
||||
data: { sortOrder: index },
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'REORDER_ROUNDS',
|
||||
entityType: 'Program',
|
||||
entityId: input.programId,
|
||||
detailsJson: { roundIds: input.roundIds },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -237,11 +237,13 @@ export const specialAwardRouter = router({
|
|||
const statusFilter = input.includeSubmitted
|
||||
? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
|
||||
: (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
|
||||
where: {
|
||||
round: { programId: award.programId },
|
||||
status: { in: [...statusFilter] },
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
|
|
@ -252,7 +254,12 @@ export const specialAwardRouter = router({
|
|||
tags: true,
|
||||
oceanIssue: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// Deduplicate projects (same project may be in multiple rounds)
|
||||
const projectMap = new Map(roundProjectEntries.map((rp) => [rp.project.id, rp.project]))
|
||||
const projects = Array.from(projectMap.values())
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw new TRPCError({
|
||||
|
|
|
|||
|
|
@ -199,9 +199,9 @@ export const typeformImportRouter = router({
|
|||
}
|
||||
|
||||
// Create project
|
||||
await ctx.prisma.project.create({
|
||||
const createdProject = await ctx.prisma.project.create({
|
||||
data: {
|
||||
roundId: round.id,
|
||||
programId: round.programId,
|
||||
title: String(title).trim(),
|
||||
teamName: typeof teamName === 'string' ? teamName.trim() : null,
|
||||
description: typeof description === 'string' ? description : null,
|
||||
|
|
@ -211,6 +211,14 @@ export const typeformImportRouter = router({
|
|||
typeformResponseId: response.response_id,
|
||||
typeformFormId: input.formId,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
// Create RoundProject entry
|
||||
await ctx.prisma.roundProject.create({
|
||||
data: {
|
||||
roundId: round.id,
|
||||
projectId: createdProject.id,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue