AI category-aware evaluation: per-round config, file parsing, shortlist, advance flow
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

- Per-juror cap mode (HARD/SOFT/NONE) in add-member dialog and members table
- Jury invite flow: create user + add to group + send invitation from dialog
- Per-round config: notifyOnAdvance, aiParseFiles, startupAdvanceCount, conceptAdvanceCount
- Moved notify-on-advance from competition-level to per-round setting
- AI filtering: round-tagged files with newest-first sorting, optional file content extraction
- File content extractor service (pdf-parse for PDF, utf-8 for text files)
- AI shortlist runs independently per category (STARTUP / BUSINESS_CONCEPT)
- generateAIRecommendations tRPC endpoint with per-round config integration
- AI recommendations UI: trigger button, confirmation dialog, per-category results display
- Category-aware advance dialog: select/deselect projects by category with target caps
- STAGE_ACTIVE bug fix in assignment router

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 10:09:52 +01:00
parent 93f4ad4b31
commit 80c9e35971
21 changed files with 1886 additions and 1381 deletions

View File

@@ -366,7 +366,7 @@ export default function CompetitionDetailPage() {
return (
<Link
key={round.id}
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
href={`/admin/rounds/${round.id}` as Route}
>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardContent className="pt-4 pb-3 space-y-3">
@@ -510,9 +510,6 @@ export default function CompetitionDetailPage() {
<div>
<label className="text-sm font-medium text-muted-foreground">Notifications</label>
<div className="flex flex-wrap gap-2 mt-1">
{competition.notifyOnRoundAdvance && (
<Badge variant="secondary" className="text-[10px]">Round Advance</Badge>
)}
{competition.notifyOnDeadlineApproach && (
<Badge variant="secondary" className="text-[10px]">Deadline Approach</Badge>
)}

View File

@@ -1,6 +1,7 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import {
Card,
@@ -473,7 +474,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div>
<div>
<p className="text-sm font-medium">Rounds</p>
<p className="text-xs text-muted-foreground">Manage competition rounds</p>
<p className="text-xs text-muted-foreground">Manage rounds</p>
</div>
</Link>
<Link href="/admin/projects/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-emerald-500/30 hover:bg-emerald-500/5">
@@ -513,7 +514,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
Rounds
</CardTitle>
<CardDescription>
Competition rounds in {edition.name}
Active rounds in {edition.name}
</CardDescription>
</div>
<Link
@@ -541,8 +542,9 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
) : (
<div className="space-y-3">
{roundsWithEvalStats.map((round: typeof roundsWithEvalStats[number]) => (
<div
<Link
key={round.id}
href={`/admin/rounds/${round.id}` as Route}
className="block"
>
<div className="rounded-lg border p-4 transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
@@ -569,7 +571,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<Progress value={round.evalPercent} className="mt-3 h-1.5" gradient />
)}
</div>
</div>
</Link>
))}
</div>
)}

View File

@@ -60,7 +60,7 @@ type UploadState = {
type UploadMap = Record<string, UploadState>
export default function BulkUploadPage() {
const [windowId, setWindowId] = useState('')
const [roundId, setRoundId] = useState('')
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'missing' | 'complete'>('all')
@@ -96,20 +96,20 @@ export default function BulkUploadPage() {
}, [])
// Queries
const { data: windows, isLoading: windowsLoading } = trpc.file.listSubmissionWindows.useQuery()
const { data: rounds, isLoading: roundsLoading } = trpc.file.listRoundsForBulkUpload.useQuery()
const { data, isLoading, refetch } = trpc.file.listProjectsWithUploadStatus.useQuery(
const { data, isLoading, refetch } = trpc.file.listProjectsByRoundRequirements.useQuery(
{
submissionWindowId: windowId,
roundId,
search: debouncedSearch || undefined,
status: statusFilter,
page,
pageSize: perPage,
},
{ enabled: !!windowId }
{ enabled: !!roundId }
)
const uploadMutation = trpc.file.adminUploadForRequirement.useMutation()
const uploadMutation = trpc.file.adminUploadForRoundRequirement.useMutation()
// Upload a single file for a project requirement
const uploadFileForRequirement = useCallback(
@@ -117,7 +117,7 @@ export default function BulkUploadPage() {
projectId: string,
requirementId: string,
file: File,
submissionWindowId: string
targetRoundId: string
) => {
const key = `${projectId}:${requirementId}`
setUploads((prev) => ({
@@ -131,8 +131,8 @@ export default function BulkUploadPage() {
fileName: file.name,
mimeType: file.type || 'application/octet-stream',
size: file.size,
submissionWindowId,
submissionFileRequirementId: requirementId,
roundId: targetRoundId,
requirementId,
})
// XHR upload with progress
@@ -186,18 +186,18 @@ export default function BulkUploadPage() {
}
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file && windowId) {
uploadFileForRequirement(projectId, requirementId, file, windowId)
if (file && roundId) {
uploadFileForRequirement(projectId, requirementId, file, roundId)
}
}
input.click()
},
[windowId, uploadFileForRequirement]
[roundId, uploadFileForRequirement]
)
// Handle bulk row upload
const handleBulkUploadAll = useCallback(async () => {
if (!bulkProject || !windowId) return
if (!bulkProject || !roundId) return
const entries = Object.entries(bulkFiles).filter(
([, file]) => file !== null
@@ -211,14 +211,14 @@ export default function BulkUploadPage() {
// Upload all in parallel
await Promise.allSettled(
entries.map(([reqId, file]) =>
uploadFileForRequirement(bulkProject.id, reqId, file, windowId)
uploadFileForRequirement(bulkProject.id, reqId, file, roundId)
)
)
setBulkProject(null)
setBulkFiles({})
toast.success('Bulk upload complete')
}, [bulkProject, bulkFiles, windowId, uploadFileForRequirement])
}, [bulkProject, bulkFiles, roundId, uploadFileForRequirement])
const progressPercent =
data && data.totalProjects > 0
@@ -242,32 +242,37 @@ export default function BulkUploadPage() {
</div>
</div>
{/* Window Selector */}
{/* Round Selector */}
<Card>
<CardHeader>
<CardTitle className="text-base">Submission Window</CardTitle>
<CardTitle className="text-base">Round</CardTitle>
</CardHeader>
<CardContent>
{windowsLoading ? (
{roundsLoading ? (
<Skeleton className="h-10 w-full" />
) : !rounds || rounds.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle className="h-4 w-4" />
<span>No rounds have file requirements configured. Add file requirements to a round first.</span>
</div>
) : (
<Select
value={windowId}
value={roundId}
onValueChange={(v) => {
setWindowId(v)
setRoundId(v)
setPage(1)
setUploads({})
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a submission window..." />
<SelectValue placeholder="Select a round..." />
</SelectTrigger>
<SelectContent>
{windows?.map((w) => (
<SelectItem key={w.id} value={w.id}>
{w.competition.program.name} {w.competition.program.year} &mdash; {w.name}{' '}
({w.fileRequirements.length} requirement
{w.fileRequirements.length !== 1 ? 's' : ''})
{rounds.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.competition.program.name} {r.competition.program.year} &mdash; {r.name}{' '}
({r.fileRequirements.length} requirement
{r.fileRequirements.length !== 1 ? 's' : ''})
</SelectItem>
))}
</SelectContent>
@@ -276,8 +281,8 @@ export default function BulkUploadPage() {
</CardContent>
</Card>
{/* Content (only if window selected) */}
{windowId && data && (
{/* Content (only if round selected) */}
{roundId && data && (
<>
{/* Progress Summary */}
<Card>

View File

@@ -92,7 +92,7 @@ function ImportPageContent() {
Create a competition with rounds before importing projects
</p>
<Button asChild className="mt-4">
<Link href="/admin/competitions">View Competitions</Link>
<Link href="/admin/rounds">View Rounds</Link>
</Button>
</div>
) : (

View File

@@ -12,6 +12,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
@@ -147,6 +148,11 @@ export default function RoundDetailPage() {
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false)
const [aiRecommendations, setAiRecommendations] = useState<{
STARTUP: Array<{ projectId: string; rank: number; score: number; category: string; strengths: string[]; concerns: string[]; recommendation: string }>
BUSINESS_CONCEPT: Array<{ projectId: string; rank: number; score: number; category: string; strengths: string[]; concerns: string[]; recommendation: string }>
} | null>(null)
const [shortlistDialogOpen, setShortlistDialogOpen] = useState(false)
const utils = trpc.useUtils()
@@ -243,6 +249,25 @@ export default function RoundDetailPage() {
onError: (err) => toast.error(err.message),
})
const shortlistMutation = trpc.round.generateAIRecommendations.useMutation({
onSuccess: (data) => {
if (data.success) {
setAiRecommendations(data.recommendations)
toast.success(
`AI recommendations generated: ${data.recommendations.STARTUP.length} startups, ${data.recommendations.BUSINESS_CONCEPT.length} concepts` +
(data.tokensUsed ? ` (${data.tokensUsed} tokens)` : ''),
)
} else {
toast.error(data.errors?.join('; ') || 'AI shortlist failed')
}
setShortlistDialogOpen(false)
},
onError: (err) => {
toast.error(err.message)
setShortlistDialogOpen(false)
},
})
const isTransitioning = activateMutation.isPending || closeMutation.isPending || archiveMutation.isPending
const handleConfigChange = useCallback((newConfig: Record<string, unknown>) => {
@@ -828,6 +853,25 @@ export default function RoundDetailPage() {
</div>
</button>
{/* AI Shortlist Recommendations */}
{(isEvaluation || isFiltering) && projectCount > 0 && (
<button
onClick={() => setShortlistDialogOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left border-purple-200 bg-purple-50/50"
disabled={shortlistMutation.isPending}
>
<BarChart3 className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">
{shortlistMutation.isPending ? 'Generating...' : 'AI Recommendations'}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Generate ranked shortlist per category using AI analysis
</p>
</div>
</button>
)}
{/* Advance projects (shown when PASSED > 0) */}
{passedCount > 0 && (
<button
@@ -847,29 +891,62 @@ export default function RoundDetailPage() {
</CardContent>
</Card>
{/* Advance Projects Confirmation Dialog */}
<AlertDialog open={advanceDialogOpen} onOpenChange={setAdvanceDialogOpen}>
{/* Advance Projects Dialog */}
<AdvanceProjectsDialog
open={advanceDialogOpen}
onOpenChange={setAdvanceDialogOpen}
roundId={roundId}
projectStates={projectStates}
config={config}
advanceMutation={advanceMutation}
/>
{/* AI Shortlist Confirmation Dialog */}
<AlertDialog open={shortlistDialogOpen} onOpenChange={setShortlistDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Advance {passedCount} project(s)?</AlertDialogTitle>
<AlertDialogTitle>Generate AI Recommendations?</AlertDialogTitle>
<AlertDialogDescription>
All projects with PASSED status in this round will be moved to the next round.
This action creates new entries in the next round and marks current entries as completed.
The AI will analyze all project evaluations and generate a ranked shortlist
for each category independently.
{config.startupAdvanceCount ? (
<span className="block mt-1">
Startup target: top {String(config.startupAdvanceCount)}
</span>
) : null}
{config.conceptAdvanceCount ? (
<span className="block">
Business Concept target: top {String(config.conceptAdvanceCount)}
</span>
) : null}
{config.aiParseFiles ? (
<span className="block mt-1 text-amber-600">
Document parsing is enabled the AI will read uploaded file contents.
</span>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => advanceMutation.mutate({ roundId })}
disabled={advanceMutation.isPending}
onClick={() => shortlistMutation.mutate({ roundId })}
disabled={shortlistMutation.isPending}
>
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Advance Projects
{shortlistMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Generate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* AI Recommendations Display */}
{aiRecommendations && (
<AIRecommendationsDisplay
recommendations={aiRecommendations}
onClear={() => setAiRecommendations(null)}
/>
)}
{/* Round Info + Project Breakdown */}
<div className="grid gap-4 sm:grid-cols-2">
<Card>
@@ -1054,7 +1131,7 @@ export default function RoundDetailPage() {
<CardTitle className="text-base">General Settings</CardTitle>
<CardDescription>Settings that apply to this round regardless of type</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-5">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-on-entry" className="text-sm font-medium">
@@ -1072,6 +1149,85 @@ export default function RoundDetailPage() {
}}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-on-advance" className="text-sm font-medium">
Notify on advance
</Label>
<p className="text-xs text-muted-foreground">
Send an email to project applicants when their project advances from this round to the next
</p>
</div>
<Switch
id="notify-on-advance"
checked={!!config.notifyOnAdvance}
onCheckedChange={(checked) => {
handleConfigChange({ ...config, notifyOnAdvance: checked })
}}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="ai-parse-files" className="text-sm font-medium">
AI document parsing
</Label>
<p className="text-xs text-muted-foreground">
Allow AI to read the contents of uploaded files (PDF/text) for deeper analysis during filtering and evaluation
</p>
</div>
<Switch
id="ai-parse-files"
checked={!!config.aiParseFiles}
onCheckedChange={(checked) => {
handleConfigChange({ ...config, aiParseFiles: checked })
}}
/>
</div>
<div className="border-t pt-4">
<Label className="text-sm font-medium">Advancement Targets</Label>
<p className="text-xs text-muted-foreground mb-3">
Target number of projects per category to advance from this round
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="startup-advance-count" className="text-xs text-muted-foreground">
Startup Projects
</Label>
<Input
id="startup-advance-count"
type="number"
min={0}
className="h-9"
placeholder="No limit"
value={(config.startupAdvanceCount as number) ?? ''}
onChange={(e) => {
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
handleConfigChange({ ...config, startupAdvanceCount: val })
}}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="concept-advance-count" className="text-xs text-muted-foreground">
Concept Projects
</Label>
<Input
id="concept-advance-count"
type="number"
min={0}
className="h-9"
placeholder="No limit"
value={(config.conceptAdvanceCount as number) ?? ''}
onChange={(e) => {
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
handleConfigChange({ ...config, conceptAdvanceCount: val })
}}
/>
</div>
</div>
</div>
</CardContent>
</Card>
@@ -1585,6 +1741,304 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
// ── Evaluation Criteria Editor ───────────────────────────────────────────
// ── Advance Projects Dialog ─────────────────────────────────────────────
function AdvanceProjectsDialog({
open,
onOpenChange,
roundId,
projectStates,
config,
advanceMutation,
}: {
open: boolean
onOpenChange: (open: boolean) => void
roundId: string
projectStates: any[] | undefined
config: Record<string, unknown>
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[] }) => void; isPending: boolean }
}) {
const passedProjects = useMemo(() =>
(projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'),
[projectStates])
const startups = useMemo(() =>
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'),
[passedProjects])
const concepts = useMemo(() =>
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'BUSINESS_CONCEPT'),
[passedProjects])
const other = useMemo(() =>
passedProjects.filter((ps: any) =>
ps.project?.competitionCategory !== 'STARTUP' && ps.project?.competitionCategory !== 'BUSINESS_CONCEPT',
),
[passedProjects])
const startupCap = (config.startupAdvanceCount as number) || 0
const conceptCap = (config.conceptAdvanceCount as number) || 0
const [selected, setSelected] = useState<Set<string>>(new Set())
// Reset selection when dialog opens
if (open && selected.size === 0 && passedProjects.length > 0) {
const initial = new Set<string>()
// Auto-select all (or up to cap if configured)
const startupSlice = startupCap > 0 ? startups.slice(0, startupCap) : startups
const conceptSlice = conceptCap > 0 ? concepts.slice(0, conceptCap) : concepts
for (const ps of startupSlice) initial.add(ps.project?.id)
for (const ps of conceptSlice) initial.add(ps.project?.id)
for (const ps of other) initial.add(ps.project?.id)
setSelected(initial)
}
const toggleProject = (projectId: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(projectId)) next.delete(projectId)
else next.add(projectId)
return next
})
}
const toggleAll = (projects: any[], on: boolean) => {
setSelected((prev) => {
const next = new Set(prev)
for (const ps of projects) {
if (on) next.add(ps.project?.id)
else next.delete(ps.project?.id)
}
return next
})
}
const handleAdvance = () => {
const ids = Array.from(selected)
if (ids.length === 0) return
advanceMutation.mutate({ roundId, projectIds: ids })
onOpenChange(false)
setSelected(new Set())
}
const handleClose = () => {
onOpenChange(false)
setSelected(new Set())
}
const renderCategorySection = (
label: string,
projects: any[],
cap: number,
badgeColor: string,
) => {
const selectedInCategory = projects.filter((ps: any) => selected.has(ps.project?.id)).length
const overCap = cap > 0 && selectedInCategory > cap
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={projects.length > 0 && projects.every((ps: any) => selected.has(ps.project?.id))}
onCheckedChange={(checked) => toggleAll(projects, !!checked)}
/>
<span className="text-sm font-medium">{label}</span>
<Badge variant="secondary" className={cn('text-[10px]', badgeColor)}>
{selectedInCategory}/{projects.length}
</Badge>
{cap > 0 && (
<span className={cn('text-[10px]', overCap ? 'text-red-500 font-medium' : 'text-muted-foreground')}>
(target: {cap})
</span>
)}
</div>
</div>
{projects.length === 0 ? (
<p className="text-xs text-muted-foreground pl-7">No passed projects in this category</p>
) : (
<div className="space-y-1 pl-7">
{projects.map((ps: any) => (
<label
key={ps.project?.id}
className="flex items-center gap-2 p-2 rounded hover:bg-muted/30 cursor-pointer"
>
<Checkbox
checked={selected.has(ps.project?.id)}
onCheckedChange={() => toggleProject(ps.project?.id)}
/>
<span className="text-sm truncate flex-1">{ps.project?.title || 'Untitled'}</span>
{ps.project?.teamName && (
<span className="text-xs text-muted-foreground shrink-0">{ps.project.teamName}</span>
)}
</label>
))}
</div>
)}
</div>
)
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Advance Projects</DialogTitle>
<DialogDescription>
Select which passed projects to advance to the next round.
{selected.size} of {passedProjects.length} selected.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 py-2">
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')}
{other.length > 0 && renderCategorySection('Other / Uncategorized', other, 0, 'bg-gray-100 text-gray-700')}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>Cancel</Button>
<Button
onClick={handleAdvance}
disabled={selected.size === 0 || advanceMutation.isPending}
>
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ── AI Recommendations Display ──────────────────────────────────────────
type RecommendationItem = {
projectId: string
rank: number
score: number
category: string
strengths: string[]
concerns: string[]
recommendation: string
}
function AIRecommendationsDisplay({
recommendations,
onClear,
}: {
recommendations: { STARTUP: RecommendationItem[]; BUSINESS_CONCEPT: RecommendationItem[] }
onClear: () => void
}) {
const [expandedId, setExpandedId] = useState<string | null>(null)
const renderCategory = (label: string, items: RecommendationItem[], colorClass: string) => {
if (items.length === 0) return (
<div className="text-center py-4 text-muted-foreground text-sm">
No {label.toLowerCase()} projects evaluated
</div>
)
return (
<div className="space-y-2">
{items.map((item) => {
const isExpanded = expandedId === `${item.category}-${item.projectId}`
return (
<div
key={item.projectId}
className="border rounded-lg overflow-hidden"
>
<button
onClick={() => setExpandedId(isExpanded ? null : `${item.category}-${item.projectId}`)}
className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/30 transition-colors"
>
<span className={cn(
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0',
colorClass,
)}>
{item.rank}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.projectId}</p>
<p className="text-xs text-muted-foreground truncate">{item.recommendation}</p>
</div>
<Badge variant="outline" className="shrink-0 text-xs font-mono">
{item.score}/100
</Badge>
<ChevronDown className={cn(
'h-4 w-4 text-muted-foreground transition-transform shrink-0',
isExpanded && 'rotate-180',
)} />
</button>
{isExpanded && (
<div className="px-3 pb-3 pt-0 space-y-2 border-t bg-muted/10">
<div className="pt-2">
<p className="text-xs font-medium text-emerald-700 mb-1">Strengths</p>
<ul className="text-xs text-muted-foreground space-y-0.5 pl-4 list-disc">
{item.strengths.map((s, i) => <li key={i}>{s}</li>)}
</ul>
</div>
{item.concerns.length > 0 && (
<div>
<p className="text-xs font-medium text-amber-700 mb-1">Concerns</p>
<ul className="text-xs text-muted-foreground space-y-0.5 pl-4 list-disc">
{item.concerns.map((c, i) => <li key={i}>{c}</li>)}
</ul>
</div>
)}
<div>
<p className="text-xs font-medium text-blue-700 mb-1">Recommendation</p>
<p className="text-xs text-muted-foreground">{item.recommendation}</p>
</div>
</div>
)}
</div>
)
})}
</div>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">AI Shortlist Recommendations</CardTitle>
<CardDescription>
Ranked independently per category {recommendations.STARTUP.length} startups, {recommendations.BUSINESS_CONCEPT.length} concepts
</CardDescription>
</div>
<Button variant="ghost" size="sm" onClick={onClear}>
<X className="h-4 w-4 mr-1" />
Dismiss
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-6 lg:grid-cols-2">
<div>
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-blue-500" />
Startup ({recommendations.STARTUP.length})
</h4>
{renderCategory('Startup', recommendations.STARTUP, 'bg-blue-500')}
</div>
<div>
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-purple-500" />
Business Concept ({recommendations.BUSINESS_CONCEPT.length})
</h4>
{renderCategory('Business Concept', recommendations.BUSINESS_CONCEPT, 'bg-purple-500')}
</div>
</div>
</CardContent>
</Card>
)
}
// ── Evaluation Criteria Editor ───────────────────────────────────────────
function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
const [editing, setEditing] = useState(false)
const [criteria, setCriteria] = useState<Array<{

View File

@@ -169,7 +169,6 @@ export default function RoundsPage() {
categoryMode: comp.categoryMode,
startupFinalistCount: comp.startupFinalistCount,
conceptFinalistCount: comp.conceptFinalistCount,
notifyOnRoundAdvance: comp.notifyOnRoundAdvance,
notifyOnDeadlineApproach: comp.notifyOnDeadlineApproach,
})
}
@@ -492,13 +491,6 @@ function CompetitionGroup({
onChange={(e) => onEditChange({ ...competitionEdits, conceptFinalistCount: parseInt(e.target.value, 10) || 10 })}
/>
</div>
<div className="flex items-center gap-3 pt-6">
<Switch
checked={(competitionEdits.notifyOnRoundAdvance as boolean) ?? false}
onCheckedChange={(v) => onEditChange({ ...competitionEdits, notifyOnRoundAdvance: v })}
/>
<Label className="text-xs font-medium">Notify on Advance</Label>
</div>
<div className="flex items-center gap-3 pt-6">
<Switch
checked={(competitionEdits.notifyOnDeadlineApproach as boolean) ?? false}