Compare commits
6 Commits
f200eda692
...
68aa393559
| Author | SHA1 | Date | |
|---|---|---|---|
| 68aa393559 | |||
| 9b3a9f6cbf | |||
| dd004baf79 | |||
| 2f1136646e | |||
| 36560a1837 | |||
| 25e06e11e4 |
@@ -1,6 +1,6 @@
|
||||
'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 { useRouter } from 'next/navigation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
@@ -19,6 +19,8 @@ import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 {
|
||||
Select,
|
||||
@@ -55,6 +57,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { FileUpload } from '@/components/shared/file-upload'
|
||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||
import { LogoUpload } from '@/components/shared/logo-upload'
|
||||
@@ -89,6 +92,13 @@ const updateProjectSchema = z.object({
|
||||
'REJECTED',
|
||||
]),
|
||||
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>
|
||||
@@ -124,6 +134,27 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
// Fetch existing tags for suggestions
|
||||
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
|
||||
const utils = trpc.useUtils()
|
||||
const updateProject = trpc.project.update.useMutation({
|
||||
@@ -157,6 +188,13 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
description: '',
|
||||
status: 'SUBMITTED',
|
||||
tags: [],
|
||||
competitionCategory: '',
|
||||
oceanIssue: '',
|
||||
institution: '',
|
||||
country: '',
|
||||
geographicZone: '',
|
||||
wantsMentorship: false,
|
||||
foundedAt: '',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -169,6 +207,13 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
description: project.description || '',
|
||||
status: (project.status ?? 'SUBMITTED') as UpdateProjectForm['status'],
|
||||
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])
|
||||
@@ -229,6 +274,13 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
description: data.description || null,
|
||||
status: data.status,
|
||||
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>
|
||||
</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 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -519,6 +724,33 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{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>
|
||||
<TableHeader>
|
||||
|
||||
@@ -216,6 +216,7 @@ function NewProjectPageContent() {
|
||||
|
||||
createProject.mutate({
|
||||
programId: selectedProgramId,
|
||||
roundId: selectedRoundId || undefined,
|
||||
title: title.trim(),
|
||||
teamName: teamName.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
|
||||
@@ -492,7 +492,7 @@ export default function RoundDetailPage() {
|
||||
const isFiltering = round?.roundType === 'FILTERING'
|
||||
const isEvaluation = round?.roundType === 'EVALUATION'
|
||||
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 poolLink = `/admin/projects?hasAssign=false&round=${roundId}` as Route
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
Search,
|
||||
MoreHorizontal,
|
||||
UserPlus,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
|
||||
export type IndividualAssignmentsTableProps = {
|
||||
@@ -170,7 +171,9 @@ export function IndividualAssignmentsTable({
|
||||
return items.filter((ps: any) =>
|
||||
ps.project?.title?.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])
|
||||
|
||||
@@ -371,6 +374,13 @@ export function IndividualAssignmentsTable({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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 && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
Play,
|
||||
Trophy,
|
||||
AlertTriangle,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
type AwardShortlistProps = {
|
||||
@@ -58,6 +60,7 @@ export function AwardShortlist({
|
||||
jobDone,
|
||||
}: AwardShortlistProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [eligibilitySearch, setEligibilitySearch] = useState('')
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const isRunning = jobStatus === 'PENDING' || jobStatus === 'PROCESSING'
|
||||
@@ -124,6 +127,19 @@ export function AwardShortlist({
|
||||
? Math.round(((currentJobDone ?? 0) / currentJobTotal) * 100)
|
||||
: 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 allShortlisted = shortlist && shortlist.eligibilities.length > 0 && shortlist.eligibilities.every((e) => e.shortlisted)
|
||||
const someShortlisted = shortlistedCount > 0 && !allShortlisted
|
||||
@@ -266,6 +282,18 @@ export function AwardShortlist({
|
||||
)}
|
||||
</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">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
@@ -288,7 +316,7 @@ export function AwardShortlist({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shortlist.eligibilities.map((e, i) => {
|
||||
{filteredEligibilities.map((e, i) => {
|
||||
const reasoning = (e.aiReasoningJson as Record<string, unknown>)?.reasoning as string | undefined
|
||||
const isTop5 = i < shortlistSize
|
||||
return (
|
||||
@@ -303,7 +331,15 @@ export function AwardShortlist({
|
||||
<td className="px-3 py-2">
|
||||
<div>
|
||||
<p className={`font-medium ${isTop5 ? 'text-amber-900' : ''}`}>
|
||||
<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 className="text-xs text-muted-foreground">
|
||||
{[e.project.teamName, e.project.country, e.project.competitionCategory].filter(Boolean).join(', ') || '—'}
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
Plus,
|
||||
Search,
|
||||
ExternalLink,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
@@ -136,6 +137,14 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
||||
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) => {
|
||||
transitionMutation.mutate({ projectId, roundId, newState })
|
||||
}
|
||||
@@ -165,10 +174,17 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
||||
}
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
result = result.filter((ps: any) =>
|
||||
(ps.project?.title || '').toLowerCase().includes(q) ||
|
||||
(ps.project?.teamName || '').toLowerCase().includes(q)
|
||||
result = result.filter((ps: any) => {
|
||||
const p = ps.project
|
||||
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
|
||||
}, [projectStates, stateFilter, searchQuery])
|
||||
@@ -406,10 +422,20 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<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" />
|
||||
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>
|
||||
<DropdownMenuSeparator />
|
||||
{PROJECT_STATES.filter((s) => s !== ps.state).map((state) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Trophy,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
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 [includeReject, setIncludeReject] = useState(false)
|
||||
|
||||
// ─── Expandable review state ──────────────────────────────────────────────
|
||||
const [expandedReviews, setExpandedReviews] = useState<Set<string>>(new Set())
|
||||
|
||||
// ─── Sensors ──────────────────────────────────────────────────────────────
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
@@ -220,6 +224,8 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
{ enabled: !!selectedProjectId },
|
||||
)
|
||||
|
||||
const { data: roundData } = trpc.round.getById.useQuery({ id: roundId })
|
||||
|
||||
// ─── tRPC mutations ───────────────────────────────────────────────────────
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
@@ -261,6 +267,19 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
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) ──────────────────────────────────────────────
|
||||
const rankingMap = useMemo(() => {
|
||||
const map = new Map<string, RankedProjectEntry>()
|
||||
@@ -298,6 +317,14 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
}
|
||||
}, [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 ────────────────────────────────────────────────────────
|
||||
function handleDragEnd(category: 'STARTUP' | 'BUSINESS_CONCEPT', event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
@@ -448,6 +475,11 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -467,14 +499,21 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
<div className="space-y-2">
|
||||
{localOrder[category].map((projectId, index) => (
|
||||
{localOrder[category].map((projectId, index) => {
|
||||
const advanceCount = category === 'STARTUP'
|
||||
? (evalConfig?.startupAdvanceCount ?? 0)
|
||||
: (evalConfig?.conceptAdvanceCount ?? 0)
|
||||
const isAdvancing = advanceCount > 0 && index < advanceCount
|
||||
const isCutoffRow = advanceCount > 0 && index === advanceCount - 1
|
||||
|
||||
return (
|
||||
<React.Fragment key={projectId}>
|
||||
<motion.div
|
||||
key={projectId}
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className={isAdvancing ? 'rounded-lg bg-emerald-50 dark:bg-emerald-950/20' : ''}
|
||||
>
|
||||
<SortableProjectRow
|
||||
projectId={projectId}
|
||||
@@ -485,7 +524,18 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
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>
|
||||
</AnimatePresence>
|
||||
</SortableContext>
|
||||
@@ -617,6 +667,17 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
<SheetDescription>
|
||||
{selectedProjectId ? `ID: …${selectedProjectId.slice(-8)}` : ''}
|
||||
</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>
|
||||
|
||||
{detailLoading ? (
|
||||
@@ -669,37 +730,40 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{submitted.map((a) => (
|
||||
<div key={a.id} className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium truncate">{a.user.name ?? a.user.email}</p>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{a.evaluation?.globalScore !== null && a.evaluation?.globalScore !== undefined && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Score: {a.evaluation.globalScore.toFixed(1)}
|
||||
</Badge>
|
||||
)}
|
||||
{a.evaluation?.binaryDecision !== null && a.evaluation?.binaryDecision !== undefined && (
|
||||
{submitted.map((a) => {
|
||||
const isExpanded = expandedReviews.has(a.id)
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className="rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => setExpandedReviews(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(a.id) ? next.delete(a.id) : next.add(a.id)
|
||||
return next
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">{a.user?.name ?? a.user?.email ?? 'Unknown'}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{a.evaluation?.binaryDecision != null && (
|
||||
<Badge
|
||||
className={cn(
|
||||
'text-xs',
|
||||
a.evaluation.binaryDecision
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-100'
|
||||
: 'bg-red-100 text-red-700 hover:bg-red-100',
|
||||
)}
|
||||
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>
|
||||
)}
|
||||
<Badge variant="outline">Score: {a.evaluation?.globalScore?.toFixed(1) ?? '—'}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{a.evaluation?.feedbackText && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{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>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -123,12 +123,16 @@ export const projectPoolRouter = router({
|
||||
where.competitionCategory = competitionCategory
|
||||
}
|
||||
|
||||
// Search in title, teamName, description
|
||||
// Search in title, teamName, description, institution, country, geographicZone, team member names
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ teamName: { 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' } } } } },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -484,6 +484,7 @@ export const projectRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
roundId: z.string().optional(),
|
||||
title: z.string().min(1).max(500),
|
||||
teamName: 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
|
||||
const inviteList: { userId: string; email: string; name: string }[] = []
|
||||
if (teamMembersInput && teamMembersInput.length > 0) {
|
||||
@@ -673,6 +688,17 @@ export const projectRouter = router({
|
||||
teamName: z.string().optional().nullable(),
|
||||
description: z.string().optional().nullable(),
|
||||
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
|
||||
.enum([
|
||||
'SUBMITTED',
|
||||
@@ -688,7 +714,7 @@ export const projectRouter = router({
|
||||
})
|
||||
)
|
||||
.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
|
||||
const normalizedCountry = country !== undefined
|
||||
@@ -717,6 +743,7 @@ export const projectRouter = router({
|
||||
...data,
|
||||
...(status && { status }),
|
||||
...(normalizedCountry !== undefined && { country: normalizedCountry }),
|
||||
...(foundedAt !== undefined && { foundedAt: foundedAt ? new Date(foundedAt) : null }),
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -861,4 +861,26 @@ export const roundRouter = router({
|
||||
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
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user