Files
MOPC-Portal/src/components/admin/evaluation-edit-sheet.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

311 lines
10 KiB
TypeScript

'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { UserAvatar } from '@/components/shared/user-avatar'
import { toast } from 'sonner'
import { formatDate } from '@/lib/utils'
import {
BarChart3,
ThumbsUp,
ThumbsDown,
MessageSquare,
Pencil,
Loader2,
Check,
X,
} from 'lucide-react'
type EvaluationEditSheetProps = {
/** The assignment object with user, evaluation, and roundId */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assignment: any
open: boolean
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({
assignment,
open,
onOpenChange,
onSaved,
category,
}: EvaluationEditSheetProps) {
const [isEditing, setIsEditing] = useState(false)
const [editedFeedback, setEditedFeedback] = useState('')
const editMutation = trpc.evaluation.adminEditEvaluation.useMutation({
onSuccess: () => {
toast.success('Feedback updated')
setIsEditing(false)
onSaved?.()
},
onError: (err) => toast.error(err.message),
})
if (!assignment?.evaluation) return null
const ev = assignment.evaluation
const criterionScores = (ev.criterionScoresJson || {}) as Record<string, number | boolean | string>
const hasScores = Object.keys(criterionScores).length > 0
const roundId = assignment.roundId as string | undefined
return (
<Sheet open={open} onOpenChange={(v) => {
if (!v) setIsEditing(false)
onOpenChange(v)
}}>
<SheetContent className="sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
{assignment.user && (
<UserAvatar user={assignment.user} avatarUrl={assignment.user.avatarUrl} size="sm" />
)}
{assignment.user?.name || assignment.user?.email || 'Juror'}
</SheetTitle>
<SheetDescription>
{ev.submittedAt
? `Submitted ${formatDate(ev.submittedAt)}`
: 'Evaluation details'}
</SheetDescription>
</SheetHeader>
<div className="space-y-6 mt-6">
{/* Global stats */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-muted">
<p className="text-xs text-muted-foreground">Score</p>
<p className="text-2xl font-bold">
{ev.globalScore !== null && ev.globalScore !== undefined ? `${ev.globalScore}/10` : '-'}
</p>
</div>
<div className="p-3 rounded-lg bg-muted">
<p className="text-xs text-muted-foreground">Decision</p>
<div className="mt-1">
{ev.binaryDecision !== null && ev.binaryDecision !== undefined ? (
ev.binaryDecision ? (
<div className="flex items-center gap-1.5 text-emerald-600">
<ThumbsUp className="h-5 w-5" />
<span className="font-semibold">Yes</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-red-600">
<ThumbsDown className="h-5 w-5" />
<span className="font-semibold">No</span>
</div>
)
) : (
<span className="text-2xl font-bold">-</span>
)}
</div>
</div>
</div>
{/* Criterion Scores */}
{hasScores && (
<CriterionScoresSection criterionScores={criterionScores} roundId={roundId} category={category ?? assignment.project?.competitionCategory} />
)}
{/* Feedback Text — editable */}
<FeedbackSection
evaluationId={ev.id}
feedbackText={ev.feedbackText}
isEditing={isEditing}
editedFeedback={editedFeedback}
onStartEdit={() => {
setEditedFeedback(ev.feedbackText || '')
setIsEditing(true)
}}
onCancelEdit={() => setIsEditing(false)}
onSave={() => {
editMutation.mutate({
evaluationId: ev.id,
feedbackText: editedFeedback,
})
}}
onChangeFeedback={setEditedFeedback}
isSaving={editMutation.isPending}
/>
</div>
</SheetContent>
</Sheet>
)
}
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 ?? '', category },
{ enabled: !!roundId }
)
const criteriaMap = new Map<string, { label: string; type: string; trueLabel?: string; falseLabel?: string }>()
if (activeForm?.criteriaJson) {
for (const c of activeForm.criteriaJson as Array<{ id: string; label: string; type?: string; trueLabel?: string; falseLabel?: string }>) {
criteriaMap.set(c.id, {
label: c.label,
type: c.type || 'numeric',
trueLabel: c.trueLabel,
falseLabel: c.falseLabel,
})
}
}
return (
<div>
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Criterion Scores
</h4>
<div className="space-y-2.5">
{Object.entries(criterionScores).map(([key, value]) => {
const meta = criteriaMap.get(key)
const label = meta?.label || key
const type = meta?.type || (typeof value === 'boolean' ? 'boolean' : typeof value === 'string' ? 'text' : 'numeric')
if (type === 'section_header') return null
if (type === 'boolean' || type === 'advance') {
return (
<div key={key} className="flex items-center justify-between p-2.5 rounded-lg border">
<span className="text-sm">{label}</span>
{value === true ? (
<Badge className="bg-emerald-100 text-emerald-700 border-emerald-200" variant="outline">
<ThumbsUp className="mr-1 h-3 w-3" />
{meta?.trueLabel || 'Yes'}
</Badge>
) : (
<Badge className="bg-red-100 text-red-700 border-red-200" variant="outline">
<ThumbsDown className="mr-1 h-3 w-3" />
{meta?.falseLabel || 'No'}
</Badge>
)}
</div>
)
}
if (type === 'text') {
return (
<div key={key} className="space-y-1">
<span className="text-sm font-medium">{label}</span>
<div className="text-sm text-muted-foreground p-2.5 rounded-lg border bg-muted/50 whitespace-pre-wrap">
{typeof value === 'string' ? value : String(value)}
</div>
</div>
)
}
// Numeric
return (
<div key={key} className="flex items-center gap-3 p-2.5 rounded-lg border">
<span className="text-sm flex-1 truncate">{label}</span>
<div className="flex items-center gap-2 shrink-0">
<div className="w-20 h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${(Number(value) / 10) * 100}%` }}
/>
</div>
<span className="text-sm font-bold tabular-nums w-8 text-right">
{typeof value === 'number' ? value : '-'}
</span>
</div>
</div>
)
})}
</div>
</div>
)
}
function FeedbackSection({
evaluationId,
feedbackText,
isEditing,
editedFeedback,
onStartEdit,
onCancelEdit,
onSave,
onChangeFeedback,
isSaving,
}: {
evaluationId: string
feedbackText: string | null
isEditing: boolean
editedFeedback: string
onStartEdit: () => void
onCancelEdit: () => void
onSave: () => void
onChangeFeedback: (v: string) => void
isSaving: boolean
}) {
return (
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Feedback
</h4>
{!isEditing && evaluationId && (
<Button variant="ghost" size="sm" onClick={onStartEdit}>
<Pencil className="h-3 w-3 mr-1" />
Edit
</Button>
)}
</div>
{isEditing ? (
<div className="space-y-2">
<Textarea
value={editedFeedback}
onChange={(e) => onChangeFeedback(e.target.value)}
rows={8}
className="text-sm"
/>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={onCancelEdit} disabled={isSaving}>
<X className="h-3 w-3 mr-1" />
Cancel
</Button>
<Button size="sm" onClick={onSave} disabled={isSaving}>
{isSaving ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Check className="h-3 w-3 mr-1" />
)}
Save
</Button>
</div>
</div>
) : feedbackText ? (
<div className="text-sm text-muted-foreground p-3 rounded-lg border bg-muted/30 whitespace-pre-wrap leading-relaxed">
{feedbackText}
</div>
) : (
<p className="text-sm text-muted-foreground italic">No feedback provided</p>
)}
</div>
)
}