Compare commits
4 Commits
7b98b64c1c
...
771f35c695
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
771f35c695 | ||
|
|
fbeec846a3 | ||
|
|
cfeef9a601 | ||
|
|
fcee8761b9 |
@@ -326,7 +326,10 @@ export default function RoundDetailPage() {
|
|||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
utils.round.getById.invalidate({ id: roundId })
|
utils.round.getById.invalidate({ id: roundId })
|
||||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
toast.success(`Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`)
|
const msg = data.autoPassedCount
|
||||||
|
? `Passed ${data.autoPassedCount} and advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`
|
||||||
|
: `Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`
|
||||||
|
toast.success(msg)
|
||||||
setAdvanceDialogOpen(false)
|
setAdvanceDialogOpen(false)
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
@@ -378,6 +381,7 @@ export default function RoundDetailPage() {
|
|||||||
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 = hasJury
|
||||||
|
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
|
||||||
|
|
||||||
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
|
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
|
||||||
|
|
||||||
@@ -582,7 +586,7 @@ export default function RoundDetailPage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* ===== STATS BAR — Accent-bordered cards ===== */}
|
{/* ===== STATS BAR — Accent-bordered cards ===== */}
|
||||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
|
<div className={cn("grid gap-3 grid-cols-2", hasJury ? "sm:grid-cols-4" : "sm:grid-cols-3")}>
|
||||||
{/* Projects */}
|
{/* Projects */}
|
||||||
<AnimatedCard index={0}>
|
<AnimatedCard index={0}>
|
||||||
<Card className="border-l-4 border-l-[#557f8c] hover:shadow-md transition-shadow">
|
<Card className="border-l-4 border-l-[#557f8c] hover:shadow-md transition-shadow">
|
||||||
@@ -605,56 +609,58 @@ export default function RoundDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
{/* Jury (with inline group selector) */}
|
{/* Jury (with inline group selector) — only for jury-relevant rounds */}
|
||||||
<AnimatedCard index={1}>
|
{hasJury && (
|
||||||
<Card className="border-l-4 border-l-purple-500 hover:shadow-md transition-shadow">
|
<AnimatedCard index={1}>
|
||||||
<CardContent className="pt-4 pb-3">
|
<Card className="border-l-4 border-l-purple-500 hover:shadow-md transition-shadow">
|
||||||
<div className="flex items-center gap-2.5 mb-1" data-jury-select>
|
<CardContent className="pt-4 pb-3">
|
||||||
<div className="rounded-full bg-purple-50 p-1.5">
|
<div className="flex items-center gap-2.5 mb-1" data-jury-select>
|
||||||
<Users className="h-4 w-4 text-purple-500" />
|
<div className="rounded-full bg-purple-50 p-1.5">
|
||||||
|
<Users className="h-4 w-4 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Jury</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-muted-foreground">Jury</span>
|
{juryGroups && juryGroups.length > 0 ? (
|
||||||
</div>
|
<Select
|
||||||
{juryGroups && juryGroups.length > 0 ? (
|
value={round.juryGroupId ?? '__none__'}
|
||||||
<Select
|
onValueChange={(value) => {
|
||||||
value={round.juryGroupId ?? '__none__'}
|
assignJuryMutation.mutate({
|
||||||
onValueChange={(value) => {
|
id: roundId,
|
||||||
assignJuryMutation.mutate({
|
juryGroupId: value === '__none__' ? null : value,
|
||||||
id: roundId,
|
})
|
||||||
juryGroupId: value === '__none__' ? null : value,
|
}}
|
||||||
})
|
disabled={assignJuryMutation.isPending}
|
||||||
}}
|
>
|
||||||
disabled={assignJuryMutation.isPending}
|
<SelectTrigger className="h-8 text-xs mt-1">
|
||||||
>
|
<SelectValue placeholder="Select jury group..." />
|
||||||
<SelectTrigger className="h-8 text-xs mt-1">
|
</SelectTrigger>
|
||||||
<SelectValue placeholder="Select jury group..." />
|
<SelectContent>
|
||||||
</SelectTrigger>
|
<SelectItem value="__none__">No jury assigned</SelectItem>
|
||||||
<SelectContent>
|
{juryGroups.map((jg: any) => (
|
||||||
<SelectItem value="__none__">No jury assigned</SelectItem>
|
<SelectItem key={jg.id} value={jg.id}>
|
||||||
{juryGroups.map((jg: any) => (
|
{jg.name} ({jg._count?.members ?? 0} members)
|
||||||
<SelectItem key={jg.id} value={jg.id}>
|
</SelectItem>
|
||||||
{jg.name} ({jg._count?.members ?? 0} members)
|
))}
|
||||||
</SelectItem>
|
</SelectContent>
|
||||||
))}
|
</Select>
|
||||||
</SelectContent>
|
) : juryGroup ? (
|
||||||
</Select>
|
<>
|
||||||
) : juryGroup ? (
|
<p className="text-3xl font-bold mt-2">{juryMemberCount}</p>
|
||||||
<>
|
<p className="text-xs text-muted-foreground truncate">{juryGroup.name}</p>
|
||||||
<p className="text-3xl font-bold mt-2">{juryMemberCount}</p>
|
</>
|
||||||
<p className="text-xs text-muted-foreground truncate">{juryGroup.name}</p>
|
) : (
|
||||||
</>
|
<>
|
||||||
) : (
|
<p className="text-3xl font-bold mt-2 text-muted-foreground">—</p>
|
||||||
<>
|
<p className="text-xs text-muted-foreground">No jury groups yet</p>
|
||||||
<p className="text-3xl font-bold mt-2 text-muted-foreground">—</p>
|
</>
|
||||||
<p className="text-xs text-muted-foreground">No jury groups yet</p>
|
)}
|
||||||
</>
|
</CardContent>
|
||||||
)}
|
</Card>
|
||||||
</CardContent>
|
</AnimatedCard>
|
||||||
</Card>
|
)}
|
||||||
</AnimatedCard>
|
|
||||||
|
|
||||||
{/* Window */}
|
{/* Window */}
|
||||||
<AnimatedCard index={2}>
|
<AnimatedCard index={hasJury ? 2 : 1}>
|
||||||
<Card className="border-l-4 border-l-emerald-500 hover:shadow-md transition-shadow">
|
<Card className="border-l-4 border-l-emerald-500 hover:shadow-md transition-shadow">
|
||||||
<CardContent className="pt-4 pb-3">
|
<CardContent className="pt-4 pb-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -687,7 +693,7 @@ export default function RoundDetailPage() {
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
{/* Advancement */}
|
{/* Advancement */}
|
||||||
<AnimatedCard index={3}>
|
<AnimatedCard index={hasJury ? 3 : 2}>
|
||||||
<Card className="border-l-4 border-l-amber-500 hover:shadow-md transition-shadow">
|
<Card className="border-l-4 border-l-amber-500 hover:shadow-md transition-shadow">
|
||||||
<CardContent className="pt-4 pb-3">
|
<CardContent className="pt-4 pb-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -967,28 +973,28 @@ export default function RoundDetailPage() {
|
|||||||
{/* Advance projects (always visible when projects exist) */}
|
{/* Advance projects (always visible when projects exist) */}
|
||||||
{projectCount > 0 && (
|
{projectCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => passedCount > 0
|
onClick={() => (isSimpleAdvance || passedCount > 0)
|
||||||
? setAdvanceDialogOpen(true)
|
? setAdvanceDialogOpen(true)
|
||||||
: toast.info('Mark projects as "Passed" first in the Projects tab')}
|
: toast.info('Mark projects as "Passed" first in the Projects tab')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left',
|
'flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left',
|
||||||
passedCount > 0
|
(isSimpleAdvance || passedCount > 0)
|
||||||
? 'border-l-4 border-l-emerald-500 bg-emerald-50/30'
|
? 'border-l-4 border-l-emerald-500 bg-emerald-50/30'
|
||||||
: 'border-dashed opacity-60',
|
: 'border-dashed opacity-60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', passedCount > 0 ? 'text-emerald-600' : 'text-muted-foreground')} />
|
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', (isSimpleAdvance || passedCount > 0) ? 'text-emerald-600' : 'text-muted-foreground')} />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Advance Projects</p>
|
<p className="text-sm font-medium">Advance Projects</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{passedCount > 0
|
{isSimpleAdvance
|
||||||
? `Move ${passedCount} passed project(s) to the next round`
|
? `Advance all ${projectCount} project(s) to the next round`
|
||||||
: 'Mark projects as "Passed" first, then advance'}
|
: passedCount > 0
|
||||||
|
? `Move ${passedCount} passed project(s) to the next round`
|
||||||
|
: 'Mark projects as "Passed" first, then advance'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{passedCount > 0 && (
|
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{isSimpleAdvance ? projectCount : passedCount}</Badge>
|
||||||
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{passedCount}</Badge>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1096,6 +1102,7 @@ export default function RoundDetailPage() {
|
|||||||
open={advanceDialogOpen}
|
open={advanceDialogOpen}
|
||||||
onOpenChange={setAdvanceDialogOpen}
|
onOpenChange={setAdvanceDialogOpen}
|
||||||
roundId={roundId}
|
roundId={roundId}
|
||||||
|
roundType={round?.roundType}
|
||||||
projectStates={projectStates}
|
projectStates={projectStates}
|
||||||
config={config}
|
config={config}
|
||||||
advanceMutation={advanceMutation}
|
advanceMutation={advanceMutation}
|
||||||
@@ -2500,6 +2507,7 @@ function AdvanceProjectsDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
roundId,
|
roundId,
|
||||||
|
roundType,
|
||||||
projectStates,
|
projectStates,
|
||||||
config,
|
config,
|
||||||
advanceMutation,
|
advanceMutation,
|
||||||
@@ -2509,12 +2517,15 @@ function AdvanceProjectsDialog({
|
|||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
roundId: string
|
roundId: string
|
||||||
|
roundType?: string
|
||||||
projectStates: any[] | undefined
|
projectStates: any[] | undefined
|
||||||
config: Record<string, unknown>
|
config: Record<string, unknown>
|
||||||
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string }) => void; isPending: boolean }
|
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string; autoPassPending?: boolean }) => void; isPending: boolean }
|
||||||
competitionRounds?: Array<{ id: string; name: string; sortOrder: number; roundType: string }>
|
competitionRounds?: Array<{ id: string; name: string; sortOrder: number; roundType: string }>
|
||||||
currentSortOrder?: number
|
currentSortOrder?: number
|
||||||
}) {
|
}) {
|
||||||
|
// For non-jury rounds (INTAKE, SUBMISSION, MENTORING), offer a simpler "advance all" flow
|
||||||
|
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(roundType ?? '')
|
||||||
// Target round selector
|
// Target round selector
|
||||||
const availableTargets = useMemo(() =>
|
const availableTargets = useMemo(() =>
|
||||||
(competitionRounds ?? [])
|
(competitionRounds ?? [])
|
||||||
@@ -2528,9 +2539,11 @@ function AdvanceProjectsDialog({
|
|||||||
if (open && !targetRoundId && availableTargets.length > 0) {
|
if (open && !targetRoundId && availableTargets.length > 0) {
|
||||||
setTargetRoundId(availableTargets[0].id)
|
setTargetRoundId(availableTargets[0].id)
|
||||||
}
|
}
|
||||||
|
const allProjects = projectStates ?? []
|
||||||
|
const pendingCount = allProjects.filter((ps: any) => ps.state === 'PENDING').length
|
||||||
const passedProjects = useMemo(() =>
|
const passedProjects = useMemo(() =>
|
||||||
(projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'),
|
allProjects.filter((ps: any) => ps.state === 'PASSED'),
|
||||||
[projectStates])
|
[allProjects])
|
||||||
|
|
||||||
const startups = useMemo(() =>
|
const startups = useMemo(() =>
|
||||||
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'),
|
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'),
|
||||||
@@ -2583,14 +2596,23 @@ function AdvanceProjectsDialog({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAdvance = () => {
|
const handleAdvance = (autoPass?: boolean) => {
|
||||||
const ids = Array.from(selected)
|
if (autoPass) {
|
||||||
if (ids.length === 0) return
|
// Auto-pass all pending then advance all
|
||||||
advanceMutation.mutate({
|
advanceMutation.mutate({
|
||||||
roundId,
|
roundId,
|
||||||
projectIds: ids,
|
autoPassPending: true,
|
||||||
...(targetRoundId ? { targetRoundId } : {}),
|
...(targetRoundId ? { targetRoundId } : {}),
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
const ids = Array.from(selected)
|
||||||
|
if (ids.length === 0) return
|
||||||
|
advanceMutation.mutate({
|
||||||
|
roundId,
|
||||||
|
projectIds: ids,
|
||||||
|
...(targetRoundId ? { targetRoundId } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
setTargetRoundId('')
|
setTargetRoundId('')
|
||||||
@@ -2655,14 +2677,18 @@ function AdvanceProjectsDialog({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalProjectCount = allProjects.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
|
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Advance Projects</DialogTitle>
|
<DialogTitle>Advance Projects</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Select which passed projects to advance.
|
{isSimpleAdvance
|
||||||
{selected.size} of {passedProjects.length} selected.
|
? `Move all ${totalProjectCount} projects to the next round.`
|
||||||
|
: `Select which passed projects to advance. ${selected.size} of ${passedProjects.length} selected.`
|
||||||
|
}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -2690,21 +2716,50 @@ function AdvanceProjectsDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-4 py-2">
|
{isSimpleAdvance ? (
|
||||||
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
|
/* Simple mode for INTAKE/SUBMISSION/MENTORING — no per-project selection needed */
|
||||||
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')}
|
<div className="py-4 space-y-3">
|
||||||
{other.length > 0 && renderCategorySection('Other / Uncategorized', other, 0, 'bg-gray-100 text-gray-700')}
|
<div className="rounded-lg border bg-muted/30 p-4 text-center space-y-1">
|
||||||
</div>
|
<p className="text-3xl font-bold">{totalProjectCount}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">projects will be advanced</p>
|
||||||
|
</div>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2">
|
||||||
|
<p className="text-xs text-blue-700">
|
||||||
|
{pendingCount} pending project{pendingCount !== 1 ? 's' : ''} will be automatically marked as passed and advanced.
|
||||||
|
{passedProjects.length > 0 && ` ${passedProjects.length} already passed.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Detailed mode for jury/evaluation rounds — per-project selection */
|
||||||
|
<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>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={handleClose}>Cancel</Button>
|
<Button variant="outline" onClick={handleClose}>Cancel</Button>
|
||||||
<Button
|
{isSimpleAdvance ? (
|
||||||
onClick={handleAdvance}
|
<Button
|
||||||
disabled={selected.size === 0 || advanceMutation.isPending}
|
onClick={() => handleAdvance(true)}
|
||||||
>
|
disabled={totalProjectCount === 0 || advanceMutation.isPending}
|
||||||
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
>
|
||||||
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
|
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
</Button>
|
Advance All {totalProjectCount} Project{totalProjectCount !== 1 ? 's' : ''}
|
||||||
|
</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>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -74,10 +74,22 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
|||||||
description: true,
|
description: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
teamName: true,
|
teamName: true,
|
||||||
|
projectTags: {
|
||||||
|
select: { tag: { select: { name: true } }, confidence: true },
|
||||||
|
},
|
||||||
_count: { select: { assignments: { where: { roundId } } } },
|
_count: { select: { assignments: { where: { roundId } } } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Enrich projects with tag confidence data for AI matching
|
||||||
|
const projectsWithConfidence = projects.map((p) => ({
|
||||||
|
...p,
|
||||||
|
tagConfidences: p.projectTags.map((pt) => ({
|
||||||
|
name: pt.tag.name,
|
||||||
|
confidence: pt.confidence,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
const existingAssignments = await prisma.assignment.findMany({
|
const existingAssignments = await prisma.assignment.findMany({
|
||||||
where: { roundId },
|
where: { roundId },
|
||||||
select: { userId: true, projectId: true },
|
select: { userId: true, projectId: true },
|
||||||
@@ -124,7 +136,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
|||||||
|
|
||||||
const result = await generateAIAssignments(
|
const result = await generateAIAssignments(
|
||||||
jurors,
|
jurors,
|
||||||
projects,
|
projectsWithConfidence,
|
||||||
constraints,
|
constraints,
|
||||||
userId,
|
userId,
|
||||||
roundId,
|
roundId,
|
||||||
|
|||||||
@@ -243,10 +243,11 @@ export const roundRouter = router({
|
|||||||
roundId: z.string(),
|
roundId: z.string(),
|
||||||
targetRoundId: z.string().optional(),
|
targetRoundId: z.string().optional(),
|
||||||
projectIds: z.array(z.string()).optional(),
|
projectIds: z.array(z.string()).optional(),
|
||||||
|
autoPassPending: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { roundId, targetRoundId, projectIds } = input
|
const { roundId, targetRoundId, projectIds, autoPassPending } = input
|
||||||
|
|
||||||
// Get current round with competition context
|
// Get current round with competition context
|
||||||
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
@@ -280,6 +281,16 @@ export const roundRouter = router({
|
|||||||
targetRound = nextRound
|
targetRound = nextRound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-pass all PENDING projects first (for intake/bulk workflows)
|
||||||
|
let autoPassedCount = 0
|
||||||
|
if (autoPassPending) {
|
||||||
|
const result = await ctx.prisma.projectRoundState.updateMany({
|
||||||
|
where: { roundId, state: 'PENDING' },
|
||||||
|
data: { state: 'PASSED' },
|
||||||
|
})
|
||||||
|
autoPassedCount = result.count
|
||||||
|
}
|
||||||
|
|
||||||
// Determine which projects to advance
|
// Determine which projects to advance
|
||||||
let idsToAdvance: string[]
|
let idsToAdvance: string[]
|
||||||
if (projectIds && projectIds.length > 0) {
|
if (projectIds && projectIds.length > 0) {
|
||||||
@@ -346,6 +357,7 @@ export const roundRouter = router({
|
|||||||
toRound: targetRound.name,
|
toRound: targetRound.name,
|
||||||
targetRoundId: targetRound.id,
|
targetRoundId: targetRound.id,
|
||||||
projectCount: idsToAdvance.length,
|
projectCount: idsToAdvance.length,
|
||||||
|
autoPassedCount,
|
||||||
projectIds: idsToAdvance,
|
projectIds: idsToAdvance,
|
||||||
},
|
},
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
@@ -354,6 +366,7 @@ export const roundRouter = router({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
advancedCount: idsToAdvance.length,
|
advancedCount: idsToAdvance.length,
|
||||||
|
autoPassedCount,
|
||||||
targetRoundId: targetRound.id,
|
targetRoundId: targetRound.id,
|
||||||
targetRoundName: targetRound.name,
|
targetRoundName: targetRound.name,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,4 +263,41 @@ export const roundEngineRouter = router({
|
|||||||
|
|
||||||
return { success: true, removedCount: deleted.count }
|
return { success: true, removedCount: deleted.count }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retroactive document check: auto-PASS any PENDING/IN_PROGRESS projects
|
||||||
|
* that already have all required documents uploaded for this round.
|
||||||
|
* Useful for rounds activated before the auto-transition feature was deployed.
|
||||||
|
*/
|
||||||
|
checkDocumentCompletion: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { batchCheckRequirementsAndTransition } = await import('../services/round-engine')
|
||||||
|
|
||||||
|
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
state: { in: ['PENDING', 'IN_PROGRESS'] },
|
||||||
|
},
|
||||||
|
select: { projectId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (projectStates.length === 0) {
|
||||||
|
return { transitionedCount: 0, checkedCount: 0, projectIds: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
|
||||||
|
const result = await batchCheckRequirementsAndTransition(
|
||||||
|
input.roundId,
|
||||||
|
projectIds,
|
||||||
|
ctx.user.id,
|
||||||
|
ctx.prisma,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
transitionedCount: result.transitionedCount,
|
||||||
|
checkedCount: projectIds.length,
|
||||||
|
projectIds: result.projectIds,
|
||||||
|
}
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const ASSIGNMENT_SYSTEM_PROMPT = `You are an expert jury assignment optimizer fo
|
|||||||
Match jurors to projects based on expertise alignment, workload balance, and coverage requirements.
|
Match jurors to projects based on expertise alignment, workload balance, and coverage requirements.
|
||||||
|
|
||||||
## Matching Criteria (Weighted)
|
## Matching Criteria (Weighted)
|
||||||
- Expertise Match (50%): How well juror tags/expertise align with project topics
|
- Expertise Match (50%): How well juror tags/expertise align with project topics. Project tags include a confidence score (0-1) — weight higher-confidence tags more heavily as they are more reliably assigned. A tag with confidence 0.9 is a strong signal; one with 0.5 is uncertain.
|
||||||
- Workload Balance (30%): Distribute assignments evenly; prefer jurors below capacity
|
- Workload Balance (30%): Distribute assignments evenly; prefer jurors below capacity
|
||||||
- Minimum Target (20%): Prioritize jurors who haven't reached their minimum assignment count
|
- Minimum Target (20%): Prioritize jurors who haven't reached their minimum assignment count
|
||||||
|
|
||||||
@@ -99,6 +99,7 @@ interface ProjectForAssignment {
|
|||||||
title: string
|
title: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
tags: string[]
|
tags: string[]
|
||||||
|
tagConfidences?: Array<{ name: string; confidence: number }>
|
||||||
teamName?: string | null
|
teamName?: string | null
|
||||||
_count?: {
|
_count?: {
|
||||||
assignments: number
|
assignments: number
|
||||||
@@ -539,7 +540,7 @@ export function generateFallbackAssignments(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
juror,
|
juror,
|
||||||
score: calculateExpertiseScore(juror.expertiseTags, project.tags),
|
score: calculateExpertiseScore(juror.expertiseTags, project.tags, project.tagConfidences),
|
||||||
loadScore: calculateLoadScore(currentLoad, maxLoad),
|
loadScore: calculateLoadScore(currentLoad, maxLoad),
|
||||||
underMinBonus: calculateUnderMinBonus(currentLoad, minTarget),
|
underMinBonus: calculateUnderMinBonus(currentLoad, minTarget),
|
||||||
}
|
}
|
||||||
@@ -586,24 +587,44 @@ export function generateFallbackAssignments(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate expertise match score based on tag overlap
|
* Calculate expertise match score based on tag overlap
|
||||||
|
* When tagConfidences are available, weights matches by confidence
|
||||||
*/
|
*/
|
||||||
function calculateExpertiseScore(
|
function calculateExpertiseScore(
|
||||||
jurorTags: string[],
|
jurorTags: string[],
|
||||||
projectTags: string[]
|
projectTags: string[],
|
||||||
|
tagConfidences?: Array<{ name: string; confidence: number }>
|
||||||
): number {
|
): number {
|
||||||
if (jurorTags.length === 0 || projectTags.length === 0) {
|
if (jurorTags.length === 0 || projectTags.length === 0) {
|
||||||
return 0.5 // Neutral score if no tags
|
return 0.5 // Neutral score if no tags
|
||||||
}
|
}
|
||||||
|
|
||||||
const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))
|
const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))
|
||||||
|
|
||||||
|
// If we have confidence data, use weighted scoring
|
||||||
|
if (tagConfidences && tagConfidences.length > 0) {
|
||||||
|
let weightedMatches = 0
|
||||||
|
let totalWeight = 0
|
||||||
|
|
||||||
|
for (const tc of tagConfidences) {
|
||||||
|
totalWeight += tc.confidence
|
||||||
|
if (jurorTagsLower.has(tc.name.toLowerCase())) {
|
||||||
|
weightedMatches += tc.confidence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalWeight === 0) return 0.5
|
||||||
|
|
||||||
|
const weightedRatio = weightedMatches / totalWeight
|
||||||
|
const hasExpertise = weightedMatches > 0 ? 0.2 : 0
|
||||||
|
return Math.min(1, weightedRatio * 0.8 + hasExpertise)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: unweighted matching using flat tags
|
||||||
const matchingTags = projectTags.filter((t) =>
|
const matchingTags = projectTags.filter((t) =>
|
||||||
jurorTagsLower.has(t.toLowerCase())
|
jurorTagsLower.has(t.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
// Score based on percentage of project tags matched
|
|
||||||
const matchRatio = matchingTags.length / projectTags.length
|
const matchRatio = matchingTags.length / projectTags.length
|
||||||
|
|
||||||
// Boost for having expertise, even if not all match
|
|
||||||
const hasExpertise = matchingTags.length > 0 ? 0.2 : 0
|
const hasExpertise = matchingTags.length > 0 ? 0.2 : 0
|
||||||
|
|
||||||
return Math.min(1, matchRatio * 0.8 + hasExpertise)
|
return Math.min(1, matchRatio * 0.8 + hasExpertise)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export interface AnonymizedProject {
|
|||||||
anonymousId: string
|
anonymousId: string
|
||||||
title: string
|
title: string
|
||||||
description: string | null
|
description: string | null
|
||||||
tags: string[]
|
tags: Array<{ name: string; confidence: number }>
|
||||||
teamName: string | null
|
teamName: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +209,7 @@ interface ProjectInput {
|
|||||||
title: string
|
title: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
tags: string[]
|
tags: string[]
|
||||||
|
tagConfidences?: Array<{ name: string; confidence: number }>
|
||||||
teamName?: string | null
|
teamName?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +254,9 @@ export function anonymizeForAI(
|
|||||||
description: project.description
|
description: project.description
|
||||||
? truncateAndSanitize(project.description, DESCRIPTION_LIMITS.ASSIGNMENT)
|
? truncateAndSanitize(project.description, DESCRIPTION_LIMITS.ASSIGNMENT)
|
||||||
: null,
|
: null,
|
||||||
tags: project.tags,
|
tags: project.tagConfidences && project.tagConfidences.length > 0
|
||||||
|
? project.tagConfidences
|
||||||
|
: project.tags.map((t) => ({ name: t, confidence: 1.0 })),
|
||||||
teamName: project.teamName ? `Team ${index + 1}` : null,
|
teamName: project.teamName ? `Team ${index + 1}` : null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -524,7 +527,7 @@ export function validateAnonymization(data: AnonymizationResult): boolean {
|
|||||||
if (!checkText(project.title)) return false
|
if (!checkText(project.title)) return false
|
||||||
if (!checkText(project.description)) return false
|
if (!checkText(project.description)) return false
|
||||||
for (const tag of project.tags) {
|
for (const tag of project.tags) {
|
||||||
if (!checkText(tag)) return false
|
if (!checkText(typeof tag === 'string' ? tag : tag.name)) return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,24 @@ export async function activateRound(
|
|||||||
detailsJson: { name: round.name, roundType: round.roundType },
|
detailsJson: { name: round.name, roundType: round.roundType },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Retroactive check: auto-PASS any projects that already have all required docs uploaded
|
||||||
|
// Non-fatal — runs after activation so it never blocks the transition
|
||||||
|
try {
|
||||||
|
const projectStates = await prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
|
||||||
|
select: { projectId: true },
|
||||||
|
})
|
||||||
|
if (projectStates.length > 0) {
|
||||||
|
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
|
||||||
|
const result = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
|
||||||
|
if (result.transitionedCount > 0) {
|
||||||
|
console.log(`[RoundEngine] On activation: auto-passed ${result.transitionedCount} projects with complete documents`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (retroError) {
|
||||||
|
console.error('[RoundEngine] Retroactive document check failed (non-fatal):', retroError)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
round: { id: updated.id, status: updated.status },
|
round: { id: updated.id, status: updated.status },
|
||||||
@@ -429,6 +447,23 @@ export async function reopenRound(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Retroactive check: auto-PASS any projects that already have all required docs
|
||||||
|
try {
|
||||||
|
const projectStates = await prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
|
||||||
|
select: { projectId: true },
|
||||||
|
})
|
||||||
|
if (projectStates.length > 0) {
|
||||||
|
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
|
||||||
|
const batchResult = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
|
||||||
|
if (batchResult.transitionedCount > 0) {
|
||||||
|
console.log(`[RoundEngine] On reopen: auto-passed ${batchResult.transitionedCount} projects with complete documents`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (retroError) {
|
||||||
|
console.error('[RoundEngine] Retroactive document check on reopen failed (non-fatal):', retroError)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
round: { id: result.updated.id, status: result.updated.status },
|
round: { id: result.updated.id, status: result.updated.status },
|
||||||
|
|||||||
Reference in New Issue
Block a user