Files
MOPC-Portal/src/components/admin/round/evaluation-criteria-editor.tsx

125 lines
4.4 KiB
TypeScript
Raw Normal View History

'use client'
import { useState, useMemo, useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
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 { 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
}
export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorProps) {
const [pendingCriteria, setPendingCriteria] = useState<Criterion[] | null>(null)
const utils = trpc.useUtils()
const { data: form, isLoading } = trpc.evaluation.getForm.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const upsertMutation = trpc.evaluation.upsertForm.useMutation({
onSuccess: () => {
utils.evaluation.getForm.invalidate({ roundId })
toast.success('Evaluation criteria saved')
setPendingCriteria(null)
},
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 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
}
// 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 (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Evaluation Criteria</CardTitle>
<CardDescription>
{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>
{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>
</CardHeader>
<CardContent>
{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}
/>
)}
</CardContent>
</Card>
)
}