diff --git a/src/app/(admin)/admin/competitions/[competitionId]/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/page.tsx index ab7fe5b..f6219c3 100644 --- a/src/app/(admin)/admin/competitions/[competitionId]/page.tsx +++ b/src/app/(admin)/admin/competitions/[competitionId]/page.tsx @@ -40,13 +40,14 @@ import { ChevronDown, Layers, Users, - FileBox, + FolderKanban, ClipboardList, Settings, MoreHorizontal, Archive, Loader2, Plus, + CalendarDays, } from 'lucide-react' import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline' @@ -298,10 +299,12 @@ export default function CompetitionDetailPage() {
- - Windows + + Projects
-

{competition.submissionWindows.length}

+

+ {competition.rounds.reduce((sum: number, r: any) => sum + (r._count?.projectRoundStates ?? 0), 0)} +

@@ -349,39 +352,93 @@ export default function CompetitionDetailPage() { ) : ( -
- {competition.rounds.map((round, index) => ( - - - -
- {index + 1} -
-
-

{round.name}

-
- + {competition.rounds.map((round: any, index: number) => { + const projectCount = round._count?.projectRoundStates ?? 0 + const assignmentCount = round._count?.assignments ?? 0 + const statusLabel = round.status.replace('ROUND_', '') + const statusColors: Record = { + DRAFT: 'bg-gray-100 text-gray-600', + ACTIVE: 'bg-emerald-100 text-emerald-700', + CLOSED: 'bg-blue-100 text-blue-700', + ARCHIVED: 'bg-muted text-muted-foreground', + } + return ( + + + + {/* Top: number + name + badges */} +
+
+ {index + 1} +
+
+

{round.name}

+
+ + {round.roundType.replace('_', ' ')} + + + {statusLabel} + +
+
+
+ + {/* Stats row */} +
+
+ + {projectCount} project{projectCount !== 1 ? 's' : ''} +
+ {(round.roundType === 'EVALUATION' || round.roundType === 'FILTERING') && ( +
+ + {assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''} +
+ )} +
+ + {/* Dates */} + {(round.windowOpenAt || round.windowCloseAt) && ( +
+ + + {round.windowOpenAt + ? new Date(round.windowOpenAt).toLocaleDateString() + : '?'} + {' \u2014 '} + {round.windowCloseAt + ? new Date(round.windowCloseAt).toLocaleDateString() + : '?'} + +
)} - > - {round.roundType.replace('_', ' ')} -
- - {round.status.replace('ROUND_', '')} - -
-
- - ))} + + {/* Jury group */} + {round.juryGroup && ( +
+ + {round.juryGroup.name} +
+ )} + + + + ) + })}
)} diff --git a/src/app/(admin)/admin/competitions/[competitionId]/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/rounds/[roundId]/page.tsx index 72ac786..94ea1ae 100644 --- a/src/app/(admin)/admin/competitions/[competitionId]/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/competitions/[competitionId]/rounds/[roundId]/page.tsx @@ -53,14 +53,21 @@ import { Settings, Zap, ExternalLink, - FileText, Shield, UserPlus, + CheckCircle2, + AlertTriangle, + CircleDot, + FileText, } from 'lucide-react' +import { Switch } from '@/components/ui/switch' +import { Label } from '@/components/ui/label' import { RoundConfigForm } from '@/components/admin/competition/round-config-form' import { ProjectStatesTable } from '@/components/admin/round/project-states-table' import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor' import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard' +import { CoverageReport } from '@/components/admin/assignment/coverage-report' +import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet' // -- Status config -- const roundStatusConfig = { @@ -109,6 +116,7 @@ export default function RoundDetailPage() { const [config, setConfig] = useState>({}) const [hasChanges, setHasChanges] = useState(false) const [activeTab, setActiveTab] = useState('overview') + const [previewSheetOpen, setPreviewSheetOpen] = useState(false) const utils = trpc.useUtils() @@ -118,6 +126,7 @@ export default function RoundDetailPage() { { competitionId }, { enabled: !!competitionId }, ) + const { data: fileRequirements } = trpc.file.listRequirements.useQuery({ roundId }) // Sync config from server when not dirty if (round && !hasChanges) { @@ -189,10 +198,12 @@ export default function RoundDetailPage() { const juryGroup = round?.juryGroup const juryMemberCount = juryGroup?.members?.length ?? 0 - // Determine available tabs based on round type + // Round type flags const isFiltering = round?.roundType === 'FILTERING' const isEvaluation = round?.roundType === 'EVALUATION' - const hasSubmissionWindows = round?.roundType === 'SUBMISSION' || round?.roundType === 'EVALUATION' || round?.roundType === 'INTAKE' + + // Pool link with context params + const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route // Loading if (isLoading) { @@ -236,6 +247,49 @@ export default function RoundDetailPage() { const statusCfg = roundStatusConfig[status] || roundStatusConfig.ROUND_DRAFT const typeCfg = roundTypeConfig[round.roundType] || roundTypeConfig.INTAKE + // -- Readiness checklist items -- + const readinessItems = [ + { + label: 'Projects assigned', + ready: projectCount > 0, + detail: projectCount > 0 ? `${projectCount} projects` : 'No projects yet', + action: projectCount === 0 ? poolLink : undefined, + actionLabel: 'Assign Projects', + }, + ...(isEvaluation || isFiltering + ? [ + { + label: 'Jury group set', + ready: !!juryGroup, + detail: juryGroup ? `${juryGroup.name} (${juryMemberCount} members)` : 'No jury group assigned', + action: undefined as Route | undefined, + actionLabel: undefined as string | undefined, + }, + ] + : []), + { + label: 'Dates configured', + ready: !!round.windowOpenAt && !!round.windowCloseAt, + detail: + round.windowOpenAt && round.windowCloseAt + ? `${new Date(round.windowOpenAt).toLocaleDateString()} \u2014 ${new Date(round.windowCloseAt).toLocaleDateString()}` + : 'No dates set \u2014 configure in Config tab', + action: undefined as Route | undefined, + actionLabel: undefined as string | undefined, + }, + { + label: 'File requirements set', + ready: (fileRequirements?.length ?? 0) > 0, + detail: + (fileRequirements?.length ?? 0) > 0 + ? `${fileRequirements?.length} requirement(s)` + : 'No file requirements \u2014 configure in Config tab', + action: undefined as Route | undefined, + actionLabel: undefined as string | undefined, + }, + ] + const readyCount = readinessItems.filter((i) => i.ready).length + return (
{/* ===== HEADER ===== */} @@ -331,15 +385,7 @@ export default function RoundDetailPage() { Save Config )} - {(isEvaluation || isFiltering) && ( - - - - )} - + + + )} +
+ ))} + + + + {/* Quick Actions */} @@ -573,7 +666,7 @@ export default function RoundDetailPage() { )} {/* Assign projects */} - + )} - {/* Evaluation specific */} + {/* Evaluation: generate assignments */} {isEvaluation && ( - - - + )} {/* View projects */} @@ -680,19 +774,19 @@ export default function RoundDetailPage() {
Jury Group - {juryGroup ? juryGroup.name : '—'} + {juryGroup ? juryGroup.name : '\u2014'}
Opens - {round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '—'} + {round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}
Closes - {round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '—'} + {round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}
@@ -757,143 +851,182 @@ export default function RoundDetailPage() { {/* ===== ASSIGNMENTS TAB (Evaluation rounds) ===== */} {isEvaluation && ( - - + + {/* Coverage Report (embedded) */} + + + {/* Generate Assignments */} + + +
+
+ Assignment Generation + + AI-suggested jury-to-project assignments based on expertise and workload + +
+
+ + + + +
+
+
+ + {!juryGroup && ( +
+ + Assign a jury group first before generating assignments. +
+ )} + {projectCount === 0 && ( +
+ + Add projects to this round first. +
+ )} + {juryGroup && projectCount > 0 && ( +

+ Click "Generate Assignments" to preview AI-suggested assignments. + You can review and execute them from the preview sheet. +

+ )} +
+
+ + {/* Unassigned Queue */} + + + {/* Assignment Preview Sheet */} +
)} {/* ===== CONFIG TAB ===== */} - + + {/* General Round Settings */} + + + General Settings + Settings that apply to this round regardless of type + + +
+
+ +

+ Send an automated email to project applicants when their project enters this round +

+
+ { + handleConfigChange({ ...config, notifyOnEntry: checked }) + }} + /> +
+
+
+ + {/* Round-type-specific config */} -
- {/* ===== DOCUMENTS TAB ===== */} - - + {/* Document Requirements (merged from old Documents tab) */} + + + Document Requirements + + Files applicants must submit for this round + {round.windowCloseAt && ( + <> — due by {new Date(round.windowCloseAt).toLocaleDateString()} + )} + + + + + + ) } -// ===== Inline sub-component for evaluation round assignments ===== +// ===== Sub-component: Unassigned projects queue for evaluation rounds ===== -function RoundAssignmentsOverview({ competitionId, roundId }: { competitionId: string; roundId: string }) { - const { data: coverage, isLoading: coverageLoading } = trpc.roundAssignment.coverageReport.useQuery({ - roundId, - requiredReviews: 3, - }) - - const { data: unassigned, isLoading: unassignedLoading } = trpc.roundAssignment.unassignedQueue.useQuery( +function RoundUnassignedQueue({ roundId }: { roundId: string }) { + const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery( { roundId, requiredReviews: 3 }, ) return ( -
- {/* Coverage stats */} - {coverageLoading ? ( -
- {[1, 2, 3].map((i) => )} -
- ) : coverage ? ( -
- - - Fully Assigned - - - -
{coverage.fullyAssigned || 0}
-

- of {coverage.totalProjects || 0} projects ({coverage.totalProjects ? ((coverage.fullyAssigned / coverage.totalProjects) * 100).toFixed(0) : 0}%) -

-
-
- - - Avg Reviews/Project - - - -
{coverage.avgReviewsPerProject?.toFixed(1) || '0'}
-

Target: 3 per project

-
-
- - - Unassigned - - - -
{coverage.unassigned || 0}
-

Need more assignments

-
-
-
- ) : null} - - {/* Unassigned queue */} - - -
-
- Unassigned Projects - Projects with fewer than 3 jury assignments -
- - - + + + Unassigned Projects + Projects with fewer than 3 jury assignments + + + {isLoading ? ( +
+ {[1, 2, 3].map((i) => )}
- - - {unassignedLoading ? ( -
- {[1, 2, 3].map((i) => )} -
- ) : unassigned && unassigned.length > 0 ? ( -
- {unassigned.map((project: any) => ( -
-
-

{project.title}

-

- {project.competitionCategory || 'No category'} - {project.teamName && ` · ${project.teamName}`} -

-
- - {project.assignmentCount || 0} / 3 - + ) : unassigned && unassigned.length > 0 ? ( +
+ {unassigned.map((project: any) => ( +
+
+

{project.title}

+

+ {project.competitionCategory || 'No category'} + {project.teamName && ` \u00b7 ${project.teamName}`} +

- ))} -
- ) : ( -

- All projects have sufficient assignments -

- )} - - -
+ + {project.assignmentCount || 0} / 3 + +
+ ))} +
+ ) : ( +

+ All projects have sufficient assignments +

+ )} +
+
) } diff --git a/src/app/(admin)/admin/projects/pool/page.tsx b/src/app/(admin)/admin/projects/pool/page.tsx index b9952c1..dc2364c 100644 --- a/src/app/(admin)/admin/projects/pool/page.tsx +++ b/src/app/(admin)/admin/projects/pool/page.tsx @@ -1,11 +1,14 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect, useMemo } from 'react' +import { useSearchParams } from 'next/navigation' import Link from 'next/link' import type { Route } from 'next' import { trpc } from '@/lib/trpc/client' +import { useEdition } from '@/contexts/edition-context' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' +import { Switch } from '@/components/ui/switch' import { Select, SelectContent, @@ -23,41 +26,86 @@ import { } from '@/components/ui/dialog' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' -import { Card } from '@/components/ui/card' +import { Card, CardContent } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { toast } from 'sonner' -import { ArrowLeft, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react' +import { ArrowLeft, ChevronLeft, ChevronRight, Loader2, X, Layers, Info } from 'lucide-react' + +const roundTypeColors: Record = { + INTAKE: 'bg-gray-100 text-gray-700', + FILTERING: 'bg-amber-100 text-amber-700', + EVALUATION: 'bg-blue-100 text-blue-700', + SUBMISSION: 'bg-purple-100 text-purple-700', + MENTORING: 'bg-teal-100 text-teal-700', + LIVE_FINAL: 'bg-red-100 text-red-700', + DELIBERATION: 'bg-indigo-100 text-indigo-700', +} export default function ProjectPoolPage() { - const [selectedProgramId, setSelectedProgramId] = useState('') + const searchParams = useSearchParams() + const { currentEdition, isLoading: editionLoading } = useEdition() + + // URL params for deep-linking context + const urlRoundId = searchParams.get('roundId') || '' + const urlCompetitionId = searchParams.get('competitionId') || '' + + // Auto-select programId from edition + const programId = currentEdition?.id || '' + const [selectedProjects, setSelectedProjects] = useState([]) const [assignDialogOpen, setAssignDialogOpen] = useState(false) const [assignAllDialogOpen, setAssignAllDialogOpen] = useState(false) - const [targetRoundId, setTargetRoundId] = useState('') + const [targetRoundId, setTargetRoundId] = useState(urlRoundId) const [searchQuery, setSearchQuery] = useState('') const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all') + const [showUnassignedOnly, setShowUnassignedOnly] = useState(false) const [currentPage, setCurrentPage] = useState(1) const perPage = 50 - const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' }) + // Pre-select target round from URL param + useEffect(() => { + if (urlRoundId) setTargetRoundId(urlRoundId) + }, [urlRoundId]) const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery( { - programId: selectedProgramId, + programId, competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter, search: searchQuery || undefined, + unassignedOnly: showUnassignedOnly, + excludeRoundId: urlRoundId || undefined, page: currentPage, perPage, }, - { enabled: !!selectedProgramId } + { enabled: !!programId } ) - // Load rounds from program (program.get returns rounds from all competitions) + // Load rounds from program (flattened from all competitions, now with competitionId) const { data: programData, isLoading: isLoadingRounds } = trpc.program.get.useQuery( - { id: selectedProgramId }, - { enabled: !!selectedProgramId } + { id: programId }, + { enabled: !!programId } ) - const rounds = (programData?.rounds || []) as Array<{ id: string; name: string; roundType: string; sortOrder: number }> + + // Get round name for context banner + const allRounds = useMemo(() => { + return (programData?.rounds || []) as Array<{ + id: string + name: string + competitionId: string + status: string + _count: { projects: number; assignments: number } + }> + }, [programData]) + + // Filter rounds by competitionId if URL param is set + const filteredRounds = useMemo(() => { + if (urlCompetitionId) { + return allRounds.filter((r) => r.competitionId === urlCompetitionId) + } + return allRounds + }, [allRounds, urlCompetitionId]) + + const contextRound = urlRoundId ? allRounds.find((r) => r.id === urlRoundId) : null const utils = trpc.useUtils() @@ -68,7 +116,7 @@ export default function ProjectPoolPage() { toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`) setSelectedProjects([]) setAssignDialogOpen(false) - setTargetRoundId('') + setTargetRoundId(urlRoundId) refetch() }, onError: (error: unknown) => { @@ -83,7 +131,7 @@ export default function ProjectPoolPage() { toast.success(`Assigned all ${result.assignedCount} projects to round`) setSelectedProjects([]) setAssignAllDialogOpen(false) - setTargetRoundId('') + setTargetRoundId(urlRoundId) refetch() }, onError: (error: unknown) => { @@ -102,11 +150,12 @@ export default function ProjectPoolPage() { } const handleAssignAll = () => { - if (!targetRoundId || !selectedProgramId) return + if (!targetRoundId || !programId) return assignAllMutation.mutate({ - programId: selectedProgramId, + programId, roundId: targetRoundId, competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter, + unassignedOnly: showUnassignedOnly, }) } @@ -134,6 +183,16 @@ export default function ProjectPoolPage() { } } + if (editionLoading) { + return ( +
+ + + +
+ ) + } + return (
{/* Header */} @@ -143,37 +202,47 @@ export default function ProjectPoolPage() { -
+

Project Pool

- Assign unassigned projects to competition rounds + {currentEdition + ? `${currentEdition.name} ${currentEdition.year} \u2014 ${poolData?.total ?? '...'} projects` + : 'No edition selected'}

+ {/* Context banner when coming from a round */} + {contextRound && ( + + +
+
+ +

+ Assigning to {contextRound.name} + {' \u2014 '} + + projects already in this round are hidden + +

+
+ + + +
+
+
+ )} + {/* Filters */}
-
- - -
-
setSearchQuery(e.target.value)} + className="pl-8 h-8 text-sm" + /> +
setSearchQuery(e.target.value)} + className="pl-8 h-8 text-sm" + /> +
+
+ + {PROJECT_STATES.map((state) => { + const count = counts[state] || 0 + if (count === 0) return null + const cfg = stateConfig[state] + return ( + + ) + })} +
- - - + + + +
+ {/* Search results count */} + {searchQuery.trim() && ( +

+ Showing {filtered.length} of {projectStates.length} projects matching "{searchQuery}" +

+ )} + {/* Bulk actions bar */} {selectedIds.size > 0 && (
@@ -316,7 +359,12 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl />
-

{ps.project?.title || 'Unknown'}

+ + {ps.project?.title || 'Unknown'} +

{ps.project?.teamName}

@@ -341,6 +389,13 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl + + + + View Project + + + {PROJECT_STATES.filter((s) => s !== ps.state).map((state) => { const sCfg = stateConfig[state] return ( @@ -368,8 +423,25 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
) })} + + {filtered.length === 0 && searchQuery.trim() && ( +
+ No projects match "{searchQuery}" +
+ )} + {/* Quick Add Dialog */} + { + utils.roundEngine.getProjectStates.invalidate({ roundId }) + }} + /> + {/* Single Remove Confirmation */} { if (!open) setRemoveConfirmId(null) }}> @@ -466,3 +538,133 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl ) } + +/** + * Quick Add Dialog — inline search + assign projects to this round without leaving the page. + */ +function QuickAddDialog({ + open, + onOpenChange, + roundId, + competitionId, + onAssigned, +}: { + open: boolean + onOpenChange: (open: boolean) => void + roundId: string + competitionId: string + onAssigned: () => void +}) { + const [search, setSearch] = useState('') + const [addingIds, setAddingIds] = useState>(new Set()) + + // Get the competition to find programId + const { data: competition } = trpc.competition.getById.useQuery( + { id: competitionId }, + { enabled: open && !!competitionId }, + ) + + const programId = (competition as any)?.programId || '' + + const { data: poolResults, isLoading } = trpc.projectPool.listUnassigned.useQuery( + { + programId, + excludeRoundId: roundId, + search: search.trim() || undefined, + perPage: 10, + }, + { enabled: open && !!programId }, + ) + + const assignMutation = trpc.projectPool.assignToRound.useMutation({ + onSuccess: (data) => { + toast.success(`Added to round`) + onAssigned() + // Remove from addingIds + setAddingIds(new Set()) + }, + onError: (err) => toast.error(err.message), + }) + + const handleQuickAssign = (projectId: string) => { + setAddingIds((prev) => new Set(prev).add(projectId)) + assignMutation.mutate({ projectIds: [projectId], roundId }) + } + + return ( + + + + Quick Add Projects + + Search and assign projects to this round without leaving the page. + + + +
+ + setSearch(e.target.value)} + className="pl-8" + autoFocus + /> +
+ +
+ {isLoading && ( +
+ +
+ )} + + {!isLoading && poolResults?.projects.length === 0 && ( +

+ {search.trim() ? `No projects found matching "${search}"` : 'No unassigned projects available'} +

+ )} + + {poolResults?.projects.map((project: any) => ( +
+
+

{project.title}

+

+ {project.teamName} + {project.competitionCategory && ( + <> · {project.competitionCategory} + )} +

+
+ +
+ ))} +
+ + {poolResults && poolResults.total > 10 && ( +

+ Showing 10 of {poolResults.total} — refine your search for more specific results +

+ )} +
+
+ ) +} diff --git a/src/server/routers/program.ts b/src/server/routers/program.ts index fc18fc6..16eaf96 100644 --- a/src/server/routers/program.ts +++ b/src/server/routers/program.ts @@ -42,15 +42,16 @@ export const programRouter = router({ : undefined, }) - // Return programs with rounds flattened + // Return programs with rounds flattened, preserving competitionId return programs.map((p) => { - const allRounds = (p as any).competitions?.flatMap((c: any) => c.rounds || []) || [] + const allRounds = (p as any).competitions?.flatMap((c: any) => + (c.rounds || []).map((round: any) => ({ ...round, competitionId: c.id })) + ) || [] return { ...p, // Provide `stages` as alias for backward compatibility stages: allRounds.map((round: any) => ({ ...round, - // Backward-compatible _count shape _count: { projects: round._count?.projectRoundStates || 0, assignments: round._count?.assignments || 0, @@ -60,6 +61,7 @@ export const programRouter = router({ rounds: allRounds.map((round: any) => ({ id: round.id, name: round.name, + competitionId: round.competitionId, status: round.status, votingEndAt: round.windowCloseAt, _count: { @@ -95,8 +97,10 @@ export const programRouter = router({ }, }) - // Flatten rounds from all competitions - const allRounds = (program as any).competitions?.flatMap((c: any) => c.rounds || []) || [] + // Flatten rounds from all competitions, preserving competitionId + const allRounds = (program as any).competitions?.flatMap((c: any) => + (c.rounds || []).map((round: any) => ({ ...round, competitionId: c.id })) + ) || [] const rounds = allRounds.map((round: any) => ({ ...round, _count: { diff --git a/src/server/routers/project-pool.ts b/src/server/routers/project-pool.ts index b66f3b5..b2d2594 100644 --- a/src/server/routers/project-pool.ts +++ b/src/server/routers/project-pool.ts @@ -2,38 +2,120 @@ import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, adminProcedure } from '../trpc' import { logAudit } from '../utils/audit' +import { sendAnnouncementEmail } from '@/lib/email' +import type { PrismaClient } from '@prisma/client' + +/** + * Send round-entry notification emails to project team members. + * Fire-and-forget: errors are logged but never block the assignment. + */ +async function sendRoundEntryEmails( + prisma: PrismaClient, + projectIds: string[], + roundName: string, +) { + try { + // Fetch projects with team members' user emails + fallback submittedByEmail + const projects = await prisma.project.findMany({ + where: { id: { in: projectIds } }, + select: { + id: true, + title: true, + submittedByEmail: true, + teamMembers: { + select: { + user: { select: { email: true, name: true } }, + }, + }, + }, + }) + + const emailPromises: Promise[] = [] + + for (const project of projects) { + // Collect unique emails for this project + const recipients = new Map() + + for (const tm of project.teamMembers) { + if (tm.user.email) { + recipients.set(tm.user.email, tm.user.name) + } + } + + // Fallback: if no team members have emails, use submittedByEmail + if (recipients.size === 0 && project.submittedByEmail) { + recipients.set(project.submittedByEmail, null) + } + + for (const [email, name] of recipients) { + emailPromises.push( + sendAnnouncementEmail( + email, + name, + `Your project has entered: ${roundName}`, + `Your project "${project.title}" has been added to the round "${roundName}" in the Monaco Ocean Protection Challenge. You will receive further instructions as the round progresses.`, + 'View Your Dashboard', + `${process.env.NEXTAUTH_URL || 'https://monaco-opc.com'}/dashboard`, + ).catch((err) => { + console.error(`[round-entry-email] Failed to send to ${email}:`, err) + }), + ) + } + } + + await Promise.allSettled(emailPromises) + } catch (err) { + console.error('[round-entry-email] Failed to send round entry emails:', err) + } +} /** * Project Pool Router * - * Manages the pool of unassigned projects (projects not yet assigned to any stage). - * Provides procedures for listing unassigned projects and bulk assigning them to stages. + * Manages the project pool for assigning projects to competition rounds. + * Shows all projects by default, with optional filtering for unassigned-only + * or projects not yet in a specific round. */ export const projectPoolRouter = router({ /** - * List unassigned projects with filtering and pagination - * Projects not assigned to any round + * List projects in the pool with filtering and pagination. + * By default shows ALL projects. Use filters to narrow: + * - unassignedOnly: true → only projects not in any round + * - excludeRoundId: "..." → only projects not already in that round */ listUnassigned: adminProcedure .input( z.object({ - programId: z.string(), // Required - must specify which program + programId: z.string(), competitionCategory: z .enum(['STARTUP', 'BUSINESS_CONCEPT']) .optional(), - search: z.string().optional(), // Search in title, teamName, description + search: z.string().optional(), + unassignedOnly: z.boolean().optional().default(false), + excludeRoundId: z.string().optional(), page: z.number().int().min(1).default(1), - perPage: z.number().int().min(1).max(200).default(20), + perPage: z.number().int().min(1).max(200).default(50), }) ) .query(async ({ ctx, input }) => { - const { programId, competitionCategory, search, page, perPage } = input + const { programId, competitionCategory, search, unassignedOnly, excludeRoundId, page, perPage } = input const skip = (page - 1) * perPage // Build where clause const where: Record = { programId, - projectRoundStates: { none: {} }, // Only unassigned projects (not in any round) + } + + // Optional: only show projects not in any round + if (unassignedOnly) { + where.projectRoundStates = { none: {} } + } + + // Optional: exclude projects already in a specific round + if (excludeRoundId && !unassignedOnly) { + where.projectRoundStates = { + none: { roundId: excludeRoundId }, + } } // Filter by competition category @@ -77,6 +159,22 @@ export const projectPoolRouter = router({ teamMembers: true, }, }, + projectRoundStates: { + select: { + roundId: true, + state: true, + round: { + select: { + name: true, + roundType: true, + sortOrder: true, + }, + }, + }, + orderBy: { + round: { sortOrder: 'asc' }, + }, + }, }, }), ctx.prisma.project.count({ where }), @@ -93,21 +191,11 @@ export const projectPoolRouter = router({ /** * Bulk assign projects to a round - * - * Validates that: - * - All projects exist - * - Round exists - * - * Creates: - * - RoundAssignment entries for each project - * - Project.status updated to 'ASSIGNED' - * - ProjectStatusHistory records for each project - * - Audit log */ assignToRound: adminProcedure .input( z.object({ - projectIds: z.array(z.string()).min(1).max(200), // Max 200 projects at once + projectIds: z.array(z.string()).min(1).max(200), roundId: z.string(), }) ) @@ -136,10 +224,10 @@ export const projectPoolRouter = router({ }) } - // Verify round exists + // Verify round exists and get config const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, - select: { id: true }, + select: { id: true, name: true, configJson: true }, }) // Step 2: Perform bulk assignment in a transaction @@ -192,6 +280,12 @@ export const projectPoolRouter = router({ userAgent: ctx.userAgent, }) + // Send round-entry notification emails if enabled (fire-and-forget) + const config = (round.configJson as Record) || {} + if (config.notifyOnEntry) { + void sendRoundEntryEmails(ctx.prisma as unknown as PrismaClient, projectIds, round.name) + } + return { success: true, assignedCount: result.count, @@ -200,7 +294,8 @@ export const projectPoolRouter = router({ }), /** - * Assign ALL unassigned projects in a program to a round (server-side, no ID limit) + * Assign ALL matching projects in a program to a round (server-side, no ID limit). + * Skips projects already in the target round. */ assignAllToRound: adminProcedure .input( @@ -208,22 +303,33 @@ export const projectPoolRouter = router({ programId: z.string(), roundId: z.string(), competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(), + unassignedOnly: z.boolean().optional().default(false), }) ) .mutation(async ({ ctx, input }) => { - const { programId, roundId, competitionCategory } = input + const { programId, roundId, competitionCategory, unassignedOnly } = input - // Verify round exists - await ctx.prisma.round.findUniqueOrThrow({ + // Verify round exists and get config + const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, - select: { id: true }, + select: { id: true, name: true, configJson: true }, }) - // Find all unassigned projects + // Find projects to assign const where: Record = { programId, - projectRoundStates: { none: {} }, } + + if (unassignedOnly) { + // Only projects not in any round + where.projectRoundStates = { none: {} } + } else { + // All projects not already in the target round + where.projectRoundStates = { + none: { roundId }, + } + } + if (competitionCategory) { where.competitionCategory = competitionCategory } @@ -271,12 +377,19 @@ export const projectPoolRouter = router({ roundId, programId, competitionCategory: competitionCategory || 'ALL', + unassignedOnly, projectCount: projectIds.length, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) + // Send round-entry notification emails if enabled (fire-and-forget) + const config = (round.configJson as Record) || {} + if (config.notifyOnEntry) { + void sendRoundEntryEmails(ctx.prisma as unknown as PrismaClient, projectIds, round.name) + } + return { success: true, assignedCount: result.count, roundId } }), })