Compare commits

...

6 Commits

Author SHA1 Message Date
68aa393559 feat: show submission round file requirements on project edit page
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m40s
Adds a new tRPC procedure `round.getSubmissionRoundForProgram` that
fetches the most recent SUBMISSION round for a given program, then
displays any `requiredDocuments` from its configJson as labeled info
cards above the general file upload section on the project edit page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:47:42 +01:00
9b3a9f6cbf feat: enhance project search to include all criteria, add AI tag generation button
- ProjectStatesTable local search now covers country, institution, competitionCategory, geographicZone
- project-pool.ts DB search extended to institution, country, geographicZone, team member names
- AwardShortlist eligibility table gains a search input filtering by title, team, country, institution, category
- IndividualAssignmentsTable project filter extended to include country and institution
- Add "Generate AI Tags" dropdown item per row in ProjectStatesTable using tag.tagProject mutation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:42:37 +01:00
dd004baf79 feat: add View Project links to admin tables, conditionally show Awards tab
- IndividualAssignmentsTable: add View Project (new tab) as first dropdown item
- AwardShortlist: make project title a clickable link opening in new tab
- ProjectStatesTable: change View Project from same-tab Link to new-tab anchor
- Round page: Awards tab now only shown when roundAwards.length > 0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:37:45 +01:00
2f1136646e feat: ranking UI improvements - highlight advancing projects, expandable reviews, view project link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:34:32 +01:00
36560a1837 fix: assign project to round on creation (create ProjectRoundState)
- Add optional roundId field to project.create mutation input schema
- After project creation, update project.roundId FK and create a
  ProjectRoundState record (state: PENDING) when roundId is supplied
- Pass selectedRoundId from the new-project form to createProject.mutate()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:31:08 +01:00
25e06e11e4 feat: add all missing fields to project update mutation and edit form
Adds competitionCategory, oceanIssue, institution, geographicZone,
wantsMentorship, and foundedAt to the tRPC update mutation input schema
and the admin project edit form UI (with CountrySelect + Switch).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:28:26 +01:00
10 changed files with 486 additions and 64 deletions

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { Suspense, use, useState, useEffect, useCallback } from 'react' import { Suspense, use, useState, useEffect, useCallback, useMemo } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
@@ -19,6 +19,8 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { CountrySelect } from '@/components/ui/country-select'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { import {
Select, Select,
@@ -55,6 +57,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { Separator } from '@/components/ui/separator'
import { FileUpload } from '@/components/shared/file-upload' import { FileUpload } from '@/components/shared/file-upload'
import { ProjectLogo } from '@/components/shared/project-logo' import { ProjectLogo } from '@/components/shared/project-logo'
import { LogoUpload } from '@/components/shared/logo-upload' import { LogoUpload } from '@/components/shared/logo-upload'
@@ -89,6 +92,13 @@ const updateProjectSchema = z.object({
'REJECTED', 'REJECTED',
]), ]),
tags: z.array(z.string()), tags: z.array(z.string()),
competitionCategory: z.string().optional(),
oceanIssue: z.string().optional(),
institution: z.string().optional(),
country: z.string().optional(),
geographicZone: z.string().optional(),
wantsMentorship: z.boolean().optional(),
foundedAt: z.string().optional(),
}) })
type UpdateProjectForm = z.infer<typeof updateProjectSchema> type UpdateProjectForm = z.infer<typeof updateProjectSchema>
@@ -124,6 +134,27 @@ function EditProjectContent({ projectId }: { projectId: string }) {
// Fetch existing tags for suggestions // Fetch existing tags for suggestions
const { data: existingTags } = trpc.project.getTags.useQuery({}) const { data: existingTags } = trpc.project.getTags.useQuery({})
// Fetch submission round config to show required documents
const programId = project?.programId
const { data: submissionRound } = trpc.round.getSubmissionRoundForProgram.useQuery(
{ programId: programId! },
{ enabled: !!programId }
)
const submissionRoundConfig = useMemo(() => {
if (!submissionRound?.configJson) return null
const config = submissionRound.configJson as Record<string, unknown>
const docs = config.requiredDocuments as
| Array<{ name: string; required?: boolean; description?: string }>
| null
| undefined
if (!docs || docs.length === 0) return null
return {
roundName: submissionRound.name,
requiredDocuments: docs,
}
}, [submissionRound])
// Mutations // Mutations
const utils = trpc.useUtils() const utils = trpc.useUtils()
const updateProject = trpc.project.update.useMutation({ const updateProject = trpc.project.update.useMutation({
@@ -157,6 +188,13 @@ function EditProjectContent({ projectId }: { projectId: string }) {
description: '', description: '',
status: 'SUBMITTED', status: 'SUBMITTED',
tags: [], tags: [],
competitionCategory: '',
oceanIssue: '',
institution: '',
country: '',
geographicZone: '',
wantsMentorship: false,
foundedAt: '',
}, },
}) })
@@ -169,6 +207,13 @@ function EditProjectContent({ projectId }: { projectId: string }) {
description: project.description || '', description: project.description || '',
status: (project.status ?? 'SUBMITTED') as UpdateProjectForm['status'], status: (project.status ?? 'SUBMITTED') as UpdateProjectForm['status'],
tags: project.tags || [], tags: project.tags || [],
competitionCategory: project.competitionCategory || '',
oceanIssue: project.oceanIssue || '',
institution: project.institution || '',
country: project.country || '',
geographicZone: project.geographicZone || '',
wantsMentorship: project.wantsMentorship ?? false,
foundedAt: project.foundedAt ? new Date(project.foundedAt).toISOString().split('T')[0] : '',
}) })
} }
}, [project, form]) }, [project, form])
@@ -229,6 +274,13 @@ function EditProjectContent({ projectId }: { projectId: string }) {
description: data.description || null, description: data.description || null,
status: data.status, status: data.status,
tags: data.tags, tags: data.tags,
competitionCategory: (data.competitionCategory || null) as 'STARTUP' | 'BUSINESS_CONCEPT' | null,
oceanIssue: (data.oceanIssue || null) as 'POLLUTION_REDUCTION' | 'CLIMATE_MITIGATION' | 'TECHNOLOGY_INNOVATION' | 'SUSTAINABLE_SHIPPING' | 'BLUE_CARBON' | 'HABITAT_RESTORATION' | 'COMMUNITY_CAPACITY' | 'SUSTAINABLE_FISHING' | 'CONSUMER_AWARENESS' | 'OCEAN_ACIDIFICATION' | 'OTHER' | null,
institution: data.institution || null,
country: data.country || null,
geographicZone: data.geographicZone || null,
wantsMentorship: data.wantsMentorship,
foundedAt: data.foundedAt ? new Date(data.foundedAt).toISOString() : null,
}) })
} }
@@ -438,6 +490,159 @@ function EditProjectContent({ projectId }: { projectId: string }) {
</CardContent> </CardContent>
</Card> </Card>
{/* Project Details */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Project Details</CardTitle>
<CardDescription>
Additional categorization and metadata for this project
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="competitionCategory"
render={({ field }) => (
<FormItem>
<FormLabel>Competition Category</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || ''}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select category..." />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="STARTUP">Startup</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="oceanIssue"
render={({ field }) => (
<FormItem>
<FormLabel>Ocean Issue</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || ''}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select ocean issue..." />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="POLLUTION_REDUCTION">Pollution Reduction</SelectItem>
<SelectItem value="CLIMATE_MITIGATION">Climate Mitigation</SelectItem>
<SelectItem value="TECHNOLOGY_INNOVATION">Technology Innovation</SelectItem>
<SelectItem value="SUSTAINABLE_SHIPPING">Sustainable Shipping</SelectItem>
<SelectItem value="BLUE_CARBON">Blue Carbon</SelectItem>
<SelectItem value="HABITAT_RESTORATION">Habitat Restoration</SelectItem>
<SelectItem value="COMMUNITY_CAPACITY">Community Capacity</SelectItem>
<SelectItem value="SUSTAINABLE_FISHING">Sustainable Fishing</SelectItem>
<SelectItem value="CONSUMER_AWARENESS">Consumer Awareness</SelectItem>
<SelectItem value="OCEAN_ACIDIFICATION">Ocean Acidification</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="institution"
render={({ field }) => (
<FormItem>
<FormLabel>Institution</FormLabel>
<FormControl>
<Input placeholder="Institution or organization" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="country"
render={({ field }) => (
<FormItem>
<FormLabel>Country</FormLabel>
<FormControl>
<CountrySelect
value={field.value || ''}
onChange={field.onChange}
placeholder="Select country..."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="geographicZone"
render={({ field }) => (
<FormItem>
<FormLabel>Geographic Zone</FormLabel>
<FormControl>
<Input placeholder="e.g. Europe, France" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="foundedAt"
render={({ field }) => (
<FormItem>
<FormLabel>Founded Date</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="wantsMentorship"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>Wants Mentorship</FormLabel>
<FormDescription>
Whether this project team is interested in mentorship
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value ?? false}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Tags */} {/* Tags */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -519,7 +724,34 @@ function EditProjectContent({ projectId }: { projectId: string }) {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{files && files.length > 0 ? ( {submissionRoundConfig && (
<div className="mb-4 space-y-3">
<div>
<p className="text-sm font-medium">Required Documents</p>
<p className="text-xs text-muted-foreground">
From {submissionRoundConfig.roundName}
</p>
</div>
<div className="space-y-2">
{submissionRoundConfig.requiredDocuments.map((doc, i) => (
<div key={i} className="flex items-center justify-between rounded-md border border-dashed p-3">
<div>
<p className="text-sm font-medium">{doc.name}</p>
{doc.description && (
<p className="text-xs text-muted-foreground">{doc.description}</p>
)}
{doc.required && (
<Badge variant="outline" className="mt-1 text-xs">Required</Badge>
)}
</div>
</div>
))}
</div>
<Separator />
</div>
)}
{files && files.length > 0 ? (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>

View File

@@ -216,6 +216,7 @@ function NewProjectPageContent() {
createProject.mutate({ createProject.mutate({
programId: selectedProgramId, programId: selectedProgramId,
roundId: selectedRoundId || undefined,
title: title.trim(), title: title.trim(),
teamName: teamName.trim() || undefined, teamName: teamName.trim() || undefined,
description: description.trim() || undefined, description: description.trim() || undefined,

View File

@@ -492,7 +492,7 @@ export default function RoundDetailPage() {
const isFiltering = round?.roundType === 'FILTERING' const isFiltering = round?.roundType === 'FILTERING'
const isEvaluation = round?.roundType === 'EVALUATION' const isEvaluation = round?.roundType === 'EVALUATION'
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '') const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
const hasAwards = hasJury const hasAwards = roundAwards.length > 0
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '') const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
const poolLink = `/admin/projects?hasAssign=false&round=${roundId}` as Route const poolLink = `/admin/projects?hasAssign=false&round=${roundId}` as Route

View File

@@ -60,6 +60,7 @@ import {
Search, Search,
MoreHorizontal, MoreHorizontal,
UserPlus, UserPlus,
ExternalLink,
} from 'lucide-react' } from 'lucide-react'
export type IndividualAssignmentsTableProps = { export type IndividualAssignmentsTableProps = {
@@ -170,7 +171,9 @@ export function IndividualAssignmentsTable({
return items.filter((ps: any) => return items.filter((ps: any) =>
ps.project?.title?.toLowerCase().includes(q) || ps.project?.title?.toLowerCase().includes(q) ||
ps.project?.teamName?.toLowerCase().includes(q) || ps.project?.teamName?.toLowerCase().includes(q) ||
ps.project?.competitionCategory?.toLowerCase().includes(q) ps.project?.competitionCategory?.toLowerCase().includes(q) ||
(ps.project?.country || '').toLowerCase().includes(q) ||
(ps.project?.institution || '').toLowerCase().includes(q)
) )
}, [projectStates, projectSearch]) }, [projectStates, projectSearch])
@@ -371,6 +374,13 @@ export function IndividualAssignmentsTable({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<a href={`/admin/projects/${a.project?.id}`} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
View Project
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
{a.conflictOfInterest?.hasConflict && ( {a.conflictOfInterest?.hasConflict && (
<> <>
<DropdownMenuItem <DropdownMenuItem

View File

@@ -1,9 +1,10 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
@@ -32,6 +33,7 @@ import {
Play, Play,
Trophy, Trophy,
AlertTriangle, AlertTriangle,
Search,
} from 'lucide-react' } from 'lucide-react'
type AwardShortlistProps = { type AwardShortlistProps = {
@@ -58,6 +60,7 @@ export function AwardShortlist({
jobDone, jobDone,
}: AwardShortlistProps) { }: AwardShortlistProps) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [eligibilitySearch, setEligibilitySearch] = useState('')
const utils = trpc.useUtils() const utils = trpc.useUtils()
const isRunning = jobStatus === 'PENDING' || jobStatus === 'PROCESSING' const isRunning = jobStatus === 'PENDING' || jobStatus === 'PROCESSING'
@@ -124,6 +127,19 @@ export function AwardShortlist({
? Math.round(((currentJobDone ?? 0) / currentJobTotal) * 100) ? Math.round(((currentJobDone ?? 0) / currentJobTotal) * 100)
: 0 : 0
const filteredEligibilities = useMemo(() => {
if (!shortlist) return []
if (!eligibilitySearch.trim()) return shortlist.eligibilities
const q = eligibilitySearch.toLowerCase()
return shortlist.eligibilities.filter((e: any) =>
(e.project?.title || '').toLowerCase().includes(q) ||
(e.project?.teamName || '').toLowerCase().includes(q) ||
(e.project?.country || '').toLowerCase().includes(q) ||
(e.project?.institution || '').toLowerCase().includes(q) ||
(e.project?.competitionCategory || '').toLowerCase().includes(q)
)
}, [shortlist, eligibilitySearch])
const shortlistedCount = shortlist?.eligibilities?.filter((e) => e.shortlisted).length ?? 0 const shortlistedCount = shortlist?.eligibilities?.filter((e) => e.shortlisted).length ?? 0
const allShortlisted = shortlist && shortlist.eligibilities.length > 0 && shortlist.eligibilities.every((e) => e.shortlisted) const allShortlisted = shortlist && shortlist.eligibilities.length > 0 && shortlist.eligibilities.every((e) => e.shortlisted)
const someShortlisted = shortlistedCount > 0 && !allShortlisted const someShortlisted = shortlistedCount > 0 && !allShortlisted
@@ -266,6 +282,18 @@ export function AwardShortlist({
)} )}
</div> </div>
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={eligibilitySearch}
onChange={(e) => setEligibilitySearch(e.target.value)}
className="pl-9 h-9 max-w-sm"
/>
</div>
</div>
<div className="border rounded-md overflow-hidden"> <div className="border rounded-md overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/50"> <thead className="bg-muted/50">
@@ -288,7 +316,7 @@ export function AwardShortlist({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{shortlist.eligibilities.map((e, i) => { {filteredEligibilities.map((e, i) => {
const reasoning = (e.aiReasoningJson as Record<string, unknown>)?.reasoning as string | undefined const reasoning = (e.aiReasoningJson as Record<string, unknown>)?.reasoning as string | undefined
const isTop5 = i < shortlistSize const isTop5 = i < shortlistSize
return ( return (
@@ -303,7 +331,15 @@ export function AwardShortlist({
<td className="px-3 py-2"> <td className="px-3 py-2">
<div> <div>
<p className={`font-medium ${isTop5 ? 'text-amber-900' : ''}`}> <p className={`font-medium ${isTop5 ? 'text-amber-900' : ''}`}>
{e.project.title} <a
href={`/admin/projects/${e.project.id}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline font-medium"
onClick={(ev) => ev.stopPropagation()}
>
{e.project.title}
</a>
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{[e.project.teamName, e.project.country, e.project.competitionCategory].filter(Boolean).join(', ') || '—'} {[e.project.teamName, e.project.country, e.project.competitionCategory].filter(Boolean).join(', ') || '—'}

View File

@@ -58,6 +58,7 @@ import {
Plus, Plus,
Search, Search,
ExternalLink, ExternalLink,
Sparkles,
} from 'lucide-react' } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
@@ -136,6 +137,14 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
const tagProject = trpc.tag.tagProject.useMutation({
onSuccess: () => {
toast.success('AI tags generated')
utils.roundEngine.getProjectStates.invalidate({ roundId })
},
onError: (err: any) => toast.error(`Tag generation failed: ${err.message}`),
})
const handleTransition = (projectId: string, newState: ProjectState) => { const handleTransition = (projectId: string, newState: ProjectState) => {
transitionMutation.mutate({ projectId, roundId, newState }) transitionMutation.mutate({ projectId, roundId, newState })
} }
@@ -165,10 +174,17 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
} }
if (searchQuery.trim()) { if (searchQuery.trim()) {
const q = searchQuery.toLowerCase() const q = searchQuery.toLowerCase()
result = result.filter((ps: any) => result = result.filter((ps: any) => {
(ps.project?.title || '').toLowerCase().includes(q) || const p = ps.project
(ps.project?.teamName || '').toLowerCase().includes(q) return (
) (p?.title || '').toLowerCase().includes(q) ||
(p?.teamName || '').toLowerCase().includes(q) ||
(p?.country || '').toLowerCase().includes(q) ||
(p?.institution || '').toLowerCase().includes(q) ||
(p?.competitionCategory || '').toLowerCase().includes(q) ||
(p?.geographicZone || '').toLowerCase().includes(q)
)
})
} }
return result return result
}, [projectStates, stateFilter, searchQuery]) }, [projectStates, stateFilter, searchQuery])
@@ -406,10 +422,20 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/admin/projects/${ps.projectId}` as Route}> <a href={`/admin/projects/${ps.projectId}`} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3.5 w-3.5 mr-2" /> <ExternalLink className="h-3.5 w-3.5 mr-2" />
View Project View Project
</Link> </a>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
tagProject.mutate({ projectId: ps.projectId })
}}
disabled={tagProject.isPending}
>
<Sparkles className="mr-2 h-4 w-4" />
Generate AI Tags
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{PROJECT_STATES.filter((s) => s !== ps.state).map((state) => { {PROJECT_STATES.filter((s) => s !== ps.state).map((state) => {

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useEffect, useRef, useMemo } from 'react' import React, { useState, useEffect, useRef, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -49,6 +49,7 @@ import {
Loader2, Loader2,
RefreshCw, RefreshCw,
Trophy, Trophy,
ExternalLink,
} from 'lucide-react' } from 'lucide-react'
import type { RankedProjectEntry } from '@/server/services/ai-ranking' import type { RankedProjectEntry } from '@/server/services/ai-ranking'
@@ -191,6 +192,9 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
const [topNConceptual, setTopNConceptual] = useState(3) const [topNConceptual, setTopNConceptual] = useState(3)
const [includeReject, setIncludeReject] = useState(false) const [includeReject, setIncludeReject] = useState(false)
// ─── Expandable review state ──────────────────────────────────────────────
const [expandedReviews, setExpandedReviews] = useState<Set<string>>(new Set())
// ─── Sensors ────────────────────────────────────────────────────────────── // ─── Sensors ──────────────────────────────────────────────────────────────
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor), useSensor(PointerSensor),
@@ -220,6 +224,8 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
{ enabled: !!selectedProjectId }, { enabled: !!selectedProjectId },
) )
const { data: roundData } = trpc.round.getById.useQuery({ id: roundId })
// ─── tRPC mutations ─────────────────────────────────────────────────────── // ─── tRPC mutations ───────────────────────────────────────────────────────
const utils = trpc.useUtils() const utils = trpc.useUtils()
@@ -261,6 +267,19 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
// ─── evalConfig (advancement counts from round config) ────────────────────
const evalConfig = useMemo(() => {
if (!roundData?.configJson) return null
try {
const config = roundData.configJson as Record<string, unknown>
const advConfig = config.advancementConfig as Record<string, unknown> | undefined
return {
startupAdvanceCount: (advConfig?.startupCount ?? config.startupAdvanceCount ?? 0) as number,
conceptAdvanceCount: (advConfig?.conceptCount ?? config.conceptAdvanceCount ?? 0) as number,
}
} catch { return null }
}, [roundData])
// ─── rankingMap (O(1) lookup) ────────────────────────────────────────────── // ─── rankingMap (O(1) lookup) ──────────────────────────────────────────────
const rankingMap = useMemo(() => { const rankingMap = useMemo(() => {
const map = new Map<string, RankedProjectEntry>() const map = new Map<string, RankedProjectEntry>()
@@ -298,6 +317,14 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
} }
}, [snapshot]) }, [snapshot])
// ─── sync advance dialog defaults from config ────────────────────────────
useEffect(() => {
if (evalConfig) {
if (evalConfig.startupAdvanceCount > 0) setTopNStartup(evalConfig.startupAdvanceCount)
if (evalConfig.conceptAdvanceCount > 0) setTopNConceptual(evalConfig.conceptAdvanceCount)
}
}, [evalConfig])
// ─── handleDragEnd ──────────────────────────────────────────────────────── // ─── handleDragEnd ────────────────────────────────────────────────────────
function handleDragEnd(category: 'STARTUP' | 'BUSINESS_CONCEPT', event: DragEndEvent) { function handleDragEnd(category: 'STARTUP' | 'BUSINESS_CONCEPT', event: DragEndEvent) {
const { active, over } = event const { active, over } = event
@@ -448,6 +475,11 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
<CardHeader> <CardHeader>
<CardTitle className="text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <CardTitle className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{categoryLabels[category]} {categoryLabels[category]}
{evalConfig && (category === 'STARTUP' ? evalConfig.startupAdvanceCount : evalConfig.conceptAdvanceCount) > 0 && (
<span className="ml-2 text-xs font-normal normal-case">
(Top {category === 'STARTUP' ? evalConfig.startupAdvanceCount : evalConfig.conceptAdvanceCount} advance)
</span>
)}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -467,25 +499,43 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
> >
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
<div className="space-y-2"> <div className="space-y-2">
{localOrder[category].map((projectId, index) => ( {localOrder[category].map((projectId, index) => {
<motion.div const advanceCount = category === 'STARTUP'
key={projectId} ? (evalConfig?.startupAdvanceCount ?? 0)
layout : (evalConfig?.conceptAdvanceCount ?? 0)
initial={{ opacity: 0 }} const isAdvancing = advanceCount > 0 && index < advanceCount
animate={{ opacity: 1 }} const isCutoffRow = advanceCount > 0 && index === advanceCount - 1
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }} return (
> <React.Fragment key={projectId}>
<SortableProjectRow <motion.div
projectId={projectId} layout
currentRank={index + 1} initial={{ opacity: 0, y: 20 }}
entry={rankingMap.get(projectId)} animate={{ opacity: 1, y: 0 }}
projectInfo={projectInfoMap.get(projectId)} exit={{ opacity: 0, y: -20 }}
onSelect={() => setSelectedProjectId(projectId)} className={isAdvancing ? 'rounded-lg bg-emerald-50 dark:bg-emerald-950/20' : ''}
isSelected={selectedProjectId === projectId} >
/> <SortableProjectRow
</motion.div> projectId={projectId}
))} currentRank={index + 1}
entry={rankingMap.get(projectId)}
projectInfo={projectInfoMap.get(projectId)}
onSelect={() => setSelectedProjectId(projectId)}
isSelected={selectedProjectId === projectId}
/>
</motion.div>
{isCutoffRow && (
<div className="flex items-center gap-2 py-1">
<div className="flex-1 border-t-2 border-dashed border-emerald-400/60" />
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400 whitespace-nowrap">
Advancement cutoff Top {advanceCount}
</span>
<div className="flex-1 border-t-2 border-dashed border-emerald-400/60" />
</div>
)}
</React.Fragment>
)
})}
</div> </div>
</AnimatePresence> </AnimatePresence>
</SortableContext> </SortableContext>
@@ -617,6 +667,17 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
<SheetDescription> <SheetDescription>
{selectedProjectId ? `ID: …${selectedProjectId.slice(-8)}` : ''} {selectedProjectId ? `ID: …${selectedProjectId.slice(-8)}` : ''}
</SheetDescription> </SheetDescription>
{selectedProjectId && (
<a
href={`/admin/projects/${selectedProjectId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline mt-1"
>
<ExternalLink className="h-3.5 w-3.5" />
View Project Page
</a>
)}
</SheetHeader> </SheetHeader>
{detailLoading ? ( {detailLoading ? (
@@ -669,37 +730,40 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
} }
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{submitted.map((a) => ( {submitted.map((a) => {
<div key={a.id} className="rounded-lg border p-3 space-y-2"> const isExpanded = expandedReviews.has(a.id)
<div className="flex items-center justify-between gap-2"> return (
<p className="text-sm font-medium truncate">{a.user.name ?? a.user.email}</p> <div
<div className="flex items-center gap-2 flex-shrink-0"> key={a.id}
{a.evaluation?.globalScore !== null && a.evaluation?.globalScore !== undefined && ( className="rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
<Badge variant="outline" className="text-xs"> onClick={() => setExpandedReviews(prev => {
Score: {a.evaluation.globalScore.toFixed(1)} const next = new Set(prev)
</Badge> next.has(a.id) ? next.delete(a.id) : next.add(a.id)
)} return next
{a.evaluation?.binaryDecision !== null && a.evaluation?.binaryDecision !== undefined && ( })}
<Badge >
className={cn( <div className="flex items-center justify-between">
'text-xs', <span className="font-medium text-sm">{a.user?.name ?? a.user?.email ?? 'Unknown'}</span>
a.evaluation.binaryDecision <div className="flex items-center gap-2">
? 'bg-green-100 text-green-700 hover:bg-green-100' {a.evaluation?.binaryDecision != null && (
: 'bg-red-100 text-red-700 hover:bg-red-100', <Badge
)} variant={a.evaluation.binaryDecision ? 'default' : 'destructive'}
> className={a.evaluation.binaryDecision ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-100' : ''}
{a.evaluation.binaryDecision ? 'Yes' : 'No'} >
</Badge> {a.evaluation.binaryDecision ? 'Yes' : 'No'}
)} </Badge>
)}
<Badge variant="outline">Score: {a.evaluation?.globalScore?.toFixed(1) ?? '—'}</Badge>
</div>
</div> </div>
{isExpanded && a.evaluation?.feedbackText && (
<p className="mt-2 text-sm text-muted-foreground whitespace-pre-wrap border-t pt-2">
{a.evaluation.feedbackText}
</p>
)}
</div> </div>
{a.evaluation?.feedbackText && ( )
<p className="text-xs text-muted-foreground leading-relaxed"> })}
{a.evaluation.feedbackText}
</p>
)}
</div>
))}
</div> </div>
) )
})()} })()}

View File

@@ -123,12 +123,16 @@ export const projectPoolRouter = router({
where.competitionCategory = competitionCategory where.competitionCategory = competitionCategory
} }
// Search in title, teamName, description // Search in title, teamName, description, institution, country, geographicZone, team member names
if (search) { if (search) {
where.OR = [ where.OR = [
{ title: { contains: search, mode: 'insensitive' } }, { title: { contains: search, mode: 'insensitive' } },
{ teamName: { contains: search, mode: 'insensitive' } }, { teamName: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } },
{ institution: { contains: search, mode: 'insensitive' } },
{ country: { contains: search, mode: 'insensitive' } },
{ geographicZone: { contains: search, mode: 'insensitive' } },
{ teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } },
] ]
} }

View File

@@ -484,6 +484,7 @@ export const projectRouter = router({
.input( .input(
z.object({ z.object({
programId: z.string(), programId: z.string(),
roundId: z.string().optional(),
title: z.string().min(1).max(500), title: z.string().min(1).max(500),
teamName: z.string().optional(), teamName: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
@@ -553,6 +554,20 @@ export const projectRouter = router({
}, },
}) })
if (input.roundId) {
await tx.project.update({
where: { id: created.id },
data: { roundId: input.roundId },
})
await tx.projectRoundState.create({
data: {
projectId: created.id,
roundId: input.roundId,
state: 'PENDING',
},
})
}
// Create team members if provided // Create team members if provided
const inviteList: { userId: string; email: string; name: string }[] = [] const inviteList: { userId: string; email: string; name: string }[] = []
if (teamMembersInput && teamMembersInput.length > 0) { if (teamMembersInput && teamMembersInput.length > 0) {
@@ -673,6 +688,17 @@ export const projectRouter = router({
teamName: z.string().optional().nullable(), teamName: z.string().optional().nullable(),
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
country: z.string().optional().nullable(), // ISO-2 code or country name (will be normalized) country: z.string().optional().nullable(), // ISO-2 code or country name (will be normalized)
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional().nullable(),
oceanIssue: z.enum([
'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
'OCEAN_ACIDIFICATION', 'OTHER',
]).optional().nullable(),
institution: z.string().optional().nullable(),
geographicZone: z.string().optional().nullable(),
wantsMentorship: z.boolean().optional(),
foundedAt: z.string().datetime().optional().nullable(),
status: z status: z
.enum([ .enum([
'SUBMITTED', 'SUBMITTED',
@@ -688,7 +714,7 @@ export const projectRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { id, metadataJson, status, country, ...data } = input const { id, metadataJson, status, country, foundedAt, ...data } = input
// Normalize country to ISO-2 code if provided // Normalize country to ISO-2 code if provided
const normalizedCountry = country !== undefined const normalizedCountry = country !== undefined
@@ -717,6 +743,7 @@ export const projectRouter = router({
...data, ...data,
...(status && { status }), ...(status && { status }),
...(normalizedCountry !== undefined && { country: normalizedCountry }), ...(normalizedCountry !== undefined && { country: normalizedCountry }),
...(foundedAt !== undefined && { foundedAt: foundedAt ? new Date(foundedAt) : null }),
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
}, },
}) })

View File

@@ -861,4 +861,26 @@ export const roundRouter = router({
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: 'asc' },
}) })
}), }),
/**
* Get the most recent SUBMISSION round config for a program.
* Used on the project edit page to show required document slots.
*/
getSubmissionRoundForProgram: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findFirst({
where: {
roundType: 'SUBMISSION',
competition: { programId: input.programId },
},
select: {
id: true,
name: true,
configJson: true,
},
orderBy: { sortOrder: 'desc' },
})
return round ?? null
}),
}) })