Jury evaluation UX overhaul + admin review features
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s
- Fix project documents not displaying on jury project page (rewrote MultiWindowDocViewer to use file.listByProject) - Add working download/preview for project files via presigned URLs - Display project tags on jury project detail page - Add autosave for evaluation drafts (debounced 3s + save on unmount/beforeunload) - Support mixed criterion types: numeric scores, yes/no booleans, text responses, section headers - Replace inline criteria editor with rich EvaluationFormBuilder on admin round page - Remove COI dialog from evaluation page - Update AI summary service to handle boolean/text criteria (yes/no counts, text synthesis) - Update EvaluationSummaryCard to show boolean criteria bars and text responses - Add evaluation detail sheet on admin project page (click juror row to view full scores + feedback) - Add Recent Evaluations dashboard widget showing latest jury reviews Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ import { ProjectListCompact } from '@/components/dashboard/project-list-compact'
|
||||
import { ActivityFeed } from '@/components/dashboard/activity-feed'
|
||||
import { CategoryBreakdown } from '@/components/dashboard/category-breakdown'
|
||||
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton'
|
||||
import { RecentEvaluations } from '@/components/dashboard/recent-evaluations'
|
||||
|
||||
type DashboardContentProps = {
|
||||
editionId: string
|
||||
@@ -33,6 +34,10 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
{ editionId },
|
||||
{ enabled: !!editionId, retry: 1, refetchInterval: 30_000 }
|
||||
)
|
||||
const { data: recentEvals } = trpc.dashboard.getRecentEvaluations.useQuery(
|
||||
{ editionId, limit: 8 },
|
||||
{ enabled: !!editionId, refetchInterval: 30_000 }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardSkeleton />
|
||||
@@ -158,15 +163,21 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
<AnimatedCard index={3}>
|
||||
<ProjectListCompact projects={latestProjects} />
|
||||
</AnimatedCard>
|
||||
|
||||
{recentEvals && recentEvals.length > 0 && (
|
||||
<AnimatedCard index={4}>
|
||||
<RecentEvaluations evaluations={recentEvals} />
|
||||
</AnimatedCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-6 lg:col-span-4">
|
||||
<AnimatedCard index={4}>
|
||||
<AnimatedCard index={5}>
|
||||
<SmartActions actions={nextActions} />
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={5}>
|
||||
<AnimatedCard index={6}>
|
||||
<ActivityFeed activity={recentActivity} />
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
@@ -175,12 +186,12 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
{/* Bottom Full Width */}
|
||||
<div className="grid gap-6 lg:grid-cols-12">
|
||||
<div className="lg:col-span-8">
|
||||
<AnimatedCard index={6}>
|
||||
<AnimatedCard index={7}>
|
||||
<GeographicSummaryCard programId={editionId} />
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
<div className="lg:col-span-4">
|
||||
<AnimatedCard index={7}>
|
||||
<AnimatedCard index={8}>
|
||||
<CategoryBreakdown
|
||||
categories={categoryBreakdown}
|
||||
issues={oceanIssueBreakdown}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use } from 'react'
|
||||
import { Suspense, use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
@@ -28,6 +28,13 @@ import { FileUpload } from '@/components/shared/file-upload'
|
||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -51,6 +58,8 @@ import {
|
||||
UserPlus,
|
||||
Loader2,
|
||||
ScanSearch,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDate, formatDateOnly } from '@/lib/utils'
|
||||
@@ -117,6 +126,10 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// State for evaluation detail sheet
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null)
|
||||
|
||||
if (isLoading) {
|
||||
return <ProjectDetailSkeleton />
|
||||
}
|
||||
@@ -728,11 +741,20 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
<TableHead>Decision</TableHead>
|
||||
<TableHead className="w-10"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assignments.map((assignment) => (
|
||||
<TableRow key={assignment.id}>
|
||||
<TableRow
|
||||
key={assignment.id}
|
||||
className={assignment.evaluation?.status === 'SUBMITTED' ? 'cursor-pointer hover:bg-muted/50' : ''}
|
||||
onClick={() => {
|
||||
if (assignment.evaluation?.status === 'SUBMITTED') {
|
||||
setSelectedEvalAssignment(assignment)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
@@ -806,6 +828,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{assignment.evaluation?.status === 'SUBMITTED' && (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -815,6 +842,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Evaluation Detail Sheet */}
|
||||
<EvaluationDetailSheet
|
||||
assignment={selectedEvalAssignment}
|
||||
open={!!selectedEvalAssignment}
|
||||
onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }}
|
||||
/>
|
||||
|
||||
{/* AI Evaluation Summary */}
|
||||
{assignments && assignments.length > 0 && stats && stats.totalEvaluations > 0 && (
|
||||
<EvaluationSummaryCard
|
||||
@@ -897,6 +931,173 @@ function AnalyzeDocumentsButton({ projectId, onComplete }: { projectId: string;
|
||||
)
|
||||
}
|
||||
|
||||
function EvaluationDetailSheet({
|
||||
assignment,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
assignment: any
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
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
|
||||
|
||||
// Try to get the evaluation form for labels
|
||||
const roundId = assignment.roundId as string | undefined
|
||||
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||
{ roundId: roundId ?? '' },
|
||||
{ enabled: !!roundId }
|
||||
)
|
||||
|
||||
// Build label lookup from form criteria
|
||||
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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="sm:max-w-lg overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<UserAvatar user={assignment.user} avatarUrl={assignment.user.avatarUrl} size="sm" />
|
||||
{assignment.user.name || assignment.user.email}
|
||||
</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}/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 ? (
|
||||
<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 && (
|
||||
<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') {
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Feedback Text */}
|
||||
{ev.feedbackText && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Feedback
|
||||
</h4>
|
||||
<div className="text-sm text-muted-foreground p-3 rounded-lg border bg-muted/30 whitespace-pre-wrap leading-relaxed">
|
||||
{ev.feedbackText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProjectDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params)
|
||||
|
||||
|
||||
@@ -108,6 +108,8 @@ import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
||||
import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog'
|
||||
import { motion } from 'motion/react'
|
||||
import { EvaluationFormBuilder } from '@/components/forms/evaluation-form-builder'
|
||||
import type { Criterion } from '@/components/forms/evaluation-form-builder'
|
||||
|
||||
// ── Status & type config maps ──────────────────────────────────────────────
|
||||
const roundStatusConfig = {
|
||||
@@ -3118,12 +3120,9 @@ function AIRecommendationsDisplay({
|
||||
// ── Evaluation Criteria Editor ───────────────────────────────────────────
|
||||
|
||||
function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [criteria, setCriteria] = useState<Array<{
|
||||
id: string; label: string; description?: string; weight?: number; minScore?: number; maxScore?: number
|
||||
}>>([])
|
||||
|
||||
const [pendingCriteria, setPendingCriteria] = useState<Criterion[] | null>(null)
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: form, isLoading } = trpc.evaluation.getForm.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 30_000 },
|
||||
@@ -3133,49 +3132,59 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
|
||||
onSuccess: () => {
|
||||
utils.evaluation.getForm.invalidate({ roundId })
|
||||
toast.success('Evaluation criteria saved')
|
||||
setEditing(false)
|
||||
setPendingCriteria(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Sync from server
|
||||
if (form && !editing) {
|
||||
const serverCriteria = form.criteriaJson ?? []
|
||||
if (JSON.stringify(serverCriteria) !== JSON.stringify(criteria)) {
|
||||
setCriteria(serverCriteria)
|
||||
}
|
||||
}
|
||||
// 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 handleAdd = () => {
|
||||
setCriteria([...criteria, {
|
||||
id: `c-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
label: '',
|
||||
description: '',
|
||||
weight: 1,
|
||||
minScore: 0,
|
||||
maxScore: 10,
|
||||
}])
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
setCriteria(criteria.filter((c) => c.id !== id))
|
||||
}
|
||||
|
||||
const handleChange = (id: string, field: string, value: string | number) => {
|
||||
setCriteria(criteria.map((c) =>
|
||||
c.id === id ? { ...c, [field]: value } : c,
|
||||
))
|
||||
setEditing(true)
|
||||
}
|
||||
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, criteria: validCriteria })
|
||||
// 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,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -3185,30 +3194,22 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
|
||||
<div>
|
||||
<CardTitle className="text-base">Evaluation Criteria</CardTitle>
|
||||
<CardDescription>
|
||||
{form ? `Version ${form.version} \u2014 ${form.criteriaJson.length} criteria` : 'No criteria defined yet'}
|
||||
{form
|
||||
? `Version ${form.version} \u2014 ${(form.criteriaJson as Criterion[]).filter((c) => (c.type || 'numeric') !== 'section_header').length} criteria`
|
||||
: 'No criteria defined yet. Add numeric scores, yes/no questions, and text fields.'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{editing && (
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
setEditing(false)
|
||||
if (form) setCriteria(form.criteriaJson)
|
||||
}}>
|
||||
{pendingCriteria && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setPendingCriteria(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{editing ? (
|
||||
<Button size="sm" onClick={handleSave} disabled={upsertMutation.isPending}>
|
||||
{upsertMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
Save Criteria
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={handleAdd}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Add Criterion
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -3216,83 +3217,11 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
|
||||
</div>
|
||||
) : criteria.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<FileText className="h-8 w-8 mx-auto mb-2 opacity-40" />
|
||||
<p className="text-sm">No evaluation criteria defined</p>
|
||||
<p className="text-xs mt-1">Add criteria that jurors will use to score projects</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{criteria.map((c, idx) => (
|
||||
<div key={c.id} className="flex gap-3 items-start p-3 border rounded-lg bg-muted/20">
|
||||
<span className="text-xs text-muted-foreground font-mono mt-2.5 shrink-0 w-5 text-center">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input
|
||||
placeholder="Criterion label (e.g., Innovation)"
|
||||
value={c.label}
|
||||
onChange={(e) => handleChange(c.id, 'label', e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Description (optional)"
|
||||
value={c.description || ''}
|
||||
onChange={(e) => handleChange(c.id, 'description', e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-[10px] text-muted-foreground">Weight</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={c.weight ?? 1}
|
||||
onChange={(e) => handleChange(c.id, 'weight', Number(e.target.value))}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-[10px] text-muted-foreground">Min Score</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={c.minScore ?? 0}
|
||||
onChange={(e) => handleChange(c.id, 'minScore', Number(e.target.value))}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-[10px] text-muted-foreground">Max Score</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={c.maxScore ?? 10}
|
||||
onChange={(e) => handleChange(c.id, 'maxScore', Number(e.target.value))}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 mt-1 shrink-0"
|
||||
onClick={() => { handleRemove(c.id); setEditing(true) }}
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground hover:text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!editing && (
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={handleAdd}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Add Criterion
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<EvaluationFormBuilder
|
||||
initialCriteria={serverCriteria}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user