Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s

Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 23:04:15 +01:00
parent 9ab4717f96
commit 6ca39c976b
349 changed files with 69938 additions and 28767 deletions

View File

@@ -18,6 +18,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Progress } from '@/components/ui/progress'
import {
ArrowLeft,
Bot,
Loader2,
Users,
User,
@@ -201,6 +202,10 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<CardTitle className="text-lg flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
AI-Suggested Mentors
<Badge variant="outline" className="text-xs gap-1 shrink-0 ml-1">
<Bot className="h-3 w-3" />
AI Recommended
</Badge>
</CardTitle>
<CardDescription>
Mentors matched based on expertise and project needs

View File

@@ -87,18 +87,20 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
// Fetch files (flat list for backward compatibility)
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
// Fetch file requirements from the pipeline's intake stage
const { data: requirementsData } = trpc.file.getProjectRequirements.useQuery(
{ projectId },
{ enabled: !!project }
)
// Fetch file requirements from the competition's intake round
// Note: This procedure may need to be updated or removed depending on new system
// const { data: requirementsData } = trpc.file.getProjectRequirements.useQuery(
// { projectId },
// { enabled: !!project }
// )
const requirementsData = null // Placeholder until procedure is updated
// Fetch available stages for upload selector (if project has a programId)
// Fetch available rounds for upload selector (if project has a programId)
const { data: programData } = trpc.program.get.useQuery(
{ id: project?.programId || '' },
{ enabled: !!project?.programId }
)
const availableStages = (programData?.stages as Array<{ id: string; name: string }>) || []
const availableRounds = (programData?.stages as Array<{ id: string; name: string }>) || []
const utils = trpc.useUtils()
@@ -528,13 +530,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Required Documents from Pipeline Intake Stage */}
{requirementsData && requirementsData.requirements.length > 0 && (
{/* Required Documents from Competition Intake Round */}
{requirementsData && (requirementsData as { requirements: Array<{ id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }> }).requirements?.length > 0 && (
<>
<div>
<p className="text-sm font-semibold mb-3">Required Documents</p>
<div className="grid gap-2">
{requirementsData.requirements.map((req, idx) => {
{(requirementsData as { requirements: Array<{ id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }> }).requirements.map((req: { id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }, idx: number) => {
const isFulfilled = req.fulfilled
return (
<div
@@ -592,16 +594,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
{/* Additional Documents Upload */}
<div>
<p className="text-sm font-semibold mb-3">
{requirementsData && requirementsData.requirements.length > 0
{requirementsData && (requirementsData as { requirements: unknown[] }).requirements?.length > 0
? 'Additional Documents'
: 'Upload New Files'}
</p>
<FileUpload
projectId={projectId}
availableStages={availableStages?.map((s: { id: string; name: string }) => ({ id: s.id, name: s.name }))}
availableRounds={availableRounds?.map((s: { id: string; name: string }) => ({ id: s.id, name: s.name }))}
onUploadComplete={() => {
utils.file.listByProject.invalidate({ projectId })
utils.file.getProjectRequirements.invalidate({ projectId })
// utils.file.getProjectRequirements.invalidate({ projectId })
}}
/>
</div>
@@ -757,7 +759,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
{assignments && assignments.length > 0 && stats && stats.totalEvaluations > 0 && (
<EvaluationSummaryCard
projectId={projectId}
stageId={assignments[0].stageId}
roundId={assignments[0].roundId}
/>
)}
</div>

View File

@@ -30,9 +30,9 @@ function ImportPageContent() {
const router = useRouter()
const utils = trpc.useUtils()
const searchParams = useSearchParams()
const stageIdParam = searchParams.get('stage')
const roundIdParam = searchParams.get('stage')
const [selectedStageId, setSelectedStageId] = useState<string>(stageIdParam || '')
const [selectedRoundId, setSelectedRoundId] = useState<string>(roundIdParam || '')
// Fetch active programs with stages
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
@@ -49,7 +49,7 @@ function ImportPageContent() {
}))
) || []
const selectedStage = stages.find((s: { id: string }) => s.id === selectedStageId)
const selectedRound = stages.find((s: { id: string }) => s.id === selectedRoundId)
if (loadingPrograms) {
return <ImportPageSkeleton />
@@ -75,7 +75,7 @@ function ImportPageContent() {
</div>
{/* Stage selection */}
{!selectedStageId && (
{!selectedRoundId && (
<Card>
<CardHeader>
<CardTitle>Select Stage</CardTitle>
@@ -87,17 +87,17 @@ function ImportPageContent() {
{stages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Active Stages</p>
<p className="mt-2 font-medium">No Active Rounds</p>
<p className="text-sm text-muted-foreground">
Create a stage first before importing projects
Create a competition with rounds before importing projects
</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds/new-pipeline">Create Pipeline</Link>
<Link href="/admin/competitions">View Competitions</Link>
</Button>
</div>
) : (
<>
<Select value={selectedStageId} onValueChange={setSelectedStageId}>
<Select value={selectedRoundId} onValueChange={setSelectedRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
@@ -117,11 +117,11 @@ function ImportPageContent() {
<Button
onClick={() => {
if (selectedStageId) {
router.push(`/admin/projects/import?stage=${selectedStageId}`)
if (selectedRoundId) {
router.push(`/admin/projects/import?stage=${selectedRoundId}`)
}
}}
disabled={!selectedStageId}
disabled={!selectedRoundId}
>
Continue
</Button>
@@ -132,14 +132,14 @@ function ImportPageContent() {
)}
{/* Import form */}
{selectedStageId && selectedStage && (
{selectedRoundId && selectedRound && (
<div className="space-y-4">
<div className="flex items-center gap-4">
<FileSpreadsheet className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">Importing into: {selectedStage.name}</p>
<p className="font-medium">Importing into: {selectedRound.name}</p>
<p className="text-sm text-muted-foreground">
{selectedStage.programName}
{selectedRound.programName}
</p>
</div>
<Button
@@ -147,7 +147,7 @@ function ImportPageContent() {
size="sm"
className="ml-auto"
onClick={() => {
setSelectedStageId('')
setSelectedRoundId('')
router.push('/admin/projects/import')
}}
>
@@ -172,8 +172,8 @@ function ImportPageContent() {
</TabsList>
<TabsContent value="csv" className="mt-4">
<CSVImportForm
programId={selectedStage.programId}
stageName={selectedStage.name}
programId={selectedRound.programId}
stageName={selectedRound.name}
onSuccess={() => {
utils.project.list.invalidate()
utils.program.get.invalidate()
@@ -182,8 +182,8 @@ function ImportPageContent() {
</TabsContent>
<TabsContent value="notion" className="mt-4">
<NotionImportForm
programId={selectedStage.programId}
stageName={selectedStage.name}
programId={selectedRound.programId}
stageName={selectedRound.name}
onSuccess={() => {
utils.project.list.invalidate()
utils.program.get.invalidate()
@@ -192,8 +192,8 @@ function ImportPageContent() {
</TabsContent>
<TabsContent value="typeform" className="mt-4">
<TypeformImportForm
programId={selectedStage.programId}
stageName={selectedStage.name}
programId={selectedRound.programId}
stageName={selectedRound.name}
onSuccess={() => {
utils.project.list.invalidate()
utils.program.get.invalidate()

View File

@@ -85,11 +85,11 @@ const ROLE_SORT_ORDER: Record<string, number> = { LEAD: 0, MEMBER: 1, ADVISOR: 2
function NewProjectPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const stageIdParam = searchParams.get('stage')
const roundIdParam = searchParams.get('stage')
const programIdParam = searchParams.get('program')
const [selectedProgramId, setSelectedProgramId] = useState<string>(programIdParam || '')
const [selectedStageId, setSelectedStageId] = useState<string>(stageIdParam || '')
const [selectedRoundId, setSelectedRoundId] = useState<string>(roundIdParam || '')
// Form state
const [title, setTitle] = useState('')
@@ -286,7 +286,7 @@ function NewProjectPageContent() {
<Label>Program *</Label>
<Select value={selectedProgramId} onValueChange={(v) => {
setSelectedProgramId(v)
setSelectedStageId('') // Reset stage on program change
setSelectedRoundId('') // Reset stage on program change
}}>
<SelectTrigger>
<SelectValue placeholder="Select a program" />
@@ -303,7 +303,7 @@ function NewProjectPageContent() {
<div className="space-y-2">
<Label>Stage (optional)</Label>
<Select value={selectedStageId || '__none__'} onValueChange={(v) => setSelectedStageId(v === '__none__' ? '' : v)} disabled={!selectedProgramId}>
<Select value={selectedRoundId || '__none__'} onValueChange={(v) => setSelectedRoundId(v === '__none__' ? '' : v)} disabled={!selectedProgramId}>
<SelectTrigger>
<SelectValue placeholder="No stage assigned" />
</SelectTrigger>

View File

@@ -53,6 +53,7 @@ import {
Plus,
MoreHorizontal,
ClipboardList,
Bot,
Eye,
Pencil,
FileUp,
@@ -122,7 +123,7 @@ function parseFiltersFromParams(
statuses: searchParams.get('status')
? searchParams.get('status')!.split(',')
: [],
stageId: searchParams.get('stage') || '',
roundId: searchParams.get('round') || '',
competitionCategory: searchParams.get('category') || '',
oceanIssue: searchParams.get('issue') || '',
country: searchParams.get('country') || '',
@@ -156,7 +157,7 @@ function filtersToParams(
if (filters.search) params.set('q', filters.search)
if (filters.statuses.length > 0)
params.set('status', filters.statuses.join(','))
if (filters.stageId) params.set('stage', filters.stageId)
if (filters.roundId) params.set('round', filters.roundId)
if (filters.competitionCategory)
params.set('category', filters.competitionCategory)
if (filters.oceanIssue) params.set('issue', filters.oceanIssue)
@@ -181,7 +182,7 @@ export default function ProjectsPage() {
const [filters, setFilters] = useState<ProjectFilters>({
search: parsed.search,
statuses: parsed.statuses,
stageId: parsed.stageId,
roundId: parsed.roundId,
competitionCategory: parsed.competitionCategory,
oceanIssue: parsed.oceanIssue,
country: parsed.country,
@@ -252,7 +253,7 @@ export default function ProjectsPage() {
| 'REJECTED'
>)
: undefined,
stageId: filters.stageId || undefined,
roundId: filters.roundId || undefined,
competitionCategory:
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
undefined,
@@ -284,14 +285,14 @@ export default function ProjectsPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null)
// Assign to stage dialog state
// Assign to round dialog state
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null)
const [assignStageId, setAssignStageId] = useState('')
const [assignRoundId, setAssignRoundId] = useState('')
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
const [taggingScope, setTaggingScope] = useState<'stage' | 'program'>('stage')
const [selectedStageForTagging, setSelectedStageForTagging] = useState<string>('')
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
const [selectedProgramForTagging, setSelectedProgramForTagging] = useState<string>('')
const [activeTaggingJobId, setActiveTaggingJobId] = useState<string | null>(null)
@@ -351,10 +352,10 @@ export default function ProjectsPage() {
: null
const handleStartTagging = () => {
if (taggingScope === 'stage' && selectedStageForTagging) {
// Router only accepts programId; resolve from the selected stage's parent program
if (taggingScope === 'round' && selectedRoundForTagging) {
// Router only accepts programId; resolve from the selected round's parent program
const parentProgram = programs?.find((p) =>
((p.stages ?? []) as Array<{ id: string }>)?.some((s: { id: string }) => s.id === selectedStageForTagging)
((p.stages ?? []) as Array<{ id: string }>)?.some((s: { id: string }) => s.id === selectedRoundForTagging)
)
if (parentProgram) {
startTaggingJob.mutate({ programId: parentProgram.id })
@@ -368,19 +369,19 @@ export default function ProjectsPage() {
if (!taggingInProgress) {
setAiTagDialogOpen(false)
setActiveTaggingJobId(null)
setSelectedStageForTagging('')
setSelectedRoundForTagging('')
setSelectedProgramForTagging('')
}
}
// Get selected program's stages (flattened from pipelines -> tracks -> stages)
// Get selected program's rounds (flattened from pipelines -> tracks -> stages)
const selectedProgram = programs?.find(p => p.id === selectedProgramForTagging)
const programStages = selectedProgram?.stages ?? []
const programRounds = selectedProgram?.stages ?? []
// Calculate stats for display
const displayProgram = taggingScope === 'program'
? selectedProgram
: (selectedStageForTagging ? programs?.find(p => (p.stages as Array<{ id: string }>)?.some(s => s.id === selectedStageForTagging)) : null)
: (selectedRoundForTagging ? programs?.find(p => (p.stages as Array<{ id: string }>)?.some(s => s.id === selectedRoundForTagging)) : null)
// Calculate progress percentage
const taggingProgressPercent = jobStatus && jobStatus.totalProjects > 0
@@ -394,7 +395,7 @@ export default function ProjectsPage() {
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
const [bulkNotificationsConfirmed, setBulkNotificationsConfirmed] = useState(false)
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
const [bulkAssignStageId, setBulkAssignStageId] = useState('')
const [bulkAssignRoundId, setBulkAssignRoundId] = useState('')
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false)
@@ -415,7 +416,7 @@ export default function ProjectsPage() {
| 'REJECTED'
>)
: undefined,
stageId: filters.stageId || undefined,
roundId: filters.roundId || undefined,
competitionCategory:
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
undefined,
@@ -475,12 +476,12 @@ export default function ProjectsPage() {
}
)
const bulkAssignToStage = trpc.projectPool.assignToStage.useMutation({
const bulkAssignToRound = trpc.projectPool.assignToRound.useMutation({
onSuccess: (result) => {
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to stage`)
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to round`)
setSelectedIds(new Set())
setAllMatchingSelected(false)
setBulkAssignStageId('')
setBulkAssignRoundId('')
setBulkAssignDialogOpen(false)
utils.project.list.invalidate()
},
@@ -576,13 +577,13 @@ export default function ProjectsPage() {
? data.projects.some((p) => selectedIds.has(p.id)) && !allVisibleSelected
: false
const assignToStage = trpc.projectPool.assignToStage.useMutation({
const assignToRound = trpc.projectPool.assignToRound.useMutation({
onSuccess: () => {
toast.success('Project assigned to stage')
toast.success('Project assigned to round')
utils.project.list.invalidate()
setAssignDialogOpen(false)
setProjectToAssign(null)
setAssignStageId('')
setAssignRoundId('')
},
onError: (error) => {
toast.error(error.message || 'Failed to assign project')
@@ -618,7 +619,7 @@ export default function ProjectsPage() {
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setAiTagDialogOpen(true)}>
<Tags className="mr-2 h-4 w-4" />
<Bot className="mr-2 h-4 w-4" />
AI Tags
</Button>
<Button variant="outline" asChild>
@@ -668,7 +669,7 @@ export default function ProjectsPage() {
filters={filters}
filterOptions={filterOptions ? {
...filterOptions,
stages: programs?.flatMap(p =>
rounds: programs?.flatMap(p =>
(p.stages as Array<{ id: string; name: string }>)?.map(s => ({
id: s.id,
name: s.name,
@@ -799,7 +800,7 @@ export default function ProjectsPage() {
<p className="text-sm text-muted-foreground">
{filters.search ||
filters.statuses.length > 0 ||
filters.stageId ||
filters.roundId ||
filters.competitionCategory ||
filters.oceanIssue ||
filters.country
@@ -992,7 +993,7 @@ export default function ProjectsPage() {
}}
>
<FolderOpen className="mr-2 h-4 w-4" />
Assign to Stage
Assign to Round
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
@@ -1132,7 +1133,7 @@ export default function ProjectsPage() {
}}
>
<FolderOpen className="mr-2 h-4 w-4" />
Assign to Stage
Assign to Round
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
@@ -1258,10 +1259,10 @@ export default function ProjectsPage() {
onClick={() => setBulkAssignDialogOpen(true)}
>
<ArrowRightCircle className="mr-1.5 h-4 w-4" />
Assign to Stage
Assign to Round
</Button>
{/* Change Status (only when filtered by stage) */}
{filters.stageId && (
{/* Change Status (only when filtered by round) */}
{filters.roundId && (
<>
<Select value={bulkStatus} onValueChange={setBulkStatus}>
<SelectTrigger className="w-[160px] h-9 text-sm">
@@ -1442,22 +1443,22 @@ export default function ProjectsPage() {
</AlertDialogContent>
</AlertDialog>
{/* Assign to Stage Dialog */}
{/* Assign to Round Dialog */}
<Dialog open={assignDialogOpen} onOpenChange={(open) => {
setAssignDialogOpen(open)
if (!open) { setProjectToAssign(null); setAssignStageId('') }
if (!open) { setProjectToAssign(null); setAssignRoundId('') }
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Assign to Stage</DialogTitle>
<DialogTitle>Assign to Round</DialogTitle>
<DialogDescription>
Assign &quot;{projectToAssign?.title}&quot; to a stage.
Assign &quot;{projectToAssign?.title}&quot; to a round.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Select Stage</Label>
<Select value={assignStageId} onValueChange={setAssignStageId}>
<Label>Select Round</Label>
<Select value={assignRoundId} onValueChange={setAssignRoundId}>
<SelectTrigger>
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
@@ -1479,40 +1480,40 @@ export default function ProjectsPage() {
</Button>
<Button
onClick={() => {
if (projectToAssign && assignStageId) {
assignToStage.mutate({
if (projectToAssign && assignRoundId) {
assignToRound.mutate({
projectIds: [projectToAssign.id],
stageId: assignStageId,
roundId: assignRoundId,
})
}
}}
disabled={!assignStageId || assignToStage.isPending}
disabled={!assignRoundId || assignToRound.isPending}
>
{assignToStage.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{assignToRound.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign
</Button>
</div>
</DialogContent>
</Dialog>
{/* Bulk Assign to Stage Dialog */}
{/* Bulk Assign to Round Dialog */}
<Dialog open={bulkAssignDialogOpen} onOpenChange={(open) => {
setBulkAssignDialogOpen(open)
if (!open) setBulkAssignStageId('')
if (!open) setBulkAssignRoundId('')
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Assign to Stage</DialogTitle>
<DialogTitle>Assign to Round</DialogTitle>
<DialogDescription>
Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a stage. Projects will have their status set to &quot;Assigned&quot;.
Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a round. Projects will have their status set to &quot;Assigned&quot;.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Select Stage</Label>
<Select value={bulkAssignStageId} onValueChange={setBulkAssignStageId}>
<Label>Select Round</Label>
<Select value={bulkAssignRoundId} onValueChange={setBulkAssignRoundId}>
<SelectTrigger>
<SelectValue placeholder="Choose a stage..." />
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
<SelectContent>
{programs?.flatMap((p) =>
@@ -1532,16 +1533,16 @@ export default function ProjectsPage() {
</Button>
<Button
onClick={() => {
if (bulkAssignStageId && selectedIds.size > 0) {
bulkAssignToStage.mutate({
if (bulkAssignRoundId && selectedIds.size > 0) {
bulkAssignToRound.mutate({
projectIds: Array.from(selectedIds),
stageId: bulkAssignStageId,
roundId: bulkAssignRoundId,
})
}
}}
disabled={!bulkAssignStageId || bulkAssignToStage.isPending}
disabled={!bulkAssignRoundId || bulkAssignToRound.isPending}
>
{bulkAssignToStage.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{bulkAssignToRound.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
</Button>
</div>
@@ -1718,19 +1719,19 @@ export default function ProjectsPage() {
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setTaggingScope('stage')}
onClick={() => setTaggingScope('round')}
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors ${
taggingScope === 'stage'
taggingScope === 'round'
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/30'
}`}
>
<FolderOpen className={`h-6 w-6 ${taggingScope === 'stage' ? 'text-primary' : 'text-muted-foreground'}`} />
<span className={`text-sm font-medium ${taggingScope === 'stage' ? 'text-primary' : ''}`}>
Single Stage
<FolderOpen className={`h-6 w-6 ${taggingScope === 'round' ? 'text-primary' : 'text-muted-foreground'}`} />
<span className={`text-sm font-medium ${taggingScope === 'round' ? 'text-primary' : ''}`}>
Single Round
</span>
<span className="text-xs text-muted-foreground text-center">
Tag projects in one specific stage
Tag projects in one specific round
</span>
</button>
<button
@@ -1747,7 +1748,7 @@ export default function ProjectsPage() {
Entire Edition
</span>
<span className="text-xs text-muted-foreground text-center">
Tag all projects across all stages
Tag all projects across all rounds
</span>
</button>
</div>
@@ -1755,15 +1756,15 @@ export default function ProjectsPage() {
{/* Selection */}
<div className="space-y-2">
{taggingScope === 'stage' ? (
{taggingScope === 'round' ? (
<>
<Label htmlFor="stage-select">Select Stage</Label>
<Label htmlFor="round-select">Select Round</Label>
<Select
value={selectedStageForTagging}
onValueChange={setSelectedStageForTagging}
value={selectedRoundForTagging}
onValueChange={setSelectedRoundForTagging}
>
<SelectTrigger id="stage-select">
<SelectValue placeholder="Choose a stage..." />
<SelectTrigger id="round-select">
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
<SelectContent>
{programs?.flatMap(p =>
@@ -1828,7 +1829,7 @@ export default function ProjectsPage() {
onClick={handleStartTagging}
disabled={
taggingInProgress ||
(taggingScope === 'stage' && !selectedStageForTagging) ||
(taggingScope === 'round' && !selectedRoundForTagging) ||
(taggingScope === 'program' && !selectedProgramForTagging)
}
>

View File

@@ -58,17 +58,17 @@ export default function ProjectPoolPage() {
const stages = (selectedProgramData?.stages || []) as Array<{ id: string; name: string }>
const utils = trpc.useUtils()
const assignMutation = trpc.projectPool.assignToStage.useMutation({
const assignMutation = trpc.projectPool.assignToRound.useMutation({
onSuccess: (result) => {
utils.project.list.invalidate()
utils.program.get.invalidate()
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to stage`)
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
setSelectedProjects([])
setAssignDialogOpen(false)
setTargetStageId('')
refetch()
},
onError: (error) => {
onError: (error: any) => {
toast.error(error.message || 'Failed to assign projects')
},
})
@@ -77,14 +77,14 @@ export default function ProjectPoolPage() {
if (selectedProjects.length === 0 || !targetStageId) return
assignMutation.mutate({
projectIds: selectedProjects,
stageId: targetStageId,
roundId: targetStageId,
})
}
const handleQuickAssign = (projectId: string, stageId: string) => {
const handleQuickAssign = (projectId: string, roundId: string) => {
assignMutation.mutate({
projectIds: [projectId],
stageId,
roundId,
})
}
@@ -242,7 +242,7 @@ export default function ProjectPoolPage() {
<Skeleton className="h-9 w-[200px]" />
) : (
<Select
onValueChange={(stageId) => handleQuickAssign(project.id, stageId)}
onValueChange={(roundId) => handleQuickAssign(project.id, roundId)}
disabled={assignMutation.isPending}
>
<SelectTrigger className="w-[200px]">

View File

@@ -63,7 +63,7 @@ const ISSUE_LABELS: Record<string, string> = {
export interface ProjectFilters {
search: string
statuses: string[]
stageId: string
roundId: string
competitionCategory: string
oceanIssue: string
country: string
@@ -76,7 +76,7 @@ export interface FilterOptions {
countries: string[]
categories: Array<{ value: string; count: number }>
issues: Array<{ value: string; count: number }>
stages?: Array<{ id: string; name: string; programName: string; programYear: number }>
rounds?: Array<{ id: string; name: string; programName: string; programYear: number }>
}
interface ProjectFiltersBarProps {
@@ -94,7 +94,7 @@ export function ProjectFiltersBar({
const activeFilterCount = [
filters.statuses.length > 0,
filters.stageId !== '',
filters.roundId !== '',
filters.competitionCategory !== '',
filters.oceanIssue !== '',
filters.country !== '',
@@ -114,7 +114,7 @@ export function ProjectFiltersBar({
onChange({
search: filters.search,
statuses: [],
stageId: '',
roundId: '',
competitionCategory: '',
oceanIssue: '',
country: '',
@@ -175,19 +175,19 @@ export function ProjectFiltersBar({
{/* Select filters grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label className="text-sm">Stage / Edition</Label>
<Label className="text-sm">Round / Edition</Label>
<Select
value={filters.stageId || '_all'}
value={filters.roundId || '_all'}
onValueChange={(v) =>
onChange({ ...filters, stageId: v === '_all' ? '' : v })
onChange({ ...filters, roundId: v === '_all' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All stages" />
<SelectValue placeholder="All rounds" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All stages</SelectItem>
{filterOptions?.stages?.map((s) => (
<SelectItem value="_all">All rounds</SelectItem>
{filterOptions?.rounds?.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name} ({s.programYear} {s.programName})
</SelectItem>