125 lines
4.4 KiB
TypeScript
125 lines
4.4 KiB
TypeScript
|
|
'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>
|
||
|
|
)
|
||
|
|
}
|