feat: per-category evaluation criteria (startup vs business concept)
Add ability to define completely different evaluation criteria for each competition category. Admins toggle "Separate Criteria per Category" in round config, then configure criteria independently via tabbed editor. - Schema: add nullable `category` to EvaluationForm with updated constraints - Config: add `perCategoryCriteria` boolean to EvaluationConfigSchema - Helper: new `findActiveForm()` with category-aware resolution + fallback - Backend: getForm, upsertForm, getStageForm, startStage all category-aware - AI services: use project category for form lookup in summaries + ranking - Export/ranking: merge criteria from all active forms for cross-category reports - Admin UI: toggle switch + tabbed criteria editor with per-category builders - Jury UI: auto-selects correct form based on project category (invisible to juror) - Fully backwards compatible: toggle defaults OFF, existing forms unchanged Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
-- AlterTable: add nullable category column to EvaluationForm
|
||||||
|
ALTER TABLE "EvaluationForm" ADD COLUMN "category" "CompetitionCategory";
|
||||||
|
|
||||||
|
-- Drop old unique constraint
|
||||||
|
ALTER TABLE "EvaluationForm" DROP CONSTRAINT "EvaluationForm_roundId_version_key";
|
||||||
|
|
||||||
|
-- Add new unique constraint including category
|
||||||
|
ALTER TABLE "EvaluationForm" ADD CONSTRAINT "EvaluationForm_roundId_version_category_key" UNIQUE ("roundId", "version", "category");
|
||||||
|
|
||||||
|
-- Partial unique index: prevent duplicate shared forms at the same version
|
||||||
|
-- (PostgreSQL treats NULLs as distinct in unique constraints, so we need this)
|
||||||
|
CREATE UNIQUE INDEX "EvaluationForm_roundId_version_null_category"
|
||||||
|
ON "EvaluationForm" ("roundId", "version") WHERE "category" IS NULL;
|
||||||
|
|
||||||
|
-- Compound index for category-aware active form lookups
|
||||||
|
CREATE INDEX "EvaluationForm_roundId_isActive_category_idx"
|
||||||
|
ON "EvaluationForm" ("roundId", "isActive", "category");
|
||||||
@@ -519,9 +519,10 @@ model WizardTemplate {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
model EvaluationForm {
|
model EvaluationForm {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
roundId String
|
roundId String
|
||||||
version Int @default(1)
|
version Int @default(1)
|
||||||
|
category CompetitionCategory? // null=shared form, STARTUP or BUSINESS_CONCEPT=category-specific
|
||||||
|
|
||||||
// Form configuration
|
// Form configuration
|
||||||
// criteriaJson: Array of { id, label, description, scale, weight, required }
|
// criteriaJson: Array of { id, label, description, scale, weight, required }
|
||||||
@@ -537,8 +538,9 @@ model EvaluationForm {
|
|||||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||||
evaluations Evaluation[]
|
evaluations Evaluation[]
|
||||||
|
|
||||||
@@unique([roundId, version])
|
@@unique([roundId, version, category])
|
||||||
@@index([roundId, isActive])
|
@@index([roundId, isActive])
|
||||||
|
@@index([roundId, isActive, category])
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -1006,6 +1006,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
open={!!selectedEvalAssignment}
|
open={!!selectedEvalAssignment}
|
||||||
onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }}
|
onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }}
|
||||||
onSaved={() => utils.project.getFullDetail.invalidate({ id: projectId })}
|
onSaved={() => utils.project.getFullDetail.invalidate({ id: projectId })}
|
||||||
|
category={project?.competitionCategory}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* AI Evaluation Summary */}
|
{/* AI Evaluation Summary */}
|
||||||
|
|||||||
@@ -2291,7 +2291,7 @@ export default function RoundDetailPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Evaluation Criteria Editor (EVALUATION rounds only) */}
|
{/* Evaluation Criteria Editor (EVALUATION rounds only) */}
|
||||||
{isEvaluation && <EvaluationCriteriaEditor roundId={roundId} />}
|
{isEvaluation && <EvaluationCriteriaEditor roundId={roundId} perCategoryCriteria={!!(config.perCategoryCriteria)} />}
|
||||||
|
|
||||||
{/* Document Requirements — hidden for EVALUATION rounds unless requireDocumentUpload is on */}
|
{/* Document Requirements — hidden for EVALUATION rounds unless requireDocumentUpload is on */}
|
||||||
{(!isEvaluation || !!(config.requireDocumentUpload as boolean)) && (
|
{(!isEvaluation || !!(config.requireDocumentUpload as boolean)) && (
|
||||||
|
|||||||
@@ -80,10 +80,10 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
const [coiCompleted, setCOICompleted] = useState(false)
|
const [coiCompleted, setCOICompleted] = useState(false)
|
||||||
const [coiHasConflict, setCOIHasConflict] = useState(false)
|
const [coiHasConflict, setCOIHasConflict] = useState(false)
|
||||||
|
|
||||||
// Fetch the active evaluation form for this round
|
// Fetch the active evaluation form for this round (category-aware)
|
||||||
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||||
{ roundId },
|
{ roundId, category: project?.competitionCategory },
|
||||||
{ enabled: !!roundId }
|
{ enabled: !!roundId && !!project }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Start evaluation mutation (creates draft)
|
// Start evaluation mutation (creates draft)
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ type EvaluationEditSheetProps = {
|
|||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
/** Called after a successful feedback edit */
|
/** Called after a successful feedback edit */
|
||||||
onSaved?: () => void
|
onSaved?: () => void
|
||||||
|
/** Optional project competition category for category-aware form lookup */
|
||||||
|
category?: 'STARTUP' | 'BUSINESS_CONCEPT' | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EvaluationEditSheet({
|
export function EvaluationEditSheet({
|
||||||
@@ -41,6 +43,7 @@ export function EvaluationEditSheet({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
category,
|
||||||
}: EvaluationEditSheetProps) {
|
}: EvaluationEditSheetProps) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [editedFeedback, setEditedFeedback] = useState('')
|
const [editedFeedback, setEditedFeedback] = useState('')
|
||||||
@@ -115,7 +118,7 @@ export function EvaluationEditSheet({
|
|||||||
|
|
||||||
{/* Criterion Scores */}
|
{/* Criterion Scores */}
|
||||||
{hasScores && (
|
{hasScores && (
|
||||||
<CriterionScoresSection criterionScores={criterionScores} roundId={roundId} />
|
<CriterionScoresSection criterionScores={criterionScores} roundId={roundId} category={category ?? assignment.project?.competitionCategory} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Feedback Text — editable */}
|
{/* Feedback Text — editable */}
|
||||||
@@ -147,12 +150,14 @@ export function EvaluationEditSheet({
|
|||||||
function CriterionScoresSection({
|
function CriterionScoresSection({
|
||||||
criterionScores,
|
criterionScores,
|
||||||
roundId,
|
roundId,
|
||||||
|
category,
|
||||||
}: {
|
}: {
|
||||||
criterionScores: Record<string, number | boolean | string>
|
criterionScores: Record<string, number | boolean | string>
|
||||||
roundId?: string
|
roundId?: string
|
||||||
|
category?: 'STARTUP' | 'BUSINESS_CONCEPT' | null
|
||||||
}) {
|
}) {
|
||||||
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||||
{ roundId: roundId ?? '' },
|
{ roundId: roundId ?? '', category },
|
||||||
{ enabled: !!roundId }
|
{ enabled: !!roundId }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,165 @@ import { toast } from 'sonner'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import { EvaluationFormBuilder } from '@/components/forms/evaluation-form-builder'
|
import { EvaluationFormBuilder } from '@/components/forms/evaluation-form-builder'
|
||||||
import type { Criterion } from '@/components/forms/evaluation-form-builder'
|
import type { Criterion } from '@/components/forms/evaluation-form-builder'
|
||||||
|
|
||||||
export type EvaluationCriteriaEditorProps = {
|
export type EvaluationCriteriaEditorProps = {
|
||||||
roundId: string
|
roundId: string
|
||||||
|
perCategoryCriteria?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorProps) {
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function parseCriteria(criteriaJson: unknown): Criterion[] {
|
||||||
|
if (!criteriaJson) return []
|
||||||
|
return (criteriaJson as Criterion[]).map((c) => {
|
||||||
|
const type = c.type || 'numeric'
|
||||||
|
if (type === 'numeric' && typeof c.scale === 'string') {
|
||||||
|
const parts = (c.scale as string).split('-').map(Number)
|
||||||
|
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
||||||
|
return { ...c, type: 'numeric' as const, scale: parts[1], minScore: parts[0], maxScore: parts[1] } as unknown as Criterion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...c, type } as Criterion
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUpsertPayload(criteria: Criterion[]) {
|
||||||
|
return criteria.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.label,
|
||||||
|
description: c.description,
|
||||||
|
type: c.type || 'numeric',
|
||||||
|
weight: c.weight,
|
||||||
|
scale: typeof c.scale === 'number' ? c.scale : undefined,
|
||||||
|
minScore: (c as any).minScore,
|
||||||
|
maxScore: (c as any).maxScore,
|
||||||
|
required: c.required,
|
||||||
|
maxLength: c.maxLength,
|
||||||
|
placeholder: c.placeholder,
|
||||||
|
trueLabel: c.trueLabel,
|
||||||
|
falseLabel: c.falseLabel,
|
||||||
|
condition: c.condition,
|
||||||
|
sectionId: c.sectionId,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Single-category panel (used both standalone and inside tabs)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SingleCriteriaPanel({
|
||||||
|
roundId,
|
||||||
|
category,
|
||||||
|
}: {
|
||||||
|
roundId: string
|
||||||
|
category?: 'STARTUP' | 'BUSINESS_CONCEPT'
|
||||||
|
}) {
|
||||||
|
const [pendingCriteria, setPendingCriteria] = useState<Criterion[] | null>(null)
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const { data: form, isLoading } = trpc.evaluation.getForm.useQuery(
|
||||||
|
{ roundId, category: category ?? null },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const upsertMutation = trpc.evaluation.upsertForm.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.evaluation.getForm.invalidate({ roundId, category: category ?? null })
|
||||||
|
toast.success('Evaluation criteria saved')
|
||||||
|
setPendingCriteria(null)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const serverCriteria: Criterion[] = useMemo(
|
||||||
|
() => parseCriteria(form?.criteriaJson),
|
||||||
|
[form?.criteriaJson],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleChange = useCallback((criteria: Criterion[]) => {
|
||||||
|
setPendingCriteria(criteria)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const criteria = pendingCriteria ?? serverCriteria
|
||||||
|
const validCriteria = criteria.filter((c) => c.label.trim())
|
||||||
|
if (validCriteria.length === 0) {
|
||||||
|
toast.error('Add at least one criterion')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
upsertMutation.mutate({
|
||||||
|
roundId,
|
||||||
|
category: category ?? undefined,
|
||||||
|
criteria: buildUpsertPayload(validCriteria),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-end mb-3">
|
||||||
|
{form && (
|
||||||
|
<span className="text-xs text-muted-foreground mr-auto">
|
||||||
|
Version {form.version} —{' '}
|
||||||
|
{(form.criteriaJson as Criterion[]).filter((c) => (c.type || 'numeric') !== 'section_header').length} criteria
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!form && !isLoading && (
|
||||||
|
<span className="text-xs text-muted-foreground mr-auto">
|
||||||
|
No criteria defined yet. Add numeric scores, yes/no questions, and text fields.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{pendingCriteria && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setPendingCriteria(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={upsertMutation.isPending}>
|
||||||
|
{upsertMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
|
Save Criteria
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EvaluationFormBuilder
|
||||||
|
initialCriteria={serverCriteria}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function EvaluationCriteriaEditor({ roundId, perCategoryCriteria }: EvaluationCriteriaEditorProps) {
|
||||||
|
if (!perCategoryCriteria) {
|
||||||
|
// Original single-form layout
|
||||||
|
return <SingleFormEditor roundId={roundId} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-category tabbed layout
|
||||||
|
return <TabbedCriteriaEditor roundId={roundId} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Single form editor — preserves original Card layout exactly
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SingleFormEditor({ roundId }: { roundId: string }) {
|
||||||
const [pendingCriteria, setPendingCriteria] = useState<Criterion[] | null>(null)
|
const [pendingCriteria, setPendingCriteria] = useState<Criterion[] | null>(null)
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
@@ -32,21 +182,10 @@ export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorPr
|
|||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Convert server criteriaJson to Criterion[] format
|
const serverCriteria: Criterion[] = useMemo(
|
||||||
const serverCriteria: Criterion[] = useMemo(() => {
|
() => parseCriteria(form?.criteriaJson),
|
||||||
if (!form?.criteriaJson) return []
|
[form?.criteriaJson],
|
||||||
return (form.criteriaJson as Criterion[]).map((c) => {
|
)
|
||||||
// Handle legacy numeric-only format: convert "scale" string like "1-10" back to minScore/maxScore
|
|
||||||
const type = c.type || 'numeric'
|
|
||||||
if (type === 'numeric' && typeof c.scale === 'string') {
|
|
||||||
const parts = (c.scale as string).split('-').map(Number)
|
|
||||||
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
|
||||||
return { ...c, type: 'numeric' as const, scale: parts[1], minScore: parts[0], maxScore: parts[1] } as unknown as Criterion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ...c, type } as Criterion
|
|
||||||
})
|
|
||||||
}, [form?.criteriaJson])
|
|
||||||
|
|
||||||
const handleChange = useCallback((criteria: Criterion[]) => {
|
const handleChange = useCallback((criteria: Criterion[]) => {
|
||||||
setPendingCriteria(criteria)
|
setPendingCriteria(criteria)
|
||||||
@@ -59,26 +198,9 @@ export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorPr
|
|||||||
toast.error('Add at least one criterion')
|
toast.error('Add at least one criterion')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Map to upsertForm format
|
|
||||||
upsertMutation.mutate({
|
upsertMutation.mutate({
|
||||||
roundId,
|
roundId,
|
||||||
criteria: validCriteria.map((c) => ({
|
criteria: buildUpsertPayload(validCriteria),
|
||||||
id: c.id,
|
|
||||||
label: c.label,
|
|
||||||
description: c.description,
|
|
||||||
type: c.type || 'numeric',
|
|
||||||
weight: c.weight,
|
|
||||||
scale: typeof c.scale === 'number' ? c.scale : undefined,
|
|
||||||
minScore: (c as any).minScore,
|
|
||||||
maxScore: (c as any).maxScore,
|
|
||||||
required: c.required,
|
|
||||||
maxLength: c.maxLength,
|
|
||||||
placeholder: c.placeholder,
|
|
||||||
trueLabel: c.trueLabel,
|
|
||||||
falseLabel: c.falseLabel,
|
|
||||||
condition: c.condition,
|
|
||||||
sectionId: c.sectionId,
|
|
||||||
})),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,3 +244,59 @@ export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorPr
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tabbed criteria editor — separate forms per category
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function TabbedCriteriaEditor({ roundId }: { roundId: string }) {
|
||||||
|
const { data: startupForm } = trpc.evaluation.getForm.useQuery(
|
||||||
|
{ roundId, category: 'STARTUP' },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
const { data: conceptForm } = trpc.evaluation.getForm.useQuery(
|
||||||
|
{ roundId, category: 'BUSINESS_CONCEPT' },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Evaluation Criteria</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Separate criteria for each project category. Configure each tab independently.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="STARTUP">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="STARTUP" className="gap-2">
|
||||||
|
Startup Criteria
|
||||||
|
{!startupForm && (
|
||||||
|
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-300 text-[10px] px-1.5 py-0">
|
||||||
|
No form
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="BUSINESS_CONCEPT" className="gap-2">
|
||||||
|
Business Concept Criteria
|
||||||
|
{!conceptForm && (
|
||||||
|
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-300 text-[10px] px-1.5 py-0">
|
||||||
|
No form
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="STARTUP" className="mt-4">
|
||||||
|
<SingleCriteriaPanel roundId={roundId} category="STARTUP" />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="BUSINESS_CONCEPT" className="mt-4">
|
||||||
|
<SingleCriteriaPanel roundId={roundId} category="BUSINESS_CONCEPT" />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="perCategoryCriteria">Separate Criteria per Category</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">Define different evaluation criteria for Startup and Business Concept projects</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="perCategoryCriteria"
|
||||||
|
checked={(config.perCategoryCriteria as boolean) ?? false}
|
||||||
|
onCheckedChange={(v) => update('perCategoryCriteria', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="anonymizationLevel">Anonymization Level</Label>
|
<Label htmlFor="anonymizationLevel">Anonymization Level</Label>
|
||||||
<p className="text-xs text-muted-foreground">How much of other jurors' identities are revealed</p>
|
<p className="text-xs text-muted-foreground">How much of other jurors' identities are revealed</p>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
const roundId = data?.assignments?.[0]?.roundId as string | undefined
|
const roundId = data?.assignments?.[0]?.roundId as string | undefined
|
||||||
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||||
{ roundId: roundId ?? '' },
|
{ roundId: roundId ?? '', category: data?.project?.competitionCategory },
|
||||||
{ enabled: !!roundId },
|
{ enabled: !!roundId },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { generateSummary } from '@/server/services/ai-evaluation-summary'
|
|||||||
import { quickRank as aiQuickRank } from '../services/ai-ranking'
|
import { quickRank as aiQuickRank } from '../services/ai-ranking'
|
||||||
import type { EvaluationConfig } from '@/types/competition-configs'
|
import type { EvaluationConfig } from '@/types/competition-configs'
|
||||||
import type { PrismaClient } from '@prisma/client'
|
import type { PrismaClient } from '@prisma/client'
|
||||||
|
import { findActiveForm } from '@/server/utils/evaluation-form-lookup'
|
||||||
import { triggerInProgressOnActivity, checkEvaluationCompletionAndTransition } from '../services/round-engine'
|
import { triggerInProgressOnActivity, checkEvaluationCompletionAndTransition } from '../services/round-engine'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1298,11 +1299,9 @@ export const evaluationRouter = router({
|
|||||||
* Get active evaluation form for a round (admin view with full details)
|
* Get active evaluation form for a round (admin view with full details)
|
||||||
*/
|
*/
|
||||||
getForm: adminProcedure
|
getForm: adminProcedure
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string(), category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).nullish() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
const form = await findActiveForm(ctx.prisma, input.roundId, input.category)
|
||||||
where: { roundId: input.roundId, isActive: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!form) return null
|
if (!form) return null
|
||||||
|
|
||||||
@@ -1333,6 +1332,7 @@ export const evaluationRouter = router({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
roundId: z.string(),
|
roundId: z.string(),
|
||||||
|
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).nullish(),
|
||||||
criteria: z.array(
|
criteria: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -1364,7 +1364,7 @@ export const evaluationRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { roundId, criteria } = input
|
const { roundId, category, criteria } = input
|
||||||
|
|
||||||
// Enforce max one advance criterion per form
|
// Enforce max one advance criterion per form
|
||||||
const advanceCount = criteria.filter((c) => c.type === 'advance').length
|
const advanceCount = criteria.filter((c) => c.type === 'advance').length
|
||||||
@@ -1378,9 +1378,9 @@ export const evaluationRouter = router({
|
|||||||
// Verify round exists
|
// Verify round exists
|
||||||
await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId } })
|
await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId } })
|
||||||
|
|
||||||
// Get current max version for this round
|
// Get current max version for this round + category
|
||||||
const latestForm = await ctx.prisma.evaluationForm.findFirst({
|
const latestForm = await ctx.prisma.evaluationForm.findFirst({
|
||||||
where: { roundId },
|
where: { roundId, category: category ?? null },
|
||||||
orderBy: { version: 'desc' },
|
orderBy: { version: 'desc' },
|
||||||
select: { version: true },
|
select: { version: true },
|
||||||
})
|
})
|
||||||
@@ -1440,10 +1440,10 @@ export const evaluationRouter = router({
|
|||||||
scalesJson[scale] = { min, max }
|
scalesJson[scale] = { min, max }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transaction: deactivate old → create new
|
// Transaction: deactivate old → create new (scoped to category)
|
||||||
const form = await ctx.prisma.$transaction(async (tx) => {
|
const form = await ctx.prisma.$transaction(async (tx) => {
|
||||||
await tx.evaluationForm.updateMany({
|
await tx.evaluationForm.updateMany({
|
||||||
where: { roundId, isActive: true },
|
where: { roundId, isActive: true, category: category ?? null },
|
||||||
data: { isActive: false },
|
data: { isActive: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1454,6 +1454,7 @@ export const evaluationRouter = router({
|
|||||||
criteriaJson,
|
criteriaJson,
|
||||||
scalesJson,
|
scalesJson,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
category: category ?? null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1553,10 +1554,14 @@ export const evaluationRouter = router({
|
|||||||
})
|
})
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
|
|
||||||
// Get active evaluation form for this stage
|
// Fetch project's competition category for category-aware form lookup
|
||||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
||||||
where: { roundId: input.roundId, isActive: true },
|
where: { id: assignment.projectId },
|
||||||
|
select: { competitionCategory: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Get active evaluation form for this stage (category-aware)
|
||||||
|
const form = await findActiveForm(ctx.prisma, input.roundId, project.competitionCategory)
|
||||||
if (!form) {
|
if (!form) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
@@ -1577,11 +1582,9 @@ export const evaluationRouter = router({
|
|||||||
* Get the active evaluation form for a stage
|
* Get the active evaluation form for a stage
|
||||||
*/
|
*/
|
||||||
getStageForm: protectedProcedure
|
getStageForm: protectedProcedure
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string(), category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).nullish() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
const form = await findActiveForm(ctx.prisma, input.roundId, input.category)
|
||||||
where: { roundId: input.roundId, isActive: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!form) {
|
if (!form) {
|
||||||
return null
|
return null
|
||||||
@@ -1846,7 +1849,7 @@ export const evaluationRouter = router({
|
|||||||
const assignments = await ctx.prisma.assignment.findMany({
|
const assignments = await ctx.prisma.assignment.findMany({
|
||||||
where: { userId: input.userId },
|
where: { userId: input.userId },
|
||||||
include: {
|
include: {
|
||||||
project: { select: { id: true, title: true } },
|
project: { select: { id: true, title: true, competitionCategory: true } },
|
||||||
round: { select: { id: true, name: true, roundType: true, sortOrder: true } },
|
round: { select: { id: true, name: true, roundType: true, sortOrder: true } },
|
||||||
evaluation: {
|
evaluation: {
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -103,14 +103,23 @@ export const exportRouter = router({
|
|||||||
projectScores: adminProcedure
|
projectScores: adminProcedure
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
// Fetch evaluation form to get criteria labels
|
// Fetch all active evaluation forms for this round (shared + category-specific)
|
||||||
const evalForm = await ctx.prisma.evaluationForm.findFirst({
|
const activeForms = await ctx.prisma.evaluationForm.findMany({
|
||||||
where: { roundId: input.roundId, isActive: true },
|
where: { roundId: input.roundId, isActive: true },
|
||||||
select: { criteriaJson: true },
|
select: { criteriaJson: true },
|
||||||
})
|
})
|
||||||
const criteria = (evalForm?.criteriaJson as Array<{
|
// Merge criteria across all forms, deduplicating by criterion id
|
||||||
id: string; label: string; type?: string
|
const seenCriterionIds = new Set<string>()
|
||||||
}> | null) ?? []
|
const criteria: Array<{ id: string; label: string; type?: string }> = []
|
||||||
|
for (const f of activeForms) {
|
||||||
|
const fc = (f.criteriaJson as Array<{ id: string; label: string; type?: string }> | null) ?? []
|
||||||
|
for (const c of fc) {
|
||||||
|
if (!seenCriterionIds.has(c.id)) {
|
||||||
|
seenCriterionIds.add(c.id)
|
||||||
|
criteria.push(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const numericCriteria = criteria.filter((c) => !c.type || c.type === 'numeric')
|
const numericCriteria = criteria.filter((c) => !c.type || c.type === 'numeric')
|
||||||
|
|
||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
@@ -632,12 +641,24 @@ export const exportRouter = router({
|
|||||||
|
|
||||||
// Criteria breakdown
|
// Criteria breakdown
|
||||||
if (includeSection('criteriaBreakdown')) {
|
if (includeSection('criteriaBreakdown')) {
|
||||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
// Fetch all active forms (shared + category-specific) and merge criteria
|
||||||
|
const allForms = await ctx.prisma.evaluationForm.findMany({
|
||||||
where: { roundId: input.roundId, isActive: true },
|
where: { roundId: input.roundId, isActive: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (form?.criteriaJson) {
|
const seenIds = new Set<string>()
|
||||||
const criteria = form.criteriaJson as Array<{ id: string; label: string }>
|
const allCriteria: Array<{ id: string; label: string }> = []
|
||||||
|
for (const f of allForms) {
|
||||||
|
const fc = (f.criteriaJson as Array<{ id: string; label: string }> | null) ?? []
|
||||||
|
for (const c of fc) {
|
||||||
|
if (!seenIds.has(c.id)) {
|
||||||
|
seenIds.add(c.id)
|
||||||
|
allCriteria.push(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allCriteria.length > 0) {
|
||||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||||
where: {
|
where: {
|
||||||
assignment: { roundId: input.roundId },
|
assignment: { roundId: input.roundId },
|
||||||
@@ -646,7 +667,7 @@ export const exportRouter = router({
|
|||||||
select: { criterionScoresJson: true },
|
select: { criterionScoresJson: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
result.criteriaBreakdown = criteria.map((c) => {
|
result.criteriaBreakdown = allCriteria.map((c) => {
|
||||||
const scores: number[] = []
|
const scores: number[] = []
|
||||||
evaluations.forEach((e) => {
|
evaluations.forEach((e) => {
|
||||||
const cs = e.criterionScoresJson as Record<string, number> | null
|
const cs = e.criterionScoresJson as Record<string, number> | null
|
||||||
|
|||||||
@@ -443,17 +443,24 @@ export const rankingRouter = router({
|
|||||||
roundEvaluationScores: adminProcedure
|
roundEvaluationScores: adminProcedure
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
// Find the boolean criterion ID from the EvaluationForm (not round configJson)
|
// Find the boolean criterion ID across all active forms (shared + category-specific)
|
||||||
const evalForm = await ctx.prisma.evaluationForm.findFirst({
|
const activeForms = await ctx.prisma.evaluationForm.findMany({
|
||||||
where: { roundId: input.roundId, isActive: true },
|
where: { roundId: input.roundId, isActive: true },
|
||||||
select: { criteriaJson: true },
|
select: { criteriaJson: true },
|
||||||
})
|
})
|
||||||
const formCriteria = (evalForm?.criteriaJson as Array<{
|
let boolCriterionId: string | null = null
|
||||||
id: string; label: string; type?: string
|
for (const f of activeForms) {
|
||||||
}> | null) ?? []
|
const fc = (f.criteriaJson as Array<{
|
||||||
const boolCriterionId = formCriteria.find(
|
id: string; label: string; type?: string
|
||||||
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
|
}> | null) ?? []
|
||||||
)?.id ?? null
|
const found = fc.find(
|
||||||
|
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
|
||||||
|
)
|
||||||
|
if (found) {
|
||||||
|
boolCriterionId = found.id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const assignments = await ctx.prisma.assignment.findMany({
|
const assignments = await ctx.prisma.assignment.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
|||||||
import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
||||||
import { sanitizeText } from './anonymization'
|
import { sanitizeText } from './anonymization'
|
||||||
import type { PrismaClient, Prisma } from '@prisma/client'
|
import type { PrismaClient, Prisma } from '@prisma/client'
|
||||||
|
import { findActiveForm } from '@/server/utils/evaluation-form-lookup'
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -290,6 +291,7 @@ export async function generateSummary({
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
|
competitionCategory: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -329,11 +331,8 @@ export async function generateSummary({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get evaluation form criteria for this round
|
// Get evaluation form criteria for this round (category-aware)
|
||||||
const form = await prisma.evaluationForm.findFirst({
|
const form = await findActiveForm(prisma, roundId, project.competitionCategory)
|
||||||
where: { roundId, isActive: true },
|
|
||||||
select: { criteriaJson: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const criteria: CriterionDef[] = form?.criteriaJson
|
const criteria: CriterionDef[] = form?.criteriaJson
|
||||||
? (form.criteriaJson as unknown as CriterionDef[])
|
? (form.criteriaJson as unknown as CriterionDef[])
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
|
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
|
||||||
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
||||||
import { classifyAIError, logAIError } from './ai-errors'
|
import { classifyAIError, logAIError } from './ai-errors'
|
||||||
|
import { findActiveForm } from '@/server/utils/evaluation-form-lookup'
|
||||||
import { sanitizeUserInput } from '@/server/services/ai-prompt-guard'
|
import { sanitizeUserInput } from '@/server/services/ai-prompt-guard'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import type { CompetitionCategory, PrismaClient } from '@prisma/client'
|
import type { CompetitionCategory, PrismaClient } from '@prisma/client'
|
||||||
@@ -625,16 +626,13 @@ async function fetchCategoryProjects(
|
|||||||
roundId: string,
|
roundId: string,
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
): Promise<CategoryProjectData> {
|
): Promise<CategoryProjectData> {
|
||||||
// Fetch the round config and evaluation form in parallel
|
// Fetch the round config and evaluation form in parallel (category-aware)
|
||||||
const [round, evalForm] = await Promise.all([
|
const [round, evalForm] = await Promise.all([
|
||||||
prisma.round.findUniqueOrThrow({
|
prisma.round.findUniqueOrThrow({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
select: { configJson: true },
|
select: { configJson: true },
|
||||||
}),
|
}),
|
||||||
prisma.evaluationForm.findFirst({
|
findActiveForm(prisma, roundId, category),
|
||||||
where: { roundId, isActive: true },
|
|
||||||
select: { criteriaJson: true },
|
|
||||||
}),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const roundConfig = round.configJson as Record<string, unknown> | null
|
const roundConfig = round.configJson as Record<string, unknown> | null
|
||||||
|
|||||||
26
src/server/utils/evaluation-form-lookup.ts
Normal file
26
src/server/utils/evaluation-form-lookup.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { PrismaClient, CompetitionCategory, EvaluationForm } from '@prisma/client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the active EvaluationForm for a round, with category-aware resolution.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. If `category` is provided, try the category-specific active form first.
|
||||||
|
* 2. Fall back to the shared form (category = null).
|
||||||
|
* 3. If no `category` provided, return the shared form directly.
|
||||||
|
*/
|
||||||
|
export async function findActiveForm(
|
||||||
|
prisma: PrismaClient | Pick<PrismaClient, 'evaluationForm'>,
|
||||||
|
roundId: string,
|
||||||
|
category?: CompetitionCategory | null,
|
||||||
|
): Promise<EvaluationForm | null> {
|
||||||
|
if (category) {
|
||||||
|
const specific = await prisma.evaluationForm.findFirst({
|
||||||
|
where: { roundId, isActive: true, category },
|
||||||
|
})
|
||||||
|
if (specific) return specific
|
||||||
|
}
|
||||||
|
// Fallback to shared form (category = null)
|
||||||
|
return prisma.evaluationForm.findFirst({
|
||||||
|
where: { roundId, isActive: true, category: null },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -93,6 +93,7 @@ export const EvaluationConfigSchema = z.object({
|
|||||||
requiredReviewsPerProject: z.number().int().positive().default(3),
|
requiredReviewsPerProject: z.number().int().positive().default(3),
|
||||||
|
|
||||||
scoringMode: z.enum(['criteria', 'global', 'binary']).default('criteria'),
|
scoringMode: z.enum(['criteria', 'global', 'binary']).default('criteria'),
|
||||||
|
perCategoryCriteria: z.boolean().default(false),
|
||||||
requireFeedback: z.boolean().default(true),
|
requireFeedback: z.boolean().default(true),
|
||||||
feedbackMinLength: z.number().int().nonnegative().default(0),
|
feedbackMinLength: z.number().int().nonnegative().default(0),
|
||||||
requireAllCriteriaScored: z.boolean().default(true),
|
requireAllCriteriaScored: z.boolean().default(true),
|
||||||
|
|||||||
Reference in New Issue
Block a user