MOPC-App/src/app/(jury)/jury/projects/[id]/discussion/page.tsx

367 lines
12 KiB
TypeScript
Raw Normal View History

Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
'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>
)
}