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

@@ -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>
) : (