367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
|
|
'use client'
|
||
|
|
|
||
|
|
import { useState } from 'react'
|
||
|
|
import { useParams, useSearchParams } from 'next/navigation'
|
||
|
|
import Link from 'next/link'
|
||
|
|
import { trpc } from '@/lib/trpc/client'
|
||
|
|
import {
|
||
|
|
Card,
|
||
|
|
CardContent,
|
||
|
|
CardDescription,
|
||
|
|
CardHeader,
|
||
|
|
CardTitle,
|
||
|
|
} from '@/components/ui/card'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import { Textarea } from '@/components/ui/textarea'
|
||
|
|
import { Badge } from '@/components/ui/badge'
|
||
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
||
|
|
import {
|
||
|
|
ArrowLeft,
|
||
|
|
BarChart3,
|
||
|
|
MessageSquare,
|
||
|
|
Send,
|
||
|
|
Loader2,
|
||
|
|
Lock,
|
||
|
|
User,
|
||
|
|
} from 'lucide-react'
|
||
|
|
import { toast } from 'sonner'
|
||
|
|
import { formatDate, cn, getInitials } from '@/lib/utils'
|
||
|
|
|
||
|
|
export default function DiscussionPage() {
|
||
|
|
const params = useParams()
|
||
|
|
const searchParams = useSearchParams()
|
||
|
|
const projectId = params.id as string
|
||
|
|
const roundId = searchParams.get('roundId') || ''
|
||
|
|
|
||
|
|
const [commentText, setCommentText] = useState('')
|
||
|
|
|
||
|
|
const utils = trpc.useUtils()
|
||
|
|
|
||
|
|
// Fetch peer summary
|
||
|
|
const { data: peerSummary, isLoading: loadingSummary } =
|
||
|
|
trpc.evaluation.getPeerSummary.useQuery(
|
||
|
|
{ projectId, roundId },
|
||
|
|
{ enabled: !!roundId }
|
||
|
|
)
|
||
|
|
|
||
|
|
// Fetch discussion thread
|
||
|
|
const { data: discussion, isLoading: loadingDiscussion } =
|
||
|
|
trpc.evaluation.getDiscussion.useQuery(
|
||
|
|
{ projectId, roundId },
|
||
|
|
{ enabled: !!roundId }
|
||
|
|
)
|
||
|
|
|
||
|
|
// Add comment mutation
|
||
|
|
const addCommentMutation = trpc.evaluation.addComment.useMutation({
|
||
|
|
onSuccess: () => {
|
||
|
|
utils.evaluation.getDiscussion.invalidate({ projectId, roundId })
|
||
|
|
toast.success('Comment added')
|
||
|
|
setCommentText('')
|
||
|
|
},
|
||
|
|
onError: (e) => toast.error(e.message),
|
||
|
|
})
|
||
|
|
|
||
|
|
const handleSubmitComment = () => {
|
||
|
|
if (!commentText.trim()) {
|
||
|
|
toast.error('Please enter a comment')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
addCommentMutation.mutate({
|
||
|
|
projectId,
|
||
|
|
roundId,
|
||
|
|
content: commentText.trim(),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const isLoading = loadingSummary || loadingDiscussion
|
||
|
|
|
||
|
|
if (!roundId) {
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
<Button variant="ghost" asChild className="-ml-4">
|
||
|
|
<Link href="/jury/assignments">
|
||
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||
|
|
Back to Assignments
|
||
|
|
</Link>
|
||
|
|
</Button>
|
||
|
|
<Card>
|
||
|
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||
|
|
<MessageSquare className="h-12 w-12 text-muted-foreground/50" />
|
||
|
|
<p className="mt-2 font-medium">No round specified</p>
|
||
|
|
<p className="text-sm text-muted-foreground">
|
||
|
|
Please access the discussion from your assignments page.
|
||
|
|
</p>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isLoading) {
|
||
|
|
return <DiscussionSkeleton />
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse peer summary data
|
||
|
|
const summary = peerSummary as Record<string, unknown> | undefined
|
||
|
|
const averageScore = summary ? Number(summary.averageScore || 0) : 0
|
||
|
|
const scoreRange = summary?.scoreRange as { min: number; max: number } | undefined
|
||
|
|
const evaluationCount = summary ? Number(summary.evaluationCount || 0) : 0
|
||
|
|
const individualScores = (summary?.scores || summary?.individualScores) as
|
||
|
|
| Array<number>
|
||
|
|
| undefined
|
||
|
|
|
||
|
|
// Parse discussion data
|
||
|
|
const discussionData = discussion as Record<string, unknown> | undefined
|
||
|
|
const comments = (discussionData?.comments || []) as Array<{
|
||
|
|
id: string
|
||
|
|
user: { id: string; name: string | null; email: string }
|
||
|
|
content: string
|
||
|
|
createdAt: string
|
||
|
|
}>
|
||
|
|
const discussionStatus = String(discussionData?.status || 'OPEN')
|
||
|
|
const isClosed = discussionStatus === 'CLOSED'
|
||
|
|
const closedAt = discussionData?.closedAt as string | undefined
|
||
|
|
const closedBy = discussionData?.closedBy as Record<string, unknown> | undefined
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{/* Header */}
|
||
|
|
<div className="flex items-center gap-4">
|
||
|
|
<Button variant="ghost" asChild className="-ml-4">
|
||
|
|
<Link href="/jury/assignments">
|
||
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||
|
|
Back to Assignments
|
||
|
|
</Link>
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||
|
|
Project Discussion
|
||
|
|
</h1>
|
||
|
|
<p className="text-muted-foreground">
|
||
|
|
Peer review discussion and anonymized score summary
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Peer Summary Card */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
||
|
|
<BarChart3 className="h-5 w-5" />
|
||
|
|
Peer Summary
|
||
|
|
</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Anonymized scoring overview across all evaluations
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
{/* Stats row */}
|
||
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
||
|
|
<div className="rounded-lg border p-3 text-center">
|
||
|
|
<p className="text-2xl font-bold">{averageScore.toFixed(1)}</p>
|
||
|
|
<p className="text-xs text-muted-foreground">Average Score</p>
|
||
|
|
</div>
|
||
|
|
<div className="rounded-lg border p-3 text-center">
|
||
|
|
<p className="text-2xl font-bold">
|
||
|
|
{scoreRange
|
||
|
|
? `${scoreRange.min.toFixed(1)} - ${scoreRange.max.toFixed(1)}`
|
||
|
|
: '--'}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-muted-foreground">Score Range</p>
|
||
|
|
</div>
|
||
|
|
<div className="rounded-lg border p-3 text-center">
|
||
|
|
<p className="text-2xl font-bold">{evaluationCount}</p>
|
||
|
|
<p className="text-xs text-muted-foreground">Evaluations</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Anonymized score bars */}
|
||
|
|
{individualScores && individualScores.length > 0 && (
|
||
|
|
<div className="space-y-2">
|
||
|
|
<p className="text-sm font-medium">Anonymized Individual Scores</p>
|
||
|
|
<div className="flex items-end gap-2 h-24">
|
||
|
|
{individualScores.map((score, i) => {
|
||
|
|
const maxPossible = scoreRange?.max || 10
|
||
|
|
const height =
|
||
|
|
maxPossible > 0
|
||
|
|
? Math.max((score / maxPossible) * 100, 4)
|
||
|
|
: 4
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={i}
|
||
|
|
className="flex-1 flex flex-col items-center gap-1"
|
||
|
|
>
|
||
|
|
<span className="text-[10px] font-medium tabular-nums">
|
||
|
|
{score.toFixed(1)}
|
||
|
|
</span>
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
'w-full rounded-t transition-all',
|
||
|
|
score >= averageScore
|
||
|
|
? 'bg-primary/60'
|
||
|
|
: 'bg-muted-foreground/30'
|
||
|
|
)}
|
||
|
|
style={{ height: `${height}%` }}
|
||
|
|
/>
|
||
|
|
<span className="text-[10px] text-muted-foreground">
|
||
|
|
#{i + 1}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Discussion Section */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
||
|
|
<MessageSquare className="h-5 w-5" />
|
||
|
|
Discussion
|
||
|
|
</CardTitle>
|
||
|
|
{isClosed && (
|
||
|
|
<Badge variant="secondary" className="flex items-center gap-1">
|
||
|
|
<Lock className="h-3 w-3" />
|
||
|
|
Closed
|
||
|
|
{closedAt && (
|
||
|
|
<span className="ml-1">- {formatDate(closedAt)}</span>
|
||
|
|
)}
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<CardDescription>
|
||
|
|
{isClosed
|
||
|
|
? 'This discussion has been closed.'
|
||
|
|
: 'Share your thoughts with fellow jurors about this project.'}
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
{/* Comments */}
|
||
|
|
{comments.length > 0 ? (
|
||
|
|
<div className="space-y-4">
|
||
|
|
{comments.map((comment) => (
|
||
|
|
<div key={comment.id} className="flex gap-3">
|
||
|
|
{/* Avatar */}
|
||
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">
|
||
|
|
{comment.user?.name
|
||
|
|
? getInitials(comment.user.name)
|
||
|
|
: <User className="h-4 w-4" />}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Content */}
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<span className="text-sm font-medium">
|
||
|
|
{comment.user?.name || 'Anonymous Juror'}
|
||
|
|
</span>
|
||
|
|
<span className="text-xs text-muted-foreground">
|
||
|
|
{formatDate(comment.createdAt)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<p className="text-sm mt-1 whitespace-pre-wrap">
|
||
|
|
{comment.content}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||
|
|
<MessageSquare className="h-10 w-10 text-muted-foreground/30" />
|
||
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
||
|
|
No comments yet. Be the first to start the discussion.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Comment input */}
|
||
|
|
{!isClosed ? (
|
||
|
|
<div className="space-y-2 border-t pt-4">
|
||
|
|
<Textarea
|
||
|
|
placeholder="Write your comment..."
|
||
|
|
value={commentText}
|
||
|
|
onChange={(e) => setCommentText(e.target.value)}
|
||
|
|
rows={3}
|
||
|
|
/>
|
||
|
|
<div className="flex justify-end">
|
||
|
|
<Button
|
||
|
|
onClick={handleSubmitComment}
|
||
|
|
disabled={
|
||
|
|
addCommentMutation.isPending || !commentText.trim()
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{addCommentMutation.isPending ? (
|
||
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Send className="mr-2 h-4 w-4" />
|
||
|
|
)}
|
||
|
|
Post Comment
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="border-t pt-4">
|
||
|
|
<p className="text-sm text-muted-foreground text-center">
|
||
|
|
This discussion is closed and no longer accepts new comments.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function DiscussionSkeleton() {
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
<Skeleton className="h-9 w-36" />
|
||
|
|
<div>
|
||
|
|
<Skeleton className="h-8 w-64" />
|
||
|
|
<Skeleton className="h-4 w-40 mt-2" />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Peer summary skeleton */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<Skeleton className="h-5 w-40" />
|
||
|
|
<Skeleton className="h-4 w-56" />
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
||
|
|
{[1, 2, 3].map((i) => (
|
||
|
|
<Skeleton key={i} className="h-20 w-full" />
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
<Skeleton className="h-24 w-full" />
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Discussion skeleton */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<Skeleton className="h-5 w-32" />
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
{[1, 2, 3].map((i) => (
|
||
|
|
<div key={i} className="flex gap-3">
|
||
|
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||
|
|
<div className="flex-1 space-y-2">
|
||
|
|
<Skeleton className="h-4 w-32" />
|
||
|
|
<Skeleton className="h-4 w-full" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
<Skeleton className="h-20 w-full" />
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|