Compare commits
6 Commits
f200eda692
...
68aa393559
| Author | SHA1 | Date | |
|---|---|---|---|
| 68aa393559 | |||
| 9b3a9f6cbf | |||
| dd004baf79 | |||
| 2f1136646e | |||
| 36560a1837 | |||
| 25e06e11e4 |
@@ -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,6 +724,33 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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 ? (
|
{files && files.length > 0 ? (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' : ''}`}>
|
||||||
|
<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}
|
{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(', ') || '—'}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,14 +499,21 @@ 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) => {
|
||||||
|
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
|
<motion.div
|
||||||
key={projectId}
|
|
||||||
layout
|
layout
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0, y: -20 }}
|
||||||
transition={{ duration: 0.15 }}
|
className={isAdvancing ? 'rounded-lg bg-emerald-50 dark:bg-emerald-950/20' : ''}
|
||||||
>
|
>
|
||||||
<SortableProjectRow
|
<SortableProjectRow
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
@@ -485,7 +524,18 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
isSelected={selectedProjectId === projectId}
|
isSelected={selectedProjectId === projectId}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</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 && (
|
})}
|
||||||
|
>
|
||||||
|
<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
|
<Badge
|
||||||
className={cn(
|
variant={a.evaluation.binaryDecision ? 'default' : 'destructive'}
|
||||||
'text-xs',
|
className={a.evaluation.binaryDecision ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-100' : ''}
|
||||||
a.evaluation.binaryDecision
|
|
||||||
? 'bg-green-100 text-green-700 hover:bg-green-100'
|
|
||||||
: 'bg-red-100 text-red-700 hover:bg-red-100',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{a.evaluation.binaryDecision ? 'Yes' : 'No'}
|
{a.evaluation.binaryDecision ? 'Yes' : 'No'}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
<Badge variant="outline">Score: {a.evaluation?.globalScore?.toFixed(1) ?? '—'}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{a.evaluation?.feedbackText && (
|
{isExpanded && a.evaluation?.feedbackText && (
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
<p className="mt-2 text-sm text-muted-foreground whitespace-pre-wrap border-t pt-2">
|
||||||
{a.evaluation.feedbackText}
|
{a.evaluation.feedbackText}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -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' } } } } },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user