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
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:
@@ -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>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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} — {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} — {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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user