MOPC-App/src/components/forms/evaluation-form.tsx

601 lines
19 KiB
TypeScript
Raw Normal View History

'use client'
import { useState, useEffect, useCallback, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useDebouncedCallback } from 'use-debounce'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Loader2,
Save,
Send,
CheckCircle2,
AlertCircle,
Clock,
Star,
ThumbsUp,
ThumbsDown,
} from 'lucide-react'
import { cn } from '@/lib/utils'
// Define criterion type from the evaluation form JSON
interface Criterion {
id: string
label: string
description?: string
scale: number // max value (e.g., 5 or 10)
weight?: number
required?: boolean
}
interface EvaluationFormProps {
assignmentId: string
evaluationId: string | null
projectTitle: string
criteria: Criterion[]
initialData?: {
criterionScoresJson: Record<string, number> | null
globalScore: number | null
binaryDecision: boolean | null
feedbackText: string | null
status: string
}
isVotingOpen: boolean
deadline?: Date | null
}
const createEvaluationSchema = (criteria: Criterion[]) =>
z.object({
criterionScores: z.record(z.number()),
globalScore: z.number().int().min(1).max(10),
binaryDecision: z.boolean(),
feedbackText: z.string().min(10, 'Please provide at least 10 characters of feedback'),
})
type EvaluationFormData = z.infer<ReturnType<typeof createEvaluationSchema>>
export function EvaluationForm({
assignmentId,
evaluationId,
projectTitle,
criteria,
initialData,
isVotingOpen,
deadline,
}: EvaluationFormProps) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [lastSaved, setLastSaved] = useState<Date | null>(null)
// Initialize criterion scores with existing data or defaults
const defaultCriterionScores: Record<string, number> = {}
criteria.forEach((c) => {
defaultCriterionScores[c.id] = initialData?.criterionScoresJson?.[c.id] ?? Math.ceil(c.scale / 2)
})
const form = useForm<EvaluationFormData>({
resolver: zodResolver(createEvaluationSchema(criteria)),
defaultValues: {
criterionScores: defaultCriterionScores,
globalScore: initialData?.globalScore ?? 5,
binaryDecision: initialData?.binaryDecision ?? false,
feedbackText: initialData?.feedbackText ?? '',
},
mode: 'onChange',
})
const { watch, handleSubmit, control, formState } = form
const { errors, isValid, isDirty } = formState
// tRPC mutations
const startEvaluation = trpc.evaluation.start.useMutation()
const autosave = trpc.evaluation.autosave.useMutation()
const submit = trpc.evaluation.submit.useMutation()
const utils = trpc.useUtils()
// State to track the current evaluation ID (might be created on first autosave)
const [currentEvaluationId, setCurrentEvaluationId] = useState<string | null>(evaluationId)
// Create evaluation if it doesn't exist
useEffect(() => {
if (!currentEvaluationId && isVotingOpen) {
startEvaluation.mutate(
{ assignmentId },
{
onSuccess: (data) => {
setCurrentEvaluationId(data.id)
},
}
)
}
}, [assignmentId, currentEvaluationId, isVotingOpen])
// Debounced autosave function
const debouncedAutosave = useDebouncedCallback(
async (data: EvaluationFormData) => {
if (!currentEvaluationId || !isVotingOpen) return
setAutosaveStatus('saving')
try {
await autosave.mutateAsync({
id: currentEvaluationId,
criterionScoresJson: data.criterionScores,
globalScore: data.globalScore,
binaryDecision: data.binaryDecision,
feedbackText: data.feedbackText,
})
setAutosaveStatus('saved')
setLastSaved(new Date())
// Reset to idle after a few seconds
setTimeout(() => setAutosaveStatus('idle'), 3000)
} catch (error) {
console.error('Autosave failed:', error)
setAutosaveStatus('error')
}
},
3000 // 3 second debounce
)
// Watch form values and trigger autosave
const watchedValues = watch()
useEffect(() => {
if (isDirty && isVotingOpen) {
debouncedAutosave(watchedValues)
}
}, [watchedValues, isDirty, isVotingOpen, debouncedAutosave])
// Submit handler
const onSubmit = async (data: EvaluationFormData) => {
if (!currentEvaluationId) return
try {
await submit.mutateAsync({
id: currentEvaluationId,
criterionScoresJson: data.criterionScores,
globalScore: data.globalScore,
binaryDecision: data.binaryDecision,
feedbackText: data.feedbackText,
})
// Invalidate queries and redirect
utils.assignment.myAssignments.invalidate()
startTransition(() => {
router.push(`/jury/projects/${assignmentId.split('-')[0]}/evaluation`)
router.refresh()
})
} catch (error) {
console.error('Submit failed:', error)
}
}
const isSubmitted = initialData?.status === 'SUBMITTED' || initialData?.status === 'LOCKED'
const isReadOnly = isSubmitted || !isVotingOpen
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Status bar */}
<div className="sticky top-0 z-10 -mx-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4 py-3 border-b">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<h2 className="font-semibold truncate max-w-[200px] sm:max-w-none">
{projectTitle}
</h2>
<AutosaveIndicator status={autosaveStatus} lastSaved={lastSaved} />
</div>
{!isReadOnly && (
<div className="flex items-center gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
disabled={!isValid || submit.isPending}
>
{submit.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Submit Evaluation
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Submit Evaluation?</AlertDialogTitle>
<AlertDialogDescription>
Once submitted, you cannot edit your evaluation. Please review
your scores and feedback before confirming.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleSubmit(onSubmit)}
disabled={submit.isPending}
>
{submit.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Confirm Submit
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
{isReadOnly && (
<Badge variant="secondary">
<CheckCircle2 className="mr-1 h-3 w-3" />
{isSubmitted ? 'Submitted' : 'Read Only'}
</Badge>
)}
</div>
</div>
{/* Criteria scoring */}
{criteria.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Evaluation Criteria</CardTitle>
<CardDescription>
Rate the project on each criterion below
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{criteria.map((criterion) => (
<CriterionField
key={criterion.id}
criterion={criterion}
control={control}
disabled={isReadOnly}
/>
))}
</CardContent>
</Card>
)}
{/* Global score */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Overall Score</CardTitle>
<CardDescription>
Rate the project overall on a scale of 1 to 10
</CardDescription>
</CardHeader>
<CardContent>
<Controller
name="globalScore"
control={control}
render={({ field }) => (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Poor</span>
<span className="text-4xl font-bold">{field.value}</span>
<span className="text-sm text-muted-foreground">Excellent</span>
</div>
<Slider
min={1}
max={10}
step={1}
value={[field.value]}
onValueChange={(v) => field.onChange(v[0])}
disabled={isReadOnly}
className="py-4"
/>
<div className="flex justify-between">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
<button
key={num}
type="button"
onClick={() => !isReadOnly && field.onChange(num)}
disabled={isReadOnly}
className={cn(
'w-8 h-8 rounded-full text-sm font-medium transition-colors',
field.value === num
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80',
isReadOnly && 'opacity-50 cursor-not-allowed'
)}
>
{num}
</button>
))}
</div>
</div>
)}
/>
</CardContent>
</Card>
{/* Binary decision */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Recommendation</CardTitle>
<CardDescription>
Do you recommend this project to advance to the next round?
</CardDescription>
</CardHeader>
<CardContent>
<Controller
name="binaryDecision"
control={control}
render={({ field }) => (
<div className="flex gap-4">
<Button
type="button"
variant={field.value ? 'default' : 'outline'}
className={cn(
'flex-1 h-20',
field.value && 'bg-green-600 hover:bg-green-700'
)}
onClick={() => !isReadOnly && field.onChange(true)}
disabled={isReadOnly}
>
<ThumbsUp className="mr-2 h-6 w-6" />
Yes, Recommend
</Button>
<Button
type="button"
variant={!field.value ? 'default' : 'outline'}
className={cn(
'flex-1 h-20',
!field.value && 'bg-red-600 hover:bg-red-700'
)}
onClick={() => !isReadOnly && field.onChange(false)}
disabled={isReadOnly}
>
<ThumbsDown className="mr-2 h-6 w-6" />
No, Do Not Recommend
</Button>
</div>
)}
/>
</CardContent>
</Card>
{/* Feedback text */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Written Feedback</CardTitle>
<CardDescription>
Provide constructive feedback for this project (minimum 10 characters)
</CardDescription>
</CardHeader>
<CardContent>
<Controller
name="feedbackText"
control={control}
render={({ field }) => (
<div className="space-y-2">
<Textarea
{...field}
placeholder="Share your thoughts on the project's strengths, weaknesses, and potential..."
rows={6}
maxLength={5000}
disabled={isReadOnly}
className={cn(
errors.feedbackText && 'border-destructive'
)}
/>
<div className="flex items-center justify-between">
{errors.feedbackText ? (
<p className="text-sm text-destructive">
{errors.feedbackText.message}
</p>
) : (
<p className="text-sm text-muted-foreground">
{field.value.length} characters
</p>
)}
</div>
</div>
)}
/>
</CardContent>
</Card>
{/* Error display */}
{submit.error && (
<Card className="border-destructive">
<CardContent className="flex items-center gap-2 py-4">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm text-destructive">
{submit.error.message || 'Failed to submit evaluation. Please try again.'}
</p>
</CardContent>
</Card>
)}
{/* Bottom submit button for mobile */}
{!isReadOnly && (
<div className="flex justify-end pb-safe">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
size="lg"
disabled={!isValid || submit.isPending}
className="w-full sm:w-auto"
>
{submit.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Submit Evaluation
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Submit Evaluation?</AlertDialogTitle>
<AlertDialogDescription>
Once submitted, you cannot edit your evaluation. Please review
your scores and feedback before confirming.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleSubmit(onSubmit)}
disabled={submit.isPending}
>
{submit.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Confirm Submit
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</form>
)
}
// Criterion field component
function CriterionField({
criterion,
control,
disabled,
}: {
criterion: Criterion
control: any
disabled: boolean
}) {
return (
<Controller
name={`criterionScores.${criterion.id}`}
control={control}
render={({ field }) => (
<div className="space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-base font-medium">{criterion.label}</Label>
{criterion.description && (
<p className="text-sm text-muted-foreground">
{criterion.description}
</p>
)}
</div>
<Badge variant="secondary" className="shrink-0">
{field.value}/{criterion.scale}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-4">1</span>
<Slider
min={1}
max={criterion.scale}
step={1}
value={[field.value]}
onValueChange={(v) => field.onChange(v[0])}
disabled={disabled}
className="flex-1"
/>
<span className="text-xs text-muted-foreground w-4">{criterion.scale}</span>
</div>
{/* Visual rating buttons */}
<div className="flex gap-1 flex-wrap">
{Array.from({ length: criterion.scale }, (_, i) => i + 1).map((num) => (
<button
key={num}
type="button"
onClick={() => !disabled && field.onChange(num)}
disabled={disabled}
className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
field.value === num
? 'bg-primary text-primary-foreground'
: field.value > num
? 'bg-primary/20 text-primary'
: 'bg-muted hover:bg-muted/80',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
{num}
</button>
))}
</div>
</div>
)}
/>
)
}
// Autosave indicator component
function AutosaveIndicator({
status,
lastSaved,
}: {
status: 'idle' | 'saving' | 'saved' | 'error'
lastSaved: Date | null
}) {
if (status === 'idle' && lastSaved) {
return (
<span className="text-xs text-muted-foreground hidden sm:inline">
Saved
</span>
)
}
if (status === 'saving') {
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="hidden sm:inline">Saving...</span>
</span>
)
}
if (status === 'saved') {
return (
<span className="flex items-center gap-1 text-xs text-green-600">
<CheckCircle2 className="h-3 w-3" />
<span className="hidden sm:inline">Saved</span>
</span>
)
}
if (status === 'error') {
return (
<span className="flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="h-3 w-3" />
<span className="hidden sm:inline">Save failed</span>
</span>
)
}
return null
}