Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
600
src/components/forms/evaluation-form.tsx
Normal file
600
src/components/forms/evaluation-form.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
'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
|
||||
}
|
||||
Reference in New Issue
Block a user