Files
MOPC-Portal/src/components/admin/rounds/config/evaluation-config.tsx
Matt 3ccf9b0542 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>
2026-04-02 13:03:22 -04:00

419 lines
18 KiB
TypeScript

'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
type EvaluationConfigProps = {
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
}
export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
const advancementMode = (config.advancementMode as string) ?? 'admin_selection'
const advancementConfig = (config.advancementConfig as {
perCategory?: boolean; startupCount?: number; conceptCount?: number; tieBreaker?: string
}) ?? {}
const updateAdvancement = (key: string, value: unknown) => {
update('advancementConfig', { ...advancementConfig, [key]: value })
}
const visConfig = (config.applicantVisibility as {
enabled?: boolean; showGlobalScore?: boolean; showCriterionScores?: boolean; showFeedbackText?: boolean; hideFromRejected?: boolean
}) ?? {}
const updateVisibility = (key: string, value: unknown) => {
update('applicantVisibility', { ...visConfig, [key]: value })
}
return (
<div className="space-y-6">
{/* Scoring */}
<Card>
<CardHeader>
<CardTitle className="text-base">Scoring & Reviews</CardTitle>
<CardDescription>How jury members evaluate and score projects</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="requiredReviews">Required Reviews per Project</Label>
<p className="text-xs text-muted-foreground">Minimum number of jury evaluations needed</p>
<Input
id="requiredReviews"
type="number"
min={1}
className="w-32"
value={(config.requiredReviewsPerProject as number) ?? 3}
onChange={(e) => update('requiredReviewsPerProject', parseInt(e.target.value, 10) || 3)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="scoringMode">Scoring Mode</Label>
<p className="text-xs text-muted-foreground">How jurors assign scores to projects</p>
<Select
value={(config.scoringMode as string) ?? 'criteria'}
onValueChange={(v) => update('scoringMode', v)}
>
<SelectTrigger id="scoringMode" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="criteria">Criteria-based (multiple criteria with weights)</SelectItem>
<SelectItem value="global">Global score (single overall score)</SelectItem>
<SelectItem value="binary">Binary (pass/fail)</SelectItem>
</SelectContent>
</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&apos; identities are revealed</p>
<Select
value={(config.anonymizationLevel as string) ?? 'fully_anonymous'}
onValueChange={(v) => update('anonymizationLevel', v)}
>
<SelectTrigger id="anonymizationLevel" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fully_anonymous">Fully Anonymous</SelectItem>
<SelectItem value="show_initials">Show Initials</SelectItem>
<SelectItem value="named">Named (full names visible)</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Feedback */}
<Card>
<CardHeader>
<CardTitle className="text-base">Feedback Requirements</CardTitle>
<CardDescription>What jurors must provide alongside scores</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="requireFeedback">Require Written Feedback</Label>
<p className="text-xs text-muted-foreground">Jurors must write feedback text</p>
</div>
<Switch
id="requireFeedback"
checked={(config.requireFeedback as boolean) ?? true}
onCheckedChange={(v) => update('requireFeedback', v)}
/>
</div>
{(config.requireFeedback as boolean) !== false && (
<div className="pl-6 border-l-2 border-muted space-y-2">
<Label htmlFor="feedbackMinLength">Minimum Feedback Length</Label>
<p className="text-xs text-muted-foreground">Minimum characters (0 = no minimum)</p>
<Input
id="feedbackMinLength"
type="number"
min={0}
className="w-32"
value={(config.feedbackMinLength as number) ?? 0}
onChange={(e) => update('feedbackMinLength', parseInt(e.target.value, 10) || 0)}
/>
</div>
)}
<div className="flex items-center justify-between">
<div>
<Label htmlFor="requireAllCriteriaScored">Require All Criteria Scored</Label>
<p className="text-xs text-muted-foreground">Jurors must score every criterion before submitting</p>
</div>
<Switch
id="requireAllCriteriaScored"
checked={(config.requireAllCriteriaScored as boolean) ?? true}
onCheckedChange={(v) => update('requireAllCriteriaScored', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="coiRequired">COI Declaration Required</Label>
<p className="text-xs text-muted-foreground">Jurors must declare conflicts of interest</p>
</div>
<Switch
id="coiRequired"
checked={(config.coiRequired as boolean) ?? true}
onCheckedChange={(v) => update('coiRequired', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="requireDocumentUpload">Require Document Upload</Label>
<p className="text-xs text-muted-foreground">Applicants must upload documents for this evaluation round (disable if documents were uploaded in a previous round)</p>
</div>
<Switch
id="requireDocumentUpload"
checked={(config.requireDocumentUpload as boolean) ?? false}
onCheckedChange={(v) => update('requireDocumentUpload', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="peerReviewEnabled">Peer Review</Label>
<p className="text-xs text-muted-foreground">Allow jurors to see and comment on other evaluations</p>
</div>
<Switch
id="peerReviewEnabled"
checked={(config.peerReviewEnabled as boolean) ?? false}
onCheckedChange={(v) => update('peerReviewEnabled', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="showJurorProgressDashboard">Juror Progress Dashboard</Label>
<p className="text-xs text-muted-foreground">Show jurors a dashboard with their past evaluations, scores, and advance decisions</p>
</div>
<Switch
id="showJurorProgressDashboard"
checked={(config.showJurorProgressDashboard as boolean) ?? false}
onCheckedChange={(v) => update('showJurorProgressDashboard', v)}
/>
</div>
</CardContent>
</Card>
{/* AI Features */}
<Card>
<CardHeader>
<CardTitle className="text-base">AI Features</CardTitle>
<CardDescription>AI-powered evaluation assistance</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="aiSummaryEnabled">AI Evaluation Summary</Label>
<p className="text-xs text-muted-foreground">Generate AI synthesis of all jury evaluations</p>
</div>
<Switch
id="aiSummaryEnabled"
checked={(config.aiSummaryEnabled as boolean) ?? false}
onCheckedChange={(v) => update('aiSummaryEnabled', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="generateAiShortlist">AI Shortlist Recommendations</Label>
<p className="text-xs text-muted-foreground">AI suggests which projects should advance</p>
</div>
<Switch
id="generateAiShortlist"
checked={(config.generateAiShortlist as boolean) ?? false}
onCheckedChange={(v) => update('generateAiShortlist', v)}
/>
</div>
<div className="pt-2 border-t space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="autoRankEnabled">AI Ranking</Label>
<p className="text-xs text-muted-foreground">Rank projects using AI when all evaluations are complete. Configure ranking criteria and weights in the Ranking tab.</p>
</div>
<Switch
id="autoRankEnabled"
checked={(config.autoRankEnabled as boolean) ?? false}
onCheckedChange={(v) => update('autoRankEnabled', v)}
/>
</div>
</div>
</CardContent>
</Card>
{/* Applicant Feedback Visibility */}
<Card>
<CardHeader>
<CardTitle className="text-base">Applicant Feedback Visibility</CardTitle>
<CardDescription>Control what evaluation data applicants can see after this round closes</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="applicantVisEnabled">Show Evaluations to Applicants</Label>
<p className="text-xs text-muted-foreground">Master switch when off, nothing is visible to applicants</p>
</div>
<Switch
id="applicantVisEnabled"
checked={visConfig.enabled ?? false}
onCheckedChange={(v) => updateVisibility('enabled', v)}
/>
</div>
{visConfig.enabled && (
<div className="pl-6 border-l-2 border-muted space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="showGlobalScore">Show Global Score</Label>
<p className="text-xs text-muted-foreground">Display the overall score for each evaluation</p>
</div>
<Switch
id="showGlobalScore"
checked={visConfig.showGlobalScore ?? false}
onCheckedChange={(v) => updateVisibility('showGlobalScore', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="showCriterionScores">Show Per-Criterion Scores</Label>
<p className="text-xs text-muted-foreground">Display individual criterion scores and names</p>
</div>
<Switch
id="showCriterionScores"
checked={visConfig.showCriterionScores ?? false}
onCheckedChange={(v) => updateVisibility('showCriterionScores', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="showFeedbackText">Show Written Feedback</Label>
<p className="text-xs text-muted-foreground">Display jury members&apos; written comments</p>
</div>
<Switch
id="showFeedbackText"
checked={visConfig.showFeedbackText ?? false}
onCheckedChange={(v) => updateVisibility('showFeedbackText', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="hideFromRejected">Hide from Rejected Applicants</Label>
<p className="text-xs text-muted-foreground">Applicants whose project was rejected will not see evaluations from this round</p>
</div>
<Switch
id="hideFromRejected"
checked={visConfig.hideFromRejected ?? false}
onCheckedChange={(v) => updateVisibility('hideFromRejected', v)}
/>
</div>
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
Evaluations are only visible to applicants after this round closes.
</p>
</div>
)}
</CardContent>
</Card>
{/* Advancement */}
<Card>
<CardHeader>
<CardTitle className="text-base">Advancement Rules</CardTitle>
<CardDescription>How projects move to the next round</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="advancementMode">Advancement Mode</Label>
<Select
value={advancementMode}
onValueChange={(v) => update('advancementMode', v)}
>
<SelectTrigger id="advancementMode" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin_selection">Admin Selection (manual)</SelectItem>
<SelectItem value="auto_top_n">Auto Top-N (by score)</SelectItem>
<SelectItem value="ai_recommended">AI Recommended</SelectItem>
</SelectContent>
</Select>
</div>
{advancementMode === 'auto_top_n' && (
<div className="pl-6 border-l-2 border-muted space-y-4">
<Label className="text-sm font-medium">Auto Top-N Settings</Label>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="perCategory">Per Category</Label>
<p className="text-xs text-muted-foreground">Apply limits separately for each category</p>
</div>
<Switch
id="perCategory"
checked={advancementConfig.perCategory ?? true}
onCheckedChange={(v) => updateAdvancement('perCategory', v)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="startupCount">Startup Advancement Count</Label>
<p className="text-xs text-muted-foreground">Number of startups to advance</p>
<Input
id="startupCount"
type="number"
min={0}
className="w-32"
value={advancementConfig.startupCount ?? 10}
onChange={(e) => updateAdvancement('startupCount', parseInt(e.target.value, 10) || 0)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="conceptCount">Business Concept Advancement Count</Label>
<p className="text-xs text-muted-foreground">Number of business concepts to advance</p>
<Input
id="conceptCount"
type="number"
min={0}
className="w-32"
value={advancementConfig.conceptCount ?? 10}
onChange={(e) => updateAdvancement('conceptCount', parseInt(e.target.value, 10) || 0)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="tieBreaker">Tie Breaker</Label>
<p className="text-xs text-muted-foreground">How to handle tied scores</p>
<Select
value={advancementConfig.tieBreaker ?? 'admin_decides'}
onValueChange={(v) => updateAdvancement('tieBreaker', v)}
>
<SelectTrigger id="tieBreaker" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin_decides">Admin Decides</SelectItem>
<SelectItem value="highest_individual">Highest Individual Score</SelectItem>
<SelectItem value="revote">Re-vote</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)
}