985 lines
31 KiB
TypeScript
985 lines
31 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback, useTransition, useMemo } 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 { Progress } from '@/components/ui/progress'
|
|
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'
|
|
import { toast } from 'sonner'
|
|
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
|
|
|
// Define criterion type from the evaluation form JSON
|
|
type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header'
|
|
|
|
interface CriterionCondition {
|
|
criterionId: string
|
|
operator: 'equals' | 'greaterThan' | 'lessThan'
|
|
value: number | string | boolean
|
|
}
|
|
|
|
interface Criterion {
|
|
id: string
|
|
label: string
|
|
description?: string
|
|
type?: CriterionType // defaults to 'numeric'
|
|
// Numeric
|
|
scale?: number // max value (e.g., 5 or 10)
|
|
weight?: number
|
|
required?: boolean
|
|
// Text
|
|
maxLength?: number
|
|
placeholder?: string
|
|
// Boolean
|
|
trueLabel?: string
|
|
falseLabel?: string
|
|
// Conditional visibility
|
|
condition?: CriterionCondition
|
|
// Section grouping
|
|
sectionId?: string
|
|
}
|
|
|
|
interface EvaluationFormProps {
|
|
assignmentId: string
|
|
evaluationId: string | null
|
|
projectTitle: string
|
|
criteria: Criterion[]
|
|
initialData?: {
|
|
criterionScoresJson: Record<string, number | string | boolean> | null
|
|
globalScore: number | null
|
|
binaryDecision: boolean | null
|
|
feedbackText: string | null
|
|
status: string
|
|
}
|
|
isVotingOpen: boolean
|
|
deadline?: Date | null
|
|
}
|
|
|
|
const createEvaluationSchema = (criteria: Criterion[]) => {
|
|
const criterionFields: Record<string, z.ZodTypeAny> = {}
|
|
for (const c of criteria) {
|
|
const type = c.type || 'numeric'
|
|
if (type === 'section_header') continue
|
|
if (type === 'numeric') {
|
|
criterionFields[c.id] = z.number()
|
|
} else if (type === 'text') {
|
|
criterionFields[c.id] = z.string()
|
|
} else if (type === 'boolean') {
|
|
criterionFields[c.id] = z.boolean()
|
|
}
|
|
}
|
|
return z.object({
|
|
criterionScores: z.object(criterionFields).passthrough(),
|
|
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 = {
|
|
criterionScores: Record<string, number | string | boolean>
|
|
globalScore: number
|
|
binaryDecision: boolean
|
|
feedbackText: string
|
|
}
|
|
|
|
/** Evaluate whether a condition is met based on current form values */
|
|
function evaluateCondition(
|
|
condition: CriterionCondition,
|
|
scores: Record<string, number | string | boolean>
|
|
): boolean {
|
|
const val = scores[condition.criterionId]
|
|
if (val === undefined) return false
|
|
switch (condition.operator) {
|
|
case 'equals':
|
|
return val === condition.value
|
|
case 'greaterThan':
|
|
return typeof val === 'number' && typeof condition.value === 'number' && val > condition.value
|
|
case 'lessThan':
|
|
return typeof val === 'number' && typeof condition.value === 'number' && val < condition.value
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
// Progress tracking state
|
|
const [touchedCriteria, setTouchedCriteria] = useState<Set<string>>(() => {
|
|
const initial = new Set<string>()
|
|
if (initialData?.criterionScoresJson) {
|
|
for (const key of Object.keys(initialData.criterionScoresJson)) {
|
|
initial.add(key)
|
|
}
|
|
}
|
|
return initial
|
|
})
|
|
const [globalScoreTouched, setGlobalScoreTouched] = useState(
|
|
() => initialData?.globalScore != null
|
|
)
|
|
const [decisionTouched, setDecisionTouched] = useState(
|
|
() => initialData?.binaryDecision != null
|
|
)
|
|
|
|
// Compute which criteria are scorable (not section headers)
|
|
const scorableCriteria = useMemo(
|
|
() => criteria.filter((c) => (c.type || 'numeric') !== 'section_header'),
|
|
[criteria]
|
|
)
|
|
|
|
// Initialize criterion scores with existing data or defaults
|
|
const defaultCriterionScores: Record<string, number | string | boolean> = {}
|
|
scorableCriteria.forEach((c) => {
|
|
const type = c.type || 'numeric'
|
|
const existing = initialData?.criterionScoresJson?.[c.id]
|
|
if (type === 'numeric') {
|
|
defaultCriterionScores[c.id] = typeof existing === 'number' ? existing : Math.ceil((c.scale ?? 5) / 2)
|
|
} else if (type === 'text') {
|
|
defaultCriterionScores[c.id] = typeof existing === 'string' ? existing : ''
|
|
} else if (type === 'boolean') {
|
|
defaultCriterionScores[c.id] = typeof existing === 'boolean' ? existing : false
|
|
}
|
|
})
|
|
|
|
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
|
|
|
|
// Progress tracking callbacks
|
|
const onCriterionTouch = useCallback((criterionId: string) => {
|
|
setTouchedCriteria((prev) => {
|
|
if (prev.has(criterionId)) return prev
|
|
const next = new Set(prev)
|
|
next.add(criterionId)
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
// Compute progress - section_headers count as always complete (skip them)
|
|
const feedbackValue = watch('feedbackText')
|
|
const watchedScores = watch('criterionScores')
|
|
const progress = useMemo(() => {
|
|
// Only count scorable criteria (not section headers)
|
|
const totalItems = scorableCriteria.length + 3
|
|
|
|
// Count completed criteria
|
|
let criteriaDone = 0
|
|
for (const c of scorableCriteria) {
|
|
const type = c.type || 'numeric'
|
|
if (type === 'numeric') {
|
|
if (touchedCriteria.has(c.id)) criteriaDone++
|
|
} else if (type === 'text') {
|
|
const val = watchedScores?.[c.id]
|
|
if (typeof val === 'string' && val.length > 0) criteriaDone++
|
|
} else if (type === 'boolean') {
|
|
if (touchedCriteria.has(c.id)) criteriaDone++
|
|
}
|
|
}
|
|
|
|
const completedItems =
|
|
criteriaDone +
|
|
(globalScoreTouched ? 1 : 0) +
|
|
(decisionTouched ? 1 : 0) +
|
|
(feedbackValue.length >= 10 ? 1 : 0)
|
|
const percentage = Math.round((completedItems / totalItems) * 100)
|
|
return { totalItems, completedItems, percentage, criteriaDone, criteriaTotal: scorableCriteria.length }
|
|
}, [scorableCriteria, touchedCriteria, watchedScores, globalScoreTouched, decisionTouched, feedbackValue])
|
|
|
|
// 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()
|
|
|
|
toast.success('Evaluation submitted successfully!')
|
|
|
|
startTransition(() => {
|
|
router.push('/jury/assignments')
|
|
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 min-w-0 flex-1">
|
|
<h2 className="font-semibold truncate max-w-[200px] sm:max-w-none">
|
|
{projectTitle}
|
|
</h2>
|
|
{!isReadOnly && deadline && (
|
|
<CountdownTimer deadline={new Date(deadline)} />
|
|
)}
|
|
{!isReadOnly && (
|
|
<ProgressIndicator
|
|
percentage={progress.percentage}
|
|
criteriaDone={progress.criteriaDone}
|
|
criteriaTotal={progress.criteriaTotal}
|
|
/>
|
|
)}
|
|
<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) => {
|
|
const type = criterion.type || 'numeric'
|
|
|
|
// Evaluate conditional visibility
|
|
if (criterion.condition) {
|
|
const conditionMet = evaluateCondition(criterion.condition, watchedScores ?? {})
|
|
if (!conditionMet) return null
|
|
}
|
|
|
|
// Section header
|
|
if (type === 'section_header') {
|
|
return <SectionHeaderField key={criterion.id} criterion={criterion} />
|
|
}
|
|
|
|
// Numeric (default)
|
|
if (type === 'numeric') {
|
|
return (
|
|
<NumericCriterionField
|
|
key={criterion.id}
|
|
criterion={criterion}
|
|
control={control}
|
|
disabled={isReadOnly}
|
|
onTouch={onCriterionTouch}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Text
|
|
if (type === 'text') {
|
|
return (
|
|
<TextCriterionField
|
|
key={criterion.id}
|
|
criterion={criterion}
|
|
control={control}
|
|
disabled={isReadOnly}
|
|
onTouch={onCriterionTouch}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Boolean
|
|
if (type === 'boolean') {
|
|
return (
|
|
<BooleanCriterionField
|
|
key={criterion.id}
|
|
criterion={criterion}
|
|
control={control}
|
|
disabled={isReadOnly}
|
|
onTouch={onCriterionTouch}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return null
|
|
})}
|
|
</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])
|
|
setGlobalScoreTouched(true)
|
|
}}
|
|
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={() => {
|
|
if (!isReadOnly) {
|
|
field.onChange(num)
|
|
setGlobalScoreTouched(true)
|
|
}
|
|
}}
|
|
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={() => {
|
|
if (!isReadOnly) {
|
|
field.onChange(true)
|
|
setDecisionTouched(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={() => {
|
|
if (!isReadOnly) {
|
|
field.onChange(false)
|
|
setDecisionTouched(true)
|
|
}
|
|
}}
|
|
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>
|
|
)
|
|
}
|
|
|
|
// Section header component (no input)
|
|
function SectionHeaderField({ criterion }: { criterion: Criterion }) {
|
|
return (
|
|
<div className="border-b pb-2 pt-4 first:pt-0">
|
|
<h3 className="font-semibold text-lg">{criterion.label}</h3>
|
|
{criterion.description && (
|
|
<p className="text-sm text-muted-foreground mt-1">{criterion.description}</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Numeric criterion field component (original behavior)
|
|
function NumericCriterionField({
|
|
criterion,
|
|
control,
|
|
disabled,
|
|
onTouch,
|
|
}: {
|
|
criterion: Criterion
|
|
control: any
|
|
disabled: boolean
|
|
onTouch: (criterionId: string) => void
|
|
}) {
|
|
const scale = criterion.scale ?? 5
|
|
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}/{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={scale}
|
|
step={1}
|
|
value={[typeof field.value === 'number' ? field.value : Math.ceil(scale / 2)]}
|
|
onValueChange={(v) => {
|
|
field.onChange(v[0])
|
|
onTouch(criterion.id)
|
|
}}
|
|
disabled={disabled}
|
|
className="flex-1"
|
|
/>
|
|
<span className="text-xs text-muted-foreground w-4">{scale}</span>
|
|
</div>
|
|
|
|
{/* Visual rating buttons */}
|
|
<div className="flex gap-1 flex-wrap">
|
|
{Array.from({ length: scale }, (_, i) => i + 1).map((num) => (
|
|
<button
|
|
key={num}
|
|
type="button"
|
|
onClick={() => {
|
|
if (!disabled) {
|
|
field.onChange(num)
|
|
onTouch(criterion.id)
|
|
}
|
|
}}
|
|
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>
|
|
)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Text criterion field component
|
|
function TextCriterionField({
|
|
criterion,
|
|
control,
|
|
disabled,
|
|
onTouch,
|
|
}: {
|
|
criterion: Criterion
|
|
control: any
|
|
disabled: boolean
|
|
onTouch: (criterionId: string) => void
|
|
}) {
|
|
return (
|
|
<Controller
|
|
name={`criterionScores.${criterion.id}`}
|
|
control={control}
|
|
render={({ field }) => (
|
|
<div className="space-y-3">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<Label className="text-base font-medium">{criterion.label}</Label>
|
|
{criterion.required && (
|
|
<Badge variant="destructive" className="text-xs">Required</Badge>
|
|
)}
|
|
</div>
|
|
{criterion.description && (
|
|
<p className="text-sm text-muted-foreground">
|
|
{criterion.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Textarea
|
|
value={typeof field.value === 'string' ? field.value : ''}
|
|
onChange={(e) => {
|
|
field.onChange(e.target.value)
|
|
onTouch(criterion.id)
|
|
}}
|
|
placeholder={criterion.placeholder || 'Enter your response...'}
|
|
rows={3}
|
|
maxLength={criterion.maxLength ?? 1000}
|
|
disabled={disabled}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{typeof field.value === 'string' ? field.value.length : 0}
|
|
{criterion.maxLength ? ` / ${criterion.maxLength}` : ''} characters
|
|
</p>
|
|
</div>
|
|
)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Boolean criterion field component
|
|
function BooleanCriterionField({
|
|
criterion,
|
|
control,
|
|
disabled,
|
|
onTouch,
|
|
}: {
|
|
criterion: Criterion
|
|
control: any
|
|
disabled: boolean
|
|
onTouch: (criterionId: string) => void
|
|
}) {
|
|
const trueLabel = criterion.trueLabel || 'Yes'
|
|
const falseLabel = criterion.falseLabel || 'No'
|
|
|
|
return (
|
|
<Controller
|
|
name={`criterionScores.${criterion.id}`}
|
|
control={control}
|
|
render={({ field }) => (
|
|
<div className="space-y-3">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<Label className="text-base font-medium">{criterion.label}</Label>
|
|
{criterion.required && (
|
|
<Badge variant="destructive" className="text-xs">Required</Badge>
|
|
)}
|
|
</div>
|
|
{criterion.description && (
|
|
<p className="text-sm text-muted-foreground">
|
|
{criterion.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<Button
|
|
type="button"
|
|
variant={field.value === true ? 'default' : 'outline'}
|
|
className={cn(
|
|
'flex-1 h-12',
|
|
field.value === true && 'bg-green-600 hover:bg-green-700'
|
|
)}
|
|
onClick={() => {
|
|
if (!disabled) {
|
|
field.onChange(true)
|
|
onTouch(criterion.id)
|
|
}
|
|
}}
|
|
disabled={disabled}
|
|
>
|
|
<ThumbsUp className="mr-2 h-4 w-4" />
|
|
{trueLabel}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant={field.value === false ? 'default' : 'outline'}
|
|
className={cn(
|
|
'flex-1 h-12',
|
|
field.value === false && 'bg-red-600 hover:bg-red-700'
|
|
)}
|
|
onClick={() => {
|
|
if (!disabled) {
|
|
field.onChange(false)
|
|
onTouch(criterion.id)
|
|
}
|
|
}}
|
|
disabled={disabled}
|
|
>
|
|
<ThumbsDown className="mr-2 h-4 w-4" />
|
|
{falseLabel}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Progress indicator component
|
|
function ProgressIndicator({
|
|
percentage,
|
|
criteriaDone,
|
|
criteriaTotal,
|
|
}: {
|
|
percentage: number
|
|
criteriaDone: number
|
|
criteriaTotal: number
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Progress value={percentage} className="w-16 sm:w-24 h-2" />
|
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
|
<span className="sm:hidden">{percentage}%</span>
|
|
<span className="hidden sm:inline">
|
|
{criteriaDone} of {criteriaTotal} criteria scored · {percentage}%
|
|
</span>
|
|
</span>
|
|
</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
|
|
}
|