Files
MOPC-Portal/src/components/jury/live-voting-form.tsx

328 lines
11 KiB
TypeScript
Raw Normal View History

'use client'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Slider } from '@/components/ui/slider'
import { Textarea } from '@/components/ui/textarea'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { CheckCircle2, Pencil } from 'lucide-react'
interface LiveVotingCriterion {
id: string
label: string
description?: string
scale: number
weight: number
}
interface LiveVotingFormProps {
projectId: string
votingMode?: 'simple' | 'criteria'
criteria?: LiveVotingCriterion[]
onVoteSubmit: (vote: {
score: number
criterionScores?: Record<string, number>
comment?: string
}) => void
disabled?: boolean
existingVote?: {
score: number
criterionScoresJson?: Record<string, number>
comment?: string | null
} | null
/** Visual emphasis when the admin opens the scoring phase */
highlighted?: boolean
}
export function LiveVotingForm({
projectId,
votingMode = 'simple',
criteria,
onVoteSubmit,
disabled = false,
existingVote,
highlighted = false,
}: LiveVotingFormProps) {
const [score, setScore] = useState(existingVote?.score ?? 5)
const [criterionScores, setCriterionScores] = useState<Record<string, number>>(
existingVote?.criterionScoresJson ?? {}
)
const [comment, setComment] = useState(existingVote?.comment ?? '')
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false)
const [editing, setEditing] = useState(!existingVote)
// When the ceremony cursor moves to a new project, reset the form state
useEffect(() => {
setScore(existingVote?.score ?? 5)
setCriterionScores(existingVote?.criterionScoresJson ?? {})
setComment(existingVote?.comment ?? '')
setEditing(!existingVote)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId])
const handleConfirm = () => {
if (votingMode === 'criteria' && criteria) {
// The server recomputes the weighted score from criterionScores; this
// 1-10 value is only a fallback and MUST stay within the API's range.
let weightedSum = 0
for (const c of criteria) {
const normalizedScore = ((criterionScores[c.id] ?? 0) / c.scale) * 10
weightedSum += normalizedScore * c.weight
}
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum)))
onVoteSubmit({
score: computedScore,
criterionScores,
comment: comment.trim() || undefined,
})
} else {
onVoteSubmit({ score, comment: comment.trim() || undefined })
}
setEditing(false)
setConfirmDialogOpen(false)
}
if (!editing) {
return (
<Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
<CardContent className="flex flex-col items-center justify-center py-10">
<CheckCircle2 className="mb-3 h-12 w-12 text-green-600" />
<p className="font-medium">Vote Submitted</p>
{votingMode === 'criteria' && criteria ? (
<div className="mt-3 text-sm text-muted-foreground space-y-1">
{criteria.map((c) => (
<div key={c.id} className="flex justify-between gap-6">
<span>{c.label}:</span>
<span className="font-medium">
{criterionScores[c.id] ?? 0}/{c.scale}
</span>
</div>
))}
</div>
) : (
<p className="mt-1 text-sm text-muted-foreground">Score: {score}/10</p>
)}
{comment.trim() && (
<p className="mt-3 max-w-md text-center text-xs italic text-muted-foreground">
{comment.trim()}
</p>
)}
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => setEditing(true)}
disabled={disabled}
>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit vote
</Button>
<p className="mt-2 text-xs text-muted-foreground">
You can revise your vote until the session closes
</p>
</CardContent>
</Card>
)
}
const commentField = (
<div className="space-y-2">
<Label className="text-sm">Comment (optional)</Label>
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Visible to admins alongside your scores…"
rows={2}
/>
</div>
)
// Criteria-based voting
if (votingMode === 'criteria' && criteria && criteria.length > 0) {
const allScored = criteria.every(
(c) => criterionScores[c.id] !== undefined && criterionScores[c.id] > 0
)
return (
<>
<Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
<CardHeader>
<CardTitle>Score This Project</CardTitle>
<CardDescription>Score each criterion individually</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{criteria.map((criterion) => (
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<Label className="text-base font-semibold">{criterion.label}</Label>
{criterion.description && (
<p className="text-xs text-muted-foreground mt-1">{criterion.description}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Weight: {(criterion.weight * 100).toFixed(0)}%
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Input
type="number"
min="1"
max={criterion.scale}
value={criterionScores[criterion.id] ?? ''}
onChange={(e) => {
const val = parseInt(e.target.value, 10)
if (!isNaN(val)) {
setCriterionScores({
...criterionScores,
[criterion.id]: Math.min(criterion.scale, Math.max(1, val)),
})
}
}}
className="w-20 text-center"
placeholder="0"
/>
<span className="text-lg font-bold">/ {criterion.scale}</span>
</div>
</div>
<Slider
value={[criterionScores[criterion.id] ?? 0]}
onValueChange={(values) =>
setCriterionScores({
...criterionScores,
[criterion.id]: Math.max(1, values[0]),
})
}
min={0}
max={criterion.scale}
step={1}
className="w-full"
/>
</div>
))}
{commentField}
<Button
onClick={() => setConfirmDialogOpen(true)}
disabled={!allScored || disabled}
className="w-full"
size="lg"
>
{existingVote ? 'Update Vote' : 'Submit Vote'}
</Button>
</CardContent>
</Card>
<AlertDialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2 mt-2">
<p className="font-medium">Your scores:</p>
{criteria.map((c) => (
<div key={c.id} className="flex justify-between text-sm">
<span>{c.label}:</span>
<span className="font-semibold">
{criterionScores[c.id]}/{c.scale}
</span>
</div>
))}
<p className="text-xs text-muted-foreground mt-3">
You can still revise your vote until the session closes.
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}>Confirm Vote</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
// Simple voting (1-10 slider — matches the API contract)
return (
<>
<Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
<CardHeader>
<CardTitle>Score This Project</CardTitle>
<CardDescription>Rate this project on a scale of 1-10</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>Score</Label>
<div className="flex items-center gap-3">
<Input
type="number"
min="1"
max="10"
value={score}
onChange={(e) =>
setScore(Math.min(10, Math.max(1, parseInt(e.target.value) || 1)))
}
className="w-20 text-center"
/>
<span className="text-2xl font-bold text-primary">{score}</span>
</div>
</div>
<Slider
value={[score]}
onValueChange={(values) => setScore(Math.max(1, values[0]))}
min={1}
max={10}
step={1}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Poor (1)</span>
<span>Average (5)</span>
<span>Excellent (10)</span>
</div>
</div>
{commentField}
<Button onClick={() => setConfirmDialogOpen(true)} className="w-full" size="lg" disabled={disabled}>
{existingVote ? 'Update Vote' : 'Submit Vote'}
</Button>
</CardContent>
</Card>
<AlertDialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
<AlertDialogDescription>
You are about to submit a score of <strong>{score}/10</strong>. You can still revise
it until the session closes.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}>Confirm Vote</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}