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:
@@ -34,6 +34,8 @@ type EvaluationEditSheetProps = {
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Called after a successful feedback edit */
|
||||
onSaved?: () => void
|
||||
/** Optional project competition category for category-aware form lookup */
|
||||
category?: 'STARTUP' | 'BUSINESS_CONCEPT' | null
|
||||
}
|
||||
|
||||
export function EvaluationEditSheet({
|
||||
@@ -41,6 +43,7 @@ export function EvaluationEditSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
category,
|
||||
}: EvaluationEditSheetProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editedFeedback, setEditedFeedback] = useState('')
|
||||
@@ -115,7 +118,7 @@ export function EvaluationEditSheet({
|
||||
|
||||
{/* Criterion Scores */}
|
||||
{hasScores && (
|
||||
<CriterionScoresSection criterionScores={criterionScores} roundId={roundId} />
|
||||
<CriterionScoresSection criterionScores={criterionScores} roundId={roundId} category={category ?? assignment.project?.competitionCategory} />
|
||||
)}
|
||||
|
||||
{/* Feedback Text — editable */}
|
||||
@@ -147,12 +150,14 @@ export function EvaluationEditSheet({
|
||||
function CriterionScoresSection({
|
||||
criterionScores,
|
||||
roundId,
|
||||
category,
|
||||
}: {
|
||||
criterionScores: Record<string, number | boolean | string>
|
||||
roundId?: string
|
||||
category?: 'STARTUP' | 'BUSINESS_CONCEPT' | null
|
||||
}) {
|
||||
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||
{ roundId: roundId ?? '' },
|
||||
{ roundId: roundId ?? '', category },
|
||||
{ enabled: !!roundId }
|
||||
)
|
||||
|
||||
|
||||
@@ -6,15 +6,165 @@ import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
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 { EvaluationFormBuilder } from '@/components/forms/evaluation-form-builder'
|
||||
import type { Criterion } from '@/components/forms/evaluation-form-builder'
|
||||
|
||||
export type EvaluationCriteriaEditorProps = {
|
||||
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 utils = trpc.useUtils()
|
||||
|
||||
@@ -32,21 +182,10 @@ export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorPr
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Convert server criteriaJson to Criterion[] format
|
||||
const serverCriteria: Criterion[] = useMemo(() => {
|
||||
if (!form?.criteriaJson) return []
|
||||
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 serverCriteria: Criterion[] = useMemo(
|
||||
() => parseCriteria(form?.criteriaJson),
|
||||
[form?.criteriaJson],
|
||||
)
|
||||
|
||||
const handleChange = useCallback((criteria: Criterion[]) => {
|
||||
setPendingCriteria(criteria)
|
||||
@@ -59,26 +198,9 @@ export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorPr
|
||||
toast.error('Add at least one criterion')
|
||||
return
|
||||
}
|
||||
// Map to upsertForm format
|
||||
upsertMutation.mutate({
|
||||
roundId,
|
||||
criteria: validCriteria.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,
|
||||
})),
|
||||
criteria: buildUpsertPayload(validCriteria),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,3 +244,59 @@ export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorPr
|
||||
</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>
|
||||
</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">
|
||||
<Label htmlFor="anonymizationLevel">Anonymization Level</Label>
|
||||
<p className="text-xs text-muted-foreground">How much of other jurors' identities are revealed</p>
|
||||
|
||||
Reference in New Issue
Block a user