Jury evaluation UX overhaul + admin review features
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s
- Fix project documents not displaying on jury project page (rewrote MultiWindowDocViewer to use file.listByProject) - Add working download/preview for project files via presigned URLs - Display project tags on jury project detail page - Add autosave for evaluation drafts (debounced 3s + save on unmount/beforeunload) - Support mixed criterion types: numeric scores, yes/no booleans, text responses, section headers - Replace inline criteria editor with rich EvaluationFormBuilder on admin round page - Remove COI dialog from evaluation page - Update AI summary service to handle boolean/text criteria (yes/no counts, text synthesis) - Update EvaluationSummaryCard to show boolean criteria bars and text responses - Add evaluation detail sheet on admin project page (click juror row to view full scores + feedback) - Add Recent Evaluations dashboard widget showing latest jury reviews Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ import { ProjectListCompact } from '@/components/dashboard/project-list-compact'
|
|||||||
import { ActivityFeed } from '@/components/dashboard/activity-feed'
|
import { ActivityFeed } from '@/components/dashboard/activity-feed'
|
||||||
import { CategoryBreakdown } from '@/components/dashboard/category-breakdown'
|
import { CategoryBreakdown } from '@/components/dashboard/category-breakdown'
|
||||||
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton'
|
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton'
|
||||||
|
import { RecentEvaluations } from '@/components/dashboard/recent-evaluations'
|
||||||
|
|
||||||
type DashboardContentProps = {
|
type DashboardContentProps = {
|
||||||
editionId: string
|
editionId: string
|
||||||
@@ -33,6 +34,10 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||||||
{ editionId },
|
{ editionId },
|
||||||
{ enabled: !!editionId, retry: 1, refetchInterval: 30_000 }
|
{ enabled: !!editionId, retry: 1, refetchInterval: 30_000 }
|
||||||
)
|
)
|
||||||
|
const { data: recentEvals } = trpc.dashboard.getRecentEvaluations.useQuery(
|
||||||
|
{ editionId, limit: 8 },
|
||||||
|
{ enabled: !!editionId, refetchInterval: 30_000 }
|
||||||
|
)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <DashboardSkeleton />
|
return <DashboardSkeleton />
|
||||||
@@ -158,15 +163,21 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||||||
<AnimatedCard index={3}>
|
<AnimatedCard index={3}>
|
||||||
<ProjectListCompact projects={latestProjects} />
|
<ProjectListCompact projects={latestProjects} />
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
|
{recentEvals && recentEvals.length > 0 && (
|
||||||
|
<AnimatedCard index={4}>
|
||||||
|
<RecentEvaluations evaluations={recentEvals} />
|
||||||
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column */}
|
{/* Right Column */}
|
||||||
<div className="space-y-6 lg:col-span-4">
|
<div className="space-y-6 lg:col-span-4">
|
||||||
<AnimatedCard index={4}>
|
<AnimatedCard index={5}>
|
||||||
<SmartActions actions={nextActions} />
|
<SmartActions actions={nextActions} />
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
<AnimatedCard index={5}>
|
<AnimatedCard index={6}>
|
||||||
<ActivityFeed activity={recentActivity} />
|
<ActivityFeed activity={recentActivity} />
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,12 +186,12 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||||||
{/* Bottom Full Width */}
|
{/* Bottom Full Width */}
|
||||||
<div className="grid gap-6 lg:grid-cols-12">
|
<div className="grid gap-6 lg:grid-cols-12">
|
||||||
<div className="lg:col-span-8">
|
<div className="lg:col-span-8">
|
||||||
<AnimatedCard index={6}>
|
<AnimatedCard index={7}>
|
||||||
<GeographicSummaryCard programId={editionId} />
|
<GeographicSummaryCard programId={editionId} />
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-4">
|
<div className="lg:col-span-4">
|
||||||
<AnimatedCard index={7}>
|
<AnimatedCard index={8}>
|
||||||
<CategoryBreakdown
|
<CategoryBreakdown
|
||||||
categories={categoryBreakdown}
|
categories={categoryBreakdown}
|
||||||
issues={oceanIssueBreakdown}
|
issues={oceanIssueBreakdown}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Suspense, use } from 'react'
|
import { Suspense, use, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
@@ -28,6 +28,13 @@ import { FileUpload } from '@/components/shared/file-upload'
|
|||||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
|
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -51,6 +58,8 @@ import {
|
|||||||
UserPlus,
|
UserPlus,
|
||||||
Loader2,
|
Loader2,
|
||||||
ScanSearch,
|
ScanSearch,
|
||||||
|
Eye,
|
||||||
|
MessageSquare,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { formatDate, formatDateOnly } from '@/lib/utils'
|
import { formatDate, formatDateOnly } from '@/lib/utils'
|
||||||
@@ -117,6 +126,10 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
// State for evaluation detail sheet
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <ProjectDetailSkeleton />
|
return <ProjectDetailSkeleton />
|
||||||
}
|
}
|
||||||
@@ -728,11 +741,20 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>Score</TableHead>
|
<TableHead>Score</TableHead>
|
||||||
<TableHead>Decision</TableHead>
|
<TableHead>Decision</TableHead>
|
||||||
|
<TableHead className="w-10"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{assignments.map((assignment) => (
|
{assignments.map((assignment) => (
|
||||||
<TableRow key={assignment.id}>
|
<TableRow
|
||||||
|
key={assignment.id}
|
||||||
|
className={assignment.evaluation?.status === 'SUBMITTED' ? 'cursor-pointer hover:bg-muted/50' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
if (assignment.evaluation?.status === 'SUBMITTED') {
|
||||||
|
setSelectedEvalAssignment(assignment)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -806,6 +828,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<span className="text-muted-foreground">-</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{assignment.evaluation?.status === 'SUBMITTED' && (
|
||||||
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -815,6 +842,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Evaluation Detail Sheet */}
|
||||||
|
<EvaluationDetailSheet
|
||||||
|
assignment={selectedEvalAssignment}
|
||||||
|
open={!!selectedEvalAssignment}
|
||||||
|
onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* AI Evaluation Summary */}
|
{/* AI Evaluation Summary */}
|
||||||
{assignments && assignments.length > 0 && stats && stats.totalEvaluations > 0 && (
|
{assignments && assignments.length > 0 && stats && stats.totalEvaluations > 0 && (
|
||||||
<EvaluationSummaryCard
|
<EvaluationSummaryCard
|
||||||
@@ -897,6 +931,173 @@ function AnalyzeDocumentsButton({ projectId, onComplete }: { projectId: string;
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EvaluationDetailSheet({
|
||||||
|
assignment,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
assignment: any
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
if (!assignment?.evaluation) return null
|
||||||
|
|
||||||
|
const ev = assignment.evaluation
|
||||||
|
const criterionScores = (ev.criterionScoresJson || {}) as Record<string, number | boolean | string>
|
||||||
|
const hasScores = Object.keys(criterionScores).length > 0
|
||||||
|
|
||||||
|
// Try to get the evaluation form for labels
|
||||||
|
const roundId = assignment.roundId as string | undefined
|
||||||
|
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||||
|
{ roundId: roundId ?? '' },
|
||||||
|
{ enabled: !!roundId }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build label lookup from form criteria
|
||||||
|
const criteriaMap = new Map<string, { label: string; type: string; trueLabel?: string; falseLabel?: string }>()
|
||||||
|
if (activeForm?.criteriaJson) {
|
||||||
|
for (const c of activeForm.criteriaJson as Array<{ id: string; label: string; type?: string; trueLabel?: string; falseLabel?: string }>) {
|
||||||
|
criteriaMap.set(c.id, {
|
||||||
|
label: c.label,
|
||||||
|
type: c.type || 'numeric',
|
||||||
|
trueLabel: c.trueLabel,
|
||||||
|
falseLabel: c.falseLabel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent className="sm:max-w-lg overflow-y-auto">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="flex items-center gap-2">
|
||||||
|
<UserAvatar user={assignment.user} avatarUrl={assignment.user.avatarUrl} size="sm" />
|
||||||
|
{assignment.user.name || assignment.user.email}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
{ev.submittedAt
|
||||||
|
? `Submitted ${formatDate(ev.submittedAt)}`
|
||||||
|
: 'Evaluation details'}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 mt-6">
|
||||||
|
{/* Global stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="p-3 rounded-lg bg-muted">
|
||||||
|
<p className="text-xs text-muted-foreground">Score</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{ev.globalScore !== null ? `${ev.globalScore}/10` : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-muted">
|
||||||
|
<p className="text-xs text-muted-foreground">Decision</p>
|
||||||
|
<div className="mt-1">
|
||||||
|
{ev.binaryDecision !== null ? (
|
||||||
|
ev.binaryDecision ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-emerald-600">
|
||||||
|
<ThumbsUp className="h-5 w-5" />
|
||||||
|
<span className="font-semibold">Yes</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 text-red-600">
|
||||||
|
<ThumbsDown className="h-5 w-5" />
|
||||||
|
<span className="font-semibold">No</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-2xl font-bold">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Criterion Scores */}
|
||||||
|
{hasScores && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
Criterion Scores
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{Object.entries(criterionScores).map(([key, value]) => {
|
||||||
|
const meta = criteriaMap.get(key)
|
||||||
|
const label = meta?.label || key
|
||||||
|
const type = meta?.type || (typeof value === 'boolean' ? 'boolean' : typeof value === 'string' ? 'text' : 'numeric')
|
||||||
|
|
||||||
|
if (type === 'section_header') return null
|
||||||
|
|
||||||
|
if (type === 'boolean') {
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center justify-between p-2.5 rounded-lg border">
|
||||||
|
<span className="text-sm">{label}</span>
|
||||||
|
{value === true ? (
|
||||||
|
<Badge className="bg-emerald-100 text-emerald-700 border-emerald-200" variant="outline">
|
||||||
|
<ThumbsUp className="mr-1 h-3 w-3" />
|
||||||
|
{meta?.trueLabel || 'Yes'}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-red-100 text-red-700 border-red-200" variant="outline">
|
||||||
|
<ThumbsDown className="mr-1 h-3 w-3" />
|
||||||
|
{meta?.falseLabel || 'No'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'text') {
|
||||||
|
return (
|
||||||
|
<div key={key} className="space-y-1">
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
<div className="text-sm text-muted-foreground p-2.5 rounded-lg border bg-muted/50 whitespace-pre-wrap">
|
||||||
|
{typeof value === 'string' ? value : String(value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center gap-3 p-2.5 rounded-lg border">
|
||||||
|
<span className="text-sm flex-1 truncate">{label}</span>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<div className="w-20 h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary"
|
||||||
|
style={{ width: `${(Number(value) / 10) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold tabular-nums w-8 text-right">
|
||||||
|
{typeof value === 'number' ? value : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feedback Text */}
|
||||||
|
{ev.feedbackText && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
Feedback
|
||||||
|
</h4>
|
||||||
|
<div className="text-sm text-muted-foreground p-3 rounded-lg border bg-muted/30 whitespace-pre-wrap leading-relaxed">
|
||||||
|
{ev.feedbackText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProjectDetailPage({ params }: PageProps) {
|
export default function ProjectDetailPage({ params }: PageProps) {
|
||||||
const { id } = use(params)
|
const { id } = use(params)
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ import { AnimatedCard } from '@/components/shared/animated-container'
|
|||||||
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
||||||
import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog'
|
import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
|
import { EvaluationFormBuilder } from '@/components/forms/evaluation-form-builder'
|
||||||
|
import type { Criterion } from '@/components/forms/evaluation-form-builder'
|
||||||
|
|
||||||
// ── Status & type config maps ──────────────────────────────────────────────
|
// ── Status & type config maps ──────────────────────────────────────────────
|
||||||
const roundStatusConfig = {
|
const roundStatusConfig = {
|
||||||
@@ -3118,12 +3120,9 @@ function AIRecommendationsDisplay({
|
|||||||
// ── Evaluation Criteria Editor ───────────────────────────────────────────
|
// ── Evaluation Criteria Editor ───────────────────────────────────────────
|
||||||
|
|
||||||
function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
|
function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
|
||||||
const [editing, setEditing] = useState(false)
|
const [pendingCriteria, setPendingCriteria] = useState<Criterion[] | null>(null)
|
||||||
const [criteria, setCriteria] = useState<Array<{
|
|
||||||
id: string; label: string; description?: string; weight?: number; minScore?: number; maxScore?: number
|
|
||||||
}>>([])
|
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
const { data: form, isLoading } = trpc.evaluation.getForm.useQuery(
|
const { data: form, isLoading } = trpc.evaluation.getForm.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
{ refetchInterval: 30_000 },
|
{ refetchInterval: 30_000 },
|
||||||
@@ -3133,49 +3132,59 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.evaluation.getForm.invalidate({ roundId })
|
utils.evaluation.getForm.invalidate({ roundId })
|
||||||
toast.success('Evaluation criteria saved')
|
toast.success('Evaluation criteria saved')
|
||||||
setEditing(false)
|
setPendingCriteria(null)
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sync from server
|
// Convert server criteriaJson to Criterion[] format
|
||||||
if (form && !editing) {
|
const serverCriteria: Criterion[] = useMemo(() => {
|
||||||
const serverCriteria = form.criteriaJson ?? []
|
if (!form?.criteriaJson) return []
|
||||||
if (JSON.stringify(serverCriteria) !== JSON.stringify(criteria)) {
|
return (form.criteriaJson as Criterion[]).map((c) => {
|
||||||
setCriteria(serverCriteria)
|
// Handle legacy numeric-only format: convert "scale" string like "1-10" back to minScore/maxScore
|
||||||
|
const type = c.type || 'numeric'
|
||||||
|
if (type === 'numeric' && typeof c.scale === 'string') {
|
||||||
|
const parts = (c.scale as string).split('-').map(Number)
|
||||||
|
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
||||||
|
return { ...c, type: 'numeric' as const, scale: parts[1], minScore: parts[0], maxScore: parts[1] } as unknown as Criterion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { ...c, type } as Criterion
|
||||||
|
})
|
||||||
|
}, [form?.criteriaJson])
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleChange = useCallback((criteria: Criterion[]) => {
|
||||||
setCriteria([...criteria, {
|
setPendingCriteria(criteria)
|
||||||
id: `c-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
}, [])
|
||||||
label: '',
|
|
||||||
description: '',
|
|
||||||
weight: 1,
|
|
||||||
minScore: 0,
|
|
||||||
maxScore: 10,
|
|
||||||
}])
|
|
||||||
setEditing(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemove = (id: string) => {
|
|
||||||
setCriteria(criteria.filter((c) => c.id !== id))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleChange = (id: string, field: string, value: string | number) => {
|
|
||||||
setCriteria(criteria.map((c) =>
|
|
||||||
c.id === id ? { ...c, [field]: value } : c,
|
|
||||||
))
|
|
||||||
setEditing(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
const criteria = pendingCriteria ?? serverCriteria
|
||||||
const validCriteria = criteria.filter((c) => c.label.trim())
|
const validCriteria = criteria.filter((c) => c.label.trim())
|
||||||
if (validCriteria.length === 0) {
|
if (validCriteria.length === 0) {
|
||||||
toast.error('Add at least one criterion')
|
toast.error('Add at least one criterion')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
upsertMutation.mutate({ roundId, criteria: validCriteria })
|
// Map to upsertForm format
|
||||||
|
upsertMutation.mutate({
|
||||||
|
roundId,
|
||||||
|
criteria: validCriteria.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.label,
|
||||||
|
description: c.description,
|
||||||
|
type: c.type || 'numeric',
|
||||||
|
weight: c.weight,
|
||||||
|
scale: typeof c.scale === 'number' ? c.scale : undefined,
|
||||||
|
minScore: (c as any).minScore,
|
||||||
|
maxScore: (c as any).maxScore,
|
||||||
|
required: c.required,
|
||||||
|
maxLength: c.maxLength,
|
||||||
|
placeholder: c.placeholder,
|
||||||
|
trueLabel: c.trueLabel,
|
||||||
|
falseLabel: c.falseLabel,
|
||||||
|
condition: c.condition,
|
||||||
|
sectionId: c.sectionId,
|
||||||
|
})),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -3185,30 +3194,22 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">Evaluation Criteria</CardTitle>
|
<CardTitle className="text-base">Evaluation Criteria</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{form ? `Version ${form.version} \u2014 ${form.criteriaJson.length} criteria` : 'No criteria defined yet'}
|
{form
|
||||||
|
? `Version ${form.version} \u2014 ${(form.criteriaJson as Criterion[]).filter((c) => (c.type || 'numeric') !== 'section_header').length} criteria`
|
||||||
|
: 'No criteria defined yet. Add numeric scores, yes/no questions, and text fields.'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
{pendingCriteria && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{editing && (
|
<Button size="sm" variant="outline" onClick={() => setPendingCriteria(null)}>
|
||||||
<Button size="sm" variant="outline" onClick={() => {
|
|
||||||
setEditing(false)
|
|
||||||
if (form) setCriteria(form.criteriaJson)
|
|
||||||
}}>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
{editing ? (
|
|
||||||
<Button size="sm" onClick={handleSave} disabled={upsertMutation.isPending}>
|
<Button size="sm" onClick={handleSave} disabled={upsertMutation.isPending}>
|
||||||
{upsertMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
{upsertMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
Save Criteria
|
Save Criteria
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
|
||||||
<Button size="sm" variant="outline" onClick={handleAdd}>
|
|
||||||
<Plus className="h-4 w-4 mr-1.5" />
|
|
||||||
Add Criterion
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -3216,83 +3217,11 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
|
||||||
</div>
|
</div>
|
||||||
) : criteria.length === 0 ? (
|
|
||||||
<div className="text-center py-6 text-muted-foreground">
|
|
||||||
<FileText className="h-8 w-8 mx-auto mb-2 opacity-40" />
|
|
||||||
<p className="text-sm">No evaluation criteria defined</p>
|
|
||||||
<p className="text-xs mt-1">Add criteria that jurors will use to score projects</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<EvaluationFormBuilder
|
||||||
{criteria.map((c, idx) => (
|
initialCriteria={serverCriteria}
|
||||||
<div key={c.id} className="flex gap-3 items-start p-3 border rounded-lg bg-muted/20">
|
onChange={handleChange}
|
||||||
<span className="text-xs text-muted-foreground font-mono mt-2.5 shrink-0 w-5 text-center">
|
|
||||||
{idx + 1}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<Input
|
|
||||||
placeholder="Criterion label (e.g., Innovation)"
|
|
||||||
value={c.label}
|
|
||||||
onChange={(e) => handleChange(c.id, 'label', e.target.value)}
|
|
||||||
className="h-8 text-sm"
|
|
||||||
/>
|
/>
|
||||||
<Input
|
|
||||||
placeholder="Description (optional)"
|
|
||||||
value={c.description || ''}
|
|
||||||
onChange={(e) => handleChange(c.id, 'description', e.target.value)}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label className="text-[10px] text-muted-foreground">Weight</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={c.weight ?? 1}
|
|
||||||
onChange={(e) => handleChange(c.id, 'weight', Number(e.target.value))}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label className="text-[10px] text-muted-foreground">Min Score</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={c.minScore ?? 0}
|
|
||||||
onChange={(e) => handleChange(c.id, 'minScore', Number(e.target.value))}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label className="text-[10px] text-muted-foreground">Max Score</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={c.maxScore ?? 10}
|
|
||||||
onChange={(e) => handleChange(c.id, 'maxScore', Number(e.target.value))}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 mt-1 shrink-0"
|
|
||||||
onClick={() => { handleRemove(c.id); setEditing(true) }}
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5 text-muted-foreground hover:text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!editing && (
|
|
||||||
<Button variant="outline" size="sm" className="w-full" onClick={handleAdd}>
|
|
||||||
<Plus className="h-4 w-4 mr-1.5" />
|
|
||||||
Add Criterion
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { use, useState, useEffect } from 'react'
|
import { use, useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
@@ -12,17 +12,9 @@ import { Textarea } from '@/components/ui/textarea'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from '@/components/ui/alert-dialog'
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock } from 'lucide-react'
|
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
||||||
|
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2 } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import type { EvaluationConfig } from '@/types/competition-configs'
|
import type { EvaluationConfig } from '@/types/competition-configs'
|
||||||
|
|
||||||
@@ -36,15 +28,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
const { roundId, projectId } = params
|
const { roundId, projectId } = params
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
const [showCOIDialog, setShowCOIDialog] = useState(true)
|
// Evaluation form state — stores all criterion values (numeric, boolean, text)
|
||||||
const [coiAccepted, setCoiAccepted] = useState(false)
|
const [criteriaValues, setCriteriaValues] = useState<Record<string, number | boolean | string>>({})
|
||||||
|
|
||||||
// Evaluation form state
|
|
||||||
const [criteriaScores, setCriteriaScores] = useState<Record<string, number>>({})
|
|
||||||
const [globalScore, setGlobalScore] = useState('')
|
const [globalScore, setGlobalScore] = useState('')
|
||||||
const [binaryDecision, setBinaryDecision] = useState<'accept' | 'reject' | ''>('')
|
const [binaryDecision, setBinaryDecision] = useState<'accept' | 'reject' | ''>('')
|
||||||
const [feedbackText, setFeedbackText] = useState('')
|
const [feedbackText, setFeedbackText] = useState('')
|
||||||
|
|
||||||
|
// Track dirty state for autosave
|
||||||
|
const isDirtyRef = useRef(false)
|
||||||
|
const evaluationIdRef = useRef<string | null>(null)
|
||||||
|
const isSubmittedRef = useRef(false)
|
||||||
|
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
|
||||||
|
|
||||||
// Fetch project
|
// Fetch project
|
||||||
const { data: project } = trpc.project.get.useQuery(
|
const { data: project } = trpc.project.get.useQuery(
|
||||||
{ id: projectId },
|
{ id: projectId },
|
||||||
@@ -71,7 +67,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
{ enabled: !!myAssignment?.id }
|
{ enabled: !!myAssignment?.id }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetch the active evaluation form for this round (independent of evaluation existence)
|
// Fetch the active evaluation form for this round
|
||||||
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
{ enabled: !!roundId }
|
{ enabled: !!roundId }
|
||||||
@@ -80,17 +76,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
// Start evaluation mutation (creates draft)
|
// Start evaluation mutation (creates draft)
|
||||||
const startMutation = trpc.evaluation.start.useMutation()
|
const startMutation = trpc.evaluation.start.useMutation()
|
||||||
|
|
||||||
// Autosave mutation
|
// Autosave mutation (silent)
|
||||||
const autosaveMutation = trpc.evaluation.autosave.useMutation({
|
const autosaveMutation = trpc.evaluation.autosave.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Draft saved', { duration: 1500 })
|
isDirtyRef.current = false
|
||||||
|
setLastSavedAt(new Date())
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Submit mutation
|
// Submit mutation
|
||||||
const submitMutation = trpc.evaluation.submit.useMutation({
|
const submitMutation = trpc.evaluation.submit.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
isSubmittedRef.current = true
|
||||||
|
isDirtyRef.current = false
|
||||||
utils.roundAssignment.getMyAssignments.invalidate()
|
utils.roundAssignment.getMyAssignments.invalidate()
|
||||||
utils.evaluation.get.invalidate()
|
utils.evaluation.get.invalidate()
|
||||||
toast.success('Evaluation submitted successfully')
|
toast.success('Evaluation submitted successfully')
|
||||||
@@ -99,15 +97,24 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Track evaluation ID
|
||||||
|
useEffect(() => {
|
||||||
|
if (existingEvaluation?.id) {
|
||||||
|
evaluationIdRef.current = existingEvaluation.id
|
||||||
|
}
|
||||||
|
}, [existingEvaluation?.id])
|
||||||
|
|
||||||
// Load existing evaluation data
|
// Load existing evaluation data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existingEvaluation) {
|
if (existingEvaluation) {
|
||||||
if (existingEvaluation.criterionScoresJson) {
|
if (existingEvaluation.criterionScoresJson) {
|
||||||
const scores: Record<string, number> = {}
|
const values: Record<string, number | boolean | string> = {}
|
||||||
Object.entries(existingEvaluation.criterionScoresJson).forEach(([key, value]) => {
|
Object.entries(existingEvaluation.criterionScoresJson).forEach(([key, value]) => {
|
||||||
scores[key] = typeof value === 'number' ? value : 0
|
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') {
|
||||||
|
values[key] = value
|
||||||
|
}
|
||||||
})
|
})
|
||||||
setCriteriaScores(scores)
|
setCriteriaValues(values)
|
||||||
}
|
}
|
||||||
if (existingEvaluation.globalScore) {
|
if (existingEvaluation.globalScore) {
|
||||||
setGlobalScore(existingEvaluation.globalScore.toString())
|
setGlobalScore(existingEvaluation.globalScore.toString())
|
||||||
@@ -118,6 +125,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
if (existingEvaluation.feedbackText) {
|
if (existingEvaluation.feedbackText) {
|
||||||
setFeedbackText(existingEvaluation.feedbackText)
|
setFeedbackText(existingEvaluation.feedbackText)
|
||||||
}
|
}
|
||||||
|
isDirtyRef.current = false
|
||||||
}
|
}
|
||||||
}, [existingEvaluation])
|
}, [existingEvaluation])
|
||||||
|
|
||||||
@@ -127,12 +135,12 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
const requireFeedback = evalConfig?.requireFeedback ?? true
|
const requireFeedback = evalConfig?.requireFeedback ?? true
|
||||||
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
|
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
|
||||||
|
|
||||||
// Get criteria from the active evaluation form (independent of evaluation record)
|
// Parse criteria from the active form
|
||||||
const criteria = (activeForm?.criteriaJson ?? []).map((c) => {
|
const criteria = (activeForm?.criteriaJson ?? []).map((c) => {
|
||||||
// Parse scale string like "1-10" into minScore/maxScore
|
const type = (c as any).type || 'numeric'
|
||||||
let minScore = 1
|
let minScore = 1
|
||||||
let maxScore = 10
|
let maxScore = 10
|
||||||
if (c.scale) {
|
if (type === 'numeric' && c.scale) {
|
||||||
const parts = c.scale.split('-').map(Number)
|
const parts = c.scale.split('-').map(Number)
|
||||||
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
||||||
minScore = parts[0]
|
minScore = parts[0]
|
||||||
@@ -143,33 +151,135 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
id: c.id,
|
id: c.id,
|
||||||
label: c.label,
|
label: c.label,
|
||||||
description: c.description,
|
description: c.description,
|
||||||
|
type: type as 'numeric' | 'text' | 'boolean' | 'section_header',
|
||||||
weight: c.weight,
|
weight: c.weight,
|
||||||
minScore,
|
minScore,
|
||||||
maxScore,
|
maxScore,
|
||||||
|
required: (c as any).required ?? true,
|
||||||
|
trueLabel: (c as any).trueLabel || 'Yes',
|
||||||
|
falseLabel: (c as any).falseLabel || 'No',
|
||||||
|
maxLength: (c as any).maxLength || 1000,
|
||||||
|
placeholder: (c as any).placeholder || '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Build current form data for autosave
|
||||||
|
const buildSavePayload = useCallback(() => {
|
||||||
|
return {
|
||||||
|
criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : undefined,
|
||||||
|
globalScore: scoringMode === 'global' && globalScore ? parseInt(globalScore, 10) : null,
|
||||||
|
binaryDecision: scoringMode === 'binary' && binaryDecision ? binaryDecision === 'accept' : null,
|
||||||
|
feedbackText: feedbackText || null,
|
||||||
|
}
|
||||||
|
}, [scoringMode, criteriaValues, globalScore, binaryDecision, feedbackText])
|
||||||
|
|
||||||
|
// Perform autosave
|
||||||
|
const performAutosave = useCallback(async () => {
|
||||||
|
if (!isDirtyRef.current || isSubmittedRef.current) return
|
||||||
|
if (existingEvaluation?.status === 'SUBMITTED') return
|
||||||
|
|
||||||
|
let evalId = evaluationIdRef.current
|
||||||
|
if (!evalId && myAssignment) {
|
||||||
|
try {
|
||||||
|
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
||||||
|
evalId = newEval.id
|
||||||
|
evaluationIdRef.current = evalId
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!evalId) return
|
||||||
|
|
||||||
|
autosaveMutation.mutate({ id: evalId, ...buildSavePayload() })
|
||||||
|
}, [myAssignment, existingEvaluation?.status, startMutation, autosaveMutation, buildSavePayload])
|
||||||
|
|
||||||
|
// Debounced autosave: save 3 seconds after last change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDirtyRef.current) return
|
||||||
|
|
||||||
|
if (autosaveTimerRef.current) {
|
||||||
|
clearTimeout(autosaveTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
autosaveTimerRef.current = setTimeout(() => {
|
||||||
|
performAutosave()
|
||||||
|
}, 3000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (autosaveTimerRef.current) {
|
||||||
|
clearTimeout(autosaveTimerRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [criteriaValues, globalScore, binaryDecision, feedbackText, performAutosave])
|
||||||
|
|
||||||
|
// Save on page leave (beforeunload)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = () => {
|
||||||
|
if (isDirtyRef.current && !isSubmittedRef.current && evaluationIdRef.current) {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
id: evaluationIdRef.current,
|
||||||
|
...buildSavePayload(),
|
||||||
|
})
|
||||||
|
navigator.sendBeacon?.('/api/trpc/evaluation.autosave', payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
}, [buildSavePayload])
|
||||||
|
|
||||||
|
// Save on component unmount (navigating away within the app)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (isDirtyRef.current && !isSubmittedRef.current && evaluationIdRef.current) {
|
||||||
|
performAutosave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Mark dirty when form values change
|
||||||
|
const handleCriterionChange = (key: string, value: number | boolean | string) => {
|
||||||
|
setCriteriaValues((prev) => ({ ...prev, [key]: value }))
|
||||||
|
isDirtyRef.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGlobalScoreChange = (value: string) => {
|
||||||
|
setGlobalScore(value)
|
||||||
|
isDirtyRef.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBinaryChange = (value: 'accept' | 'reject') => {
|
||||||
|
setBinaryDecision(value)
|
||||||
|
isDirtyRef.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFeedbackChange = (value: string) => {
|
||||||
|
setFeedbackText(value)
|
||||||
|
isDirtyRef.current = true
|
||||||
|
}
|
||||||
|
|
||||||
const handleSaveDraft = async () => {
|
const handleSaveDraft = async () => {
|
||||||
if (!myAssignment) {
|
if (!myAssignment) {
|
||||||
toast.error('Assignment not found')
|
toast.error('Assignment not found')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create evaluation if it doesn't exist
|
let evaluationId = evaluationIdRef.current
|
||||||
let evaluationId = existingEvaluation?.id
|
|
||||||
if (!evaluationId) {
|
if (!evaluationId) {
|
||||||
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
||||||
evaluationId = newEval.id
|
evaluationId = newEval.id
|
||||||
|
evaluationIdRef.current = evaluationId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Autosave current state
|
autosaveMutation.mutate(
|
||||||
autosaveMutation.mutate({
|
{ id: evaluationId, ...buildSavePayload() },
|
||||||
id: evaluationId,
|
{ onSuccess: () => {
|
||||||
criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : undefined,
|
isDirtyRef.current = false
|
||||||
globalScore: scoringMode === 'global' && globalScore ? parseInt(globalScore, 10) : null,
|
setLastSavedAt(new Date())
|
||||||
binaryDecision: scoringMode === 'binary' && binaryDecision ? binaryDecision === 'accept' : null,
|
toast.success('Draft saved', { duration: 1500 })
|
||||||
feedbackText: feedbackText || null,
|
}}
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -178,17 +288,23 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation based on scoring mode
|
// Validation for criteria mode
|
||||||
if (scoringMode === 'criteria') {
|
if (scoringMode === 'criteria') {
|
||||||
if (!criteria || criteria.length === 0) {
|
const requiredCriteria = criteria.filter((c) =>
|
||||||
toast.error('No criteria found for this evaluation')
|
c.type !== 'section_header' && c.required
|
||||||
|
)
|
||||||
|
for (const c of requiredCriteria) {
|
||||||
|
const val = criteriaValues[c.id]
|
||||||
|
if (c.type === 'numeric' && (val === undefined || val === null)) {
|
||||||
|
toast.error(`Please score "${c.label}"`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const requiredCriteria = evalConfig?.requireAllCriteriaScored !== false
|
if (c.type === 'boolean' && val === undefined) {
|
||||||
if (requiredCriteria) {
|
toast.error(`Please answer "${c.label}"`)
|
||||||
const allScored = criteria.every((c) => criteriaScores[c.id] !== undefined)
|
return
|
||||||
if (!allScored) {
|
}
|
||||||
toast.error('Please score all criteria')
|
if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) {
|
||||||
|
toast.error(`Please fill in "${c.label}"`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,74 +332,43 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create evaluation if needed
|
let evaluationId = evaluationIdRef.current
|
||||||
let evaluationId = existingEvaluation?.id
|
|
||||||
if (!evaluationId) {
|
if (!evaluationId) {
|
||||||
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
||||||
evaluationId = newEval.id
|
evaluationId = newEval.id
|
||||||
|
evaluationIdRef.current = evaluationId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute a weighted global score from numeric criteria for the global score field
|
||||||
|
const numericCriteria = criteria.filter((c) => c.type === 'numeric')
|
||||||
|
let computedGlobalScore = 5
|
||||||
|
if (scoringMode === 'criteria' && numericCriteria.length > 0) {
|
||||||
|
let totalWeight = 0
|
||||||
|
let weightedSum = 0
|
||||||
|
for (const c of numericCriteria) {
|
||||||
|
const val = criteriaValues[c.id]
|
||||||
|
if (typeof val === 'number') {
|
||||||
|
const w = c.weight ?? 1
|
||||||
|
// Normalize to 1-10 scale
|
||||||
|
const normalized = ((val - c.minScore) / (c.maxScore - c.minScore)) * 9 + 1
|
||||||
|
weightedSum += normalized * w
|
||||||
|
totalWeight += w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalWeight > 0) {
|
||||||
|
computedGlobalScore = Math.round(weightedSum / totalWeight)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit
|
|
||||||
submitMutation.mutate({
|
submitMutation.mutate({
|
||||||
id: evaluationId,
|
id: evaluationId,
|
||||||
criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : {},
|
criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : {},
|
||||||
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : 5,
|
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : computedGlobalScore,
|
||||||
binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true,
|
binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true,
|
||||||
feedbackText: feedbackText || 'No feedback provided',
|
feedbackText: feedbackText || 'No feedback provided',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// COI Dialog
|
|
||||||
if (!coiAccepted && showCOIDialog && evalConfig?.coiRequired !== false) {
|
|
||||||
return (
|
|
||||||
<AlertDialog open={showCOIDialog}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Conflict of Interest Declaration</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription asChild>
|
|
||||||
<div className="space-y-3 pt-2">
|
|
||||||
<p>
|
|
||||||
Before evaluating this project, you must confirm that you have no conflict of
|
|
||||||
interest.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
A conflict of interest exists if you have a personal, professional, or financial
|
|
||||||
relationship with the project team that could influence your judgment.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<div className="flex items-start gap-3 py-4">
|
|
||||||
<Checkbox
|
|
||||||
id="coi"
|
|
||||||
checked={coiAccepted}
|
|
||||||
onCheckedChange={(checked) => setCoiAccepted(checked as boolean)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="coi" className="text-sm leading-relaxed cursor-pointer">
|
|
||||||
I confirm that I have no conflict of interest with this project and can provide an
|
|
||||||
unbiased evaluation.
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push(`/jury/competitions/${roundId}` as Route)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowCOIDialog(false)}
|
|
||||||
disabled={!coiAccepted}
|
|
||||||
className="bg-brand-blue hover:bg-brand-blue-light"
|
|
||||||
>
|
|
||||||
Continue to Evaluation
|
|
||||||
</Button>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!round || !project) {
|
if (!round || !project) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -299,7 +384,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if round is active — round status is the primary gate for evaluations
|
// Check if round is active
|
||||||
const isRoundActive = round.status === 'ROUND_ACTIVE'
|
const isRoundActive = round.status === 'ROUND_ACTIVE'
|
||||||
|
|
||||||
if (!isRoundActive) {
|
if (!isRoundActive) {
|
||||||
@@ -352,6 +437,9 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Project Documents */}
|
||||||
|
<MultiWindowDocViewer roundId={roundId} projectId={projectId} />
|
||||||
|
|
||||||
<Card className="border-l-4 border-l-amber-500">
|
<Card className="border-l-4 border-l-amber-500">
|
||||||
<CardContent className="flex items-start gap-3 p-4">
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
||||||
@@ -359,7 +447,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
<p className="font-medium text-sm">Important Reminder</p>
|
<p className="font-medium text-sm">Important Reminder</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Your evaluation will be used to assess this project. Please provide thoughtful and
|
Your evaluation will be used to assess this project. Please provide thoughtful and
|
||||||
constructive feedback to help the team improve.
|
constructive feedback. Your progress is automatically saved as a draft.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -367,21 +455,116 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
<CardTitle>Evaluation Form</CardTitle>
|
<CardTitle>Evaluation Form</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Provide your assessment using the {scoringMode} scoring method
|
{scoringMode === 'criteria'
|
||||||
|
? 'Complete all required fields below'
|
||||||
|
: `Provide your assessment using the ${scoringMode} scoring method`}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{lastSavedAt && (
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
|
||||||
|
Saved {lastSavedAt.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Criteria-based scoring */}
|
{/* Criteria-based scoring with mixed types */}
|
||||||
{scoringMode === 'criteria' && criteria && criteria.length > 0 && (
|
{scoringMode === 'criteria' && criteria && criteria.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="font-semibold">Criteria Scores</h3>
|
|
||||||
{criteria.map((criterion) => {
|
{criteria.map((criterion) => {
|
||||||
|
if (criterion.type === 'section_header') {
|
||||||
|
return (
|
||||||
|
<div key={criterion.id} 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criterion.type === 'boolean') {
|
||||||
|
const currentValue = criteriaValues[criterion.id]
|
||||||
|
return (
|
||||||
|
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-base font-medium">
|
||||||
|
{criterion.label}
|
||||||
|
{criterion.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
{criterion.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{criterion.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCriterionChange(criterion.id, true)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
|
||||||
|
currentValue === true
|
||||||
|
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
|
||||||
|
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ThumbsUp className="mr-2 h-4 w-4" />
|
||||||
|
{criterion.trueLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCriterionChange(criterion.id, false)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
|
||||||
|
currentValue === false
|
||||||
|
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
|
||||||
|
: 'border-border hover:border-red-300 hover:bg-red-50/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ThumbsDown className="mr-2 h-4 w-4" />
|
||||||
|
{criterion.falseLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criterion.type === 'text') {
|
||||||
|
const currentValue = (criteriaValues[criterion.id] as string) || ''
|
||||||
|
return (
|
||||||
|
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-base font-medium">
|
||||||
|
{criterion.label}
|
||||||
|
{criterion.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
{criterion.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{criterion.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => handleCriterionChange(criterion.id, e.target.value)}
|
||||||
|
placeholder={criterion.placeholder || 'Enter your response...'}
|
||||||
|
rows={4}
|
||||||
|
maxLength={criterion.maxLength}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground text-right">
|
||||||
|
{currentValue.length}/{criterion.maxLength}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: numeric criterion
|
||||||
const min = criterion.minScore ?? 1
|
const min = criterion.minScore ?? 1
|
||||||
const max = criterion.maxScore ?? 10
|
const max = criterion.maxScore ?? 10
|
||||||
const currentValue = criteriaScores[criterion.id]
|
const currentValue = criteriaValues[criterion.id]
|
||||||
const displayValue = currentValue !== undefined ? currentValue : undefined
|
const displayValue = typeof currentValue === 'number' ? currentValue : undefined
|
||||||
const sliderValue = typeof currentValue === 'number' ? currentValue : Math.ceil((min + max) / 2)
|
const sliderValue = typeof currentValue === 'number' ? currentValue : Math.ceil((min + max) / 2)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -390,16 +573,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-base font-medium">
|
<Label className="text-base font-medium">
|
||||||
{criterion.label}
|
{criterion.label}
|
||||||
{evalConfig?.requireAllCriteriaScored !== false && (
|
{criterion.required && <span className="text-destructive ml-1">*</span>}
|
||||||
<span className="text-destructive ml-1">*</span>
|
|
||||||
)}
|
|
||||||
</Label>
|
</Label>
|
||||||
{criterion.description && (
|
{criterion.description && (
|
||||||
<p className="text-sm text-muted-foreground">{criterion.description}</p>
|
<p className="text-sm text-muted-foreground">{criterion.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
|
<span className="shrink-0 rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
|
||||||
{displayValue !== undefined ? displayValue : '—'}/{max}
|
{displayValue !== undefined ? displayValue : '\u2014'}/{max}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -410,9 +591,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
max={max}
|
max={max}
|
||||||
step={1}
|
step={1}
|
||||||
value={[sliderValue]}
|
value={[sliderValue]}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) => handleCriterionChange(criterion.id, v[0])}
|
||||||
setCriteriaScores({ ...criteriaScores, [criterion.id]: v[0] })
|
|
||||||
}
|
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground w-4">{max}</span>
|
<span className="text-xs text-muted-foreground w-4">{max}</span>
|
||||||
@@ -423,9 +602,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
<button
|
<button
|
||||||
key={num}
|
key={num}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => handleCriterionChange(criterion.id, num)}
|
||||||
setCriteriaScores({ ...criteriaScores, [criterion.id]: num })
|
|
||||||
}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
||||||
displayValue !== undefined && displayValue === num
|
displayValue !== undefined && displayValue === num
|
||||||
@@ -453,7 +630,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
Overall Score <span className="text-destructive">*</span>
|
Overall Score <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<span className="rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
|
<span className="rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
|
||||||
{globalScore || '—'}/10
|
{globalScore || '\u2014'}/10
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -463,7 +640,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
max={10}
|
max={10}
|
||||||
step={1}
|
step={1}
|
||||||
value={[globalScore ? parseInt(globalScore, 10) : 5]}
|
value={[globalScore ? parseInt(globalScore, 10) : 5]}
|
||||||
onValueChange={(v) => setGlobalScore(v[0].toString())}
|
onValueChange={(v) => handleGlobalScoreChange(v[0].toString())}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">10</span>
|
<span className="text-xs text-muted-foreground">10</span>
|
||||||
@@ -475,7 +652,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
<button
|
<button
|
||||||
key={num}
|
key={num}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setGlobalScore(num.toString())}
|
onClick={() => handleGlobalScoreChange(num.toString())}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
||||||
current === num
|
current === num
|
||||||
@@ -490,9 +667,6 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Provide a score from 1 to 10 based on your overall assessment
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -502,7 +676,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
<Label>
|
<Label>
|
||||||
Decision <span className="text-destructive">*</span>
|
Decision <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<RadioGroup value={binaryDecision} onValueChange={(v) => setBinaryDecision(v as 'accept' | 'reject')}>
|
<RadioGroup value={binaryDecision} onValueChange={(v) => handleBinaryChange(v as 'accept' | 'reject')}>
|
||||||
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50">
|
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50">
|
||||||
<RadioGroupItem value="accept" id="accept" />
|
<RadioGroupItem value="accept" id="accept" />
|
||||||
<Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1">
|
<Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1">
|
||||||
@@ -524,13 +698,13 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
{/* Feedback */}
|
{/* Feedback */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="feedbackText">
|
<Label htmlFor="feedbackText">
|
||||||
Feedback
|
General Comment / Feedback
|
||||||
{requireFeedback && <span className="text-destructive ml-1">*</span>}
|
{requireFeedback && <span className="text-destructive ml-1">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="feedbackText"
|
id="feedbackText"
|
||||||
value={feedbackText}
|
value={feedbackText}
|
||||||
onChange={(e) => setFeedbackText(e.target.value)}
|
onChange={(e) => handleFeedbackChange(e.target.value)}
|
||||||
placeholder="Provide your feedback on the project..."
|
placeholder="Provide your feedback on the project..."
|
||||||
rows={8}
|
rows={8}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
||||||
import { ArrowLeft, FileText, Users, MapPin, Target } from 'lucide-react'
|
import { ArrowLeft, FileText, Users, MapPin, Target, Tag } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
export default function JuryProjectDetailPage() {
|
export default function JuryProjectDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -105,14 +104,40 @@ export default function JuryProjectDetailPage() {
|
|||||||
{project.competitionCategory && (
|
{project.competitionCategory && (
|
||||||
<Badge variant="outline">{project.competitionCategory}</Badge>
|
<Badge variant="outline">{project.competitionCategory}</Badge>
|
||||||
)}
|
)}
|
||||||
{project.tags && project.tags.length > 0 && (
|
</div>
|
||||||
project.tags.slice(0, 3).map((tag: string) => (
|
|
||||||
<Badge key={tag} variant="secondary">
|
{/* Project tags */}
|
||||||
|
{((project.projectTags && project.projectTags.length > 0) ||
|
||||||
|
(project.tags && project.tags.length > 0)) && (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-2 flex items-center gap-2">
|
||||||
|
<Tag className="h-4 w-4" />
|
||||||
|
Tags
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{project.projectTags && project.projectTags.length > 0
|
||||||
|
? project.projectTags.map((pt: any) => (
|
||||||
|
<Badge
|
||||||
|
key={pt.id}
|
||||||
|
variant="secondary"
|
||||||
|
style={pt.tag.color ? { backgroundColor: pt.tag.color + '20', borderColor: pt.tag.color, color: pt.tag.color } : undefined}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{pt.tag.name}
|
||||||
|
{pt.tag.category && (
|
||||||
|
<span className="ml-1 opacity-60">({pt.tag.category})</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
: project.tags.map((tag: string) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-xs">
|
||||||
{tag}
|
{tag}
|
||||||
</Badge>
|
</Badge>
|
||||||
))
|
))
|
||||||
)}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{project.description && (
|
{project.description && (
|
||||||
|
|||||||
@@ -42,10 +42,21 @@ interface EvaluationSummaryCardProps {
|
|||||||
roundId: string
|
roundId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BooleanStats {
|
||||||
|
yesCount: number
|
||||||
|
noCount: number
|
||||||
|
total: number
|
||||||
|
yesPercent: number
|
||||||
|
trueLabel: string
|
||||||
|
falseLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
interface ScoringPatterns {
|
interface ScoringPatterns {
|
||||||
averageGlobalScore: number | null
|
averageGlobalScore: number | null
|
||||||
consensus: number
|
consensus: number
|
||||||
criterionAverages: Record<string, number>
|
criterionAverages: Record<string, number>
|
||||||
|
booleanCriteria?: Record<string, BooleanStats>
|
||||||
|
textResponses?: Record<string, string[]>
|
||||||
evaluatorCount: number
|
evaluatorCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,10 +307,10 @@ export function EvaluationSummaryCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Criterion Averages */}
|
{/* Criterion Averages (Numeric) */}
|
||||||
{Object.keys(patterns.criterionAverages).length > 0 && (
|
{Object.keys(patterns.criterionAverages).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium mb-2">Criterion Averages</p>
|
<p className="text-sm font-medium mb-2">Score Averages</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{Object.entries(patterns.criterionAverages).map(([label, avg]) => (
|
{Object.entries(patterns.criterionAverages).map(([label, avg]) => (
|
||||||
<div key={label} className="flex items-center gap-3">
|
<div key={label} className="flex items-center gap-3">
|
||||||
@@ -323,6 +334,69 @@ export function EvaluationSummaryCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Boolean Criteria (Yes/No) */}
|
||||||
|
{patterns.booleanCriteria && Object.keys(patterns.booleanCriteria).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">Yes/No Decisions</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(patterns.booleanCriteria).map(([label, stats]) => (
|
||||||
|
<div key={label} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground truncate">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||||
|
{stats.yesCount} {stats.trueLabel} / {stats.noCount} {stats.falseLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-2 rounded-full overflow-hidden bg-muted">
|
||||||
|
{stats.yesCount > 0 && (
|
||||||
|
<div
|
||||||
|
className="h-full bg-emerald-500 transition-all"
|
||||||
|
style={{ width: `${stats.yesPercent}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{stats.noCount > 0 && (
|
||||||
|
<div
|
||||||
|
className="h-full bg-red-400 transition-all"
|
||||||
|
style={{ width: `${100 - stats.yesPercent}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-emerald-600">{stats.yesPercent}% {stats.trueLabel}</span>
|
||||||
|
<span className="text-red-500">{100 - stats.yesPercent}% {stats.falseLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Text Responses */}
|
||||||
|
{patterns.textResponses && Object.keys(patterns.textResponses).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">Text Responses</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(patterns.textResponses).map(([label, responses]) => (
|
||||||
|
<div key={label} className="space-y-1.5">
|
||||||
|
<p className="text-sm text-muted-foreground">{label}</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{responses.map((text, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="text-sm p-2 rounded border bg-muted/50 whitespace-pre-wrap"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Recommendation */}
|
{/* Recommendation */}
|
||||||
{summaryData.recommendation && (
|
{summaryData.recommendation && (
|
||||||
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-200">
|
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-200">
|
||||||
|
|||||||
114
src/components/dashboard/recent-evaluations.tsx
Normal file
114
src/components/dashboard/recent-evaluations.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { ClipboardCheck, ThumbsUp, ThumbsDown, ExternalLink } from 'lucide-react'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
|
||||||
|
type RecentEvaluation = {
|
||||||
|
id: string
|
||||||
|
globalScore: number | null
|
||||||
|
binaryDecision: boolean | null
|
||||||
|
submittedAt: Date | string | null
|
||||||
|
feedbackText: string | null
|
||||||
|
assignment: {
|
||||||
|
project: { id: string; title: string }
|
||||||
|
round: { id: string; name: string }
|
||||||
|
user: { id: string; name: string | null; email: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentEvaluations({ evaluations }: { evaluations: RecentEvaluation[] }) {
|
||||||
|
if (!evaluations || evaluations.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ClipboardCheck className="h-4 w-4" />
|
||||||
|
Recent Evaluations
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
No evaluations submitted yet
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ClipboardCheck className="h-4 w-4" />
|
||||||
|
Recent Evaluations
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Latest jury reviews as they come in</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{evaluations.map((ev) => (
|
||||||
|
<Link
|
||||||
|
key={ev.id}
|
||||||
|
href={`/admin/projects/${ev.assignment.project.id}`}
|
||||||
|
className="block group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3 p-2.5 rounded-lg border hover:bg-muted/50 transition-colors">
|
||||||
|
{/* Score indicator */}
|
||||||
|
<div className="flex flex-col items-center gap-0.5 shrink-0 pt-0.5">
|
||||||
|
{ev.globalScore !== null ? (
|
||||||
|
<span className="text-lg font-bold tabular-nums leading-none">
|
||||||
|
{ev.globalScore}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-lg font-bold text-muted-foreground leading-none">-</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-muted-foreground">/10</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium truncate flex-1">
|
||||||
|
{ev.assignment.project.title}
|
||||||
|
</p>
|
||||||
|
<ExternalLink className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="truncate">{ev.assignment.user.name || ev.assignment.user.email}</span>
|
||||||
|
<span className="shrink-0">
|
||||||
|
{ev.submittedAt
|
||||||
|
? formatDistanceToNow(new Date(ev.submittedAt), { addSuffix: true })
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-[10px] h-5">
|
||||||
|
{ev.assignment.round.name}
|
||||||
|
</Badge>
|
||||||
|
{ev.binaryDecision !== null && (
|
||||||
|
ev.binaryDecision ? (
|
||||||
|
<span className="flex items-center gap-0.5 text-xs text-emerald-600">
|
||||||
|
<ThumbsUp className="h-3 w-3" /> Yes
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-0.5 text-xs text-red-500">
|
||||||
|
<ThumbsDown className="h-3 w-3" /> No
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ev.feedbackText && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
|
||||||
|
{ev.feedbackText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { FileText, Download, ExternalLink } from 'lucide-react'
|
import { FileText, Download, ExternalLink, Loader2 } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
interface MultiWindowDocViewerProps {
|
interface MultiWindowDocViewerProps {
|
||||||
@@ -32,10 +32,110 @@ function getFileIcon(mimeType: string) {
|
|||||||
return '📎'
|
return '📎'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canPreviewInBrowser(mimeType: string): boolean {
|
||||||
|
return (
|
||||||
|
mimeType === 'application/pdf' ||
|
||||||
|
mimeType.startsWith('image/') ||
|
||||||
|
mimeType.startsWith('video/') ||
|
||||||
|
mimeType.startsWith('text/')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileCard({ file }: { file: { id: string; fileName: string; mimeType: string; size: number; bucket: string; objectKey: string } }) {
|
||||||
|
const [loadingAction, setLoadingAction] = useState<'download' | 'preview' | null>(null)
|
||||||
|
|
||||||
|
const downloadUrlQuery = trpc.file.getDownloadUrl.useQuery(
|
||||||
|
{ bucket: file.bucket, objectKey: file.objectKey },
|
||||||
|
{ enabled: false } // manual trigger
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAction = async (action: 'download' | 'preview') => {
|
||||||
|
setLoadingAction(action)
|
||||||
|
try {
|
||||||
|
const result = await downloadUrlQuery.refetch()
|
||||||
|
if (result.data?.url) {
|
||||||
|
if (action === 'preview' && canPreviewInBrowser(file.mimeType)) {
|
||||||
|
window.open(result.data.url, '_blank')
|
||||||
|
} else {
|
||||||
|
// Download: create temp link and click
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = result.data.url
|
||||||
|
a.download = file.fileName
|
||||||
|
a.target = '_blank'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to get file URL')
|
||||||
|
} finally {
|
||||||
|
setLoadingAction(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="text-2xl">{getFileIcon(file.mimeType || '')}</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-sm truncate" title={file.fileName}>
|
||||||
|
{file.fileName}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{file.mimeType?.split('/')[1]?.toUpperCase() || 'FILE'}
|
||||||
|
</Badge>
|
||||||
|
{file.size > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => handleAction('download')}
|
||||||
|
disabled={loadingAction !== null}
|
||||||
|
>
|
||||||
|
{loadingAction === 'download' ? (
|
||||||
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="mr-1 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
{canPreviewInBrowser(file.mimeType) && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => handleAction('preview')}
|
||||||
|
disabled={loadingAction !== null}
|
||||||
|
>
|
||||||
|
{loadingAction === 'preview' ? (
|
||||||
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ExternalLink className="mr-1 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function MultiWindowDocViewer({ roundId, projectId }: MultiWindowDocViewerProps) {
|
export function MultiWindowDocViewer({ roundId, projectId }: MultiWindowDocViewerProps) {
|
||||||
const { data: windows, isLoading } = trpc.round.getVisibleWindows.useQuery(
|
const { data: files, isLoading } = trpc.file.listByProject.useQuery(
|
||||||
{ roundId },
|
{ projectId },
|
||||||
{ enabled: !!roundId }
|
{ enabled: !!projectId }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -51,94 +151,67 @@ export function MultiWindowDocViewer({ roundId, projectId }: MultiWindowDocViewe
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!windows || windows.length === 0) {
|
if (!files || files.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Documents</CardTitle>
|
<CardTitle>Documents</CardTitle>
|
||||||
<CardDescription>Submission windows and uploaded files</CardDescription>
|
<CardDescription>Project files and submissions</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-center py-8">
|
<CardContent className="text-center py-8">
|
||||||
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||||
<p className="text-sm text-muted-foreground">No submission windows available</p>
|
<p className="text-sm text-muted-foreground">No files uploaded</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group files by round name or "General"
|
||||||
|
const grouped: Record<string, typeof files> = {}
|
||||||
|
for (const file of files) {
|
||||||
|
const groupName = file.requirement?.round?.name ?? 'General'
|
||||||
|
if (!grouped[groupName]) grouped[groupName] = []
|
||||||
|
grouped[groupName].push(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupNames = Object.keys(grouped)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Documents</CardTitle>
|
<CardTitle>Documents</CardTitle>
|
||||||
<CardDescription>Files submitted across all windows</CardDescription>
|
<CardDescription>
|
||||||
|
{files.length} file{files.length !== 1 ? 's' : ''} submitted
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Tabs defaultValue={windows[0]?.id || ''} className="w-full">
|
{groupNames.length === 1 ? (
|
||||||
<TabsList className="w-full flex-wrap justify-start h-auto gap-1 bg-transparent p-0 mb-4">
|
// Single group — no need for headers
|
||||||
{windows.map((window: any) => (
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<TabsTrigger
|
{grouped[groupNames[0]].map((file) => (
|
||||||
key={window.id}
|
<FileCard key={file.id} file={file} />
|
||||||
value={window.id}
|
|
||||||
className="data-[state=active]:bg-brand-blue data-[state=active]:text-white px-4 py-2 rounded-md text-sm"
|
|
||||||
>
|
|
||||||
{window.name}
|
|
||||||
{window.files && window.files.length > 0 && (
|
|
||||||
<Badge variant="secondary" className="ml-2 text-xs">
|
|
||||||
{window.files.length}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{windows.map((window: any) => (
|
|
||||||
<TabsContent key={window.id} value={window.id} className="mt-0">
|
|
||||||
{!window.files || window.files.length === 0 ? (
|
|
||||||
<div className="text-center py-8 border border-dashed rounded-lg">
|
|
||||||
<FileText className="h-10 w-10 text-muted-foreground/50 mx-auto mb-2" />
|
|
||||||
<p className="text-sm text-muted-foreground">No files uploaded</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
// Multiple groups — show headers
|
||||||
{window.files.map((file: any) => (
|
<div className="space-y-6">
|
||||||
<Card key={file.id} className="overflow-hidden">
|
{groupNames.map((groupName) => (
|
||||||
<CardContent className="p-4">
|
<div key={groupName}>
|
||||||
<div className="flex items-start gap-3">
|
<h4 className="font-medium text-sm text-muted-foreground mb-3">
|
||||||
<div className="text-2xl">{getFileIcon(file.mimeType || '')}</div>
|
{groupName}
|
||||||
<div className="flex-1 min-w-0">
|
<Badge variant="secondary" className="ml-2 text-xs">
|
||||||
<p className="font-medium text-sm truncate" title={file.filename}>
|
{grouped[groupName].length}
|
||||||
{file.filename}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{file.mimeType?.split('/')[1]?.toUpperCase() || 'FILE'}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
{file.size && (
|
</h4>
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{formatFileSize(file.size)}
|
{grouped[groupName].map((file) => (
|
||||||
</span>
|
<FileCard key={file.id} file={file} />
|
||||||
)}
|
))}
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 mt-3">
|
|
||||||
<Button size="sm" variant="outline" className="h-7 text-xs">
|
|
||||||
<Download className="mr-1 h-3 w-3" />
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" className="h-7 text-xs">
|
|
||||||
<ExternalLink className="mr-1 h-3 w-3" />
|
|
||||||
Preview
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -465,4 +465,37 @@ export const dashboardRouter = router({
|
|||||||
recentActivity,
|
recentActivity,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getRecentEvaluations: adminProcedure
|
||||||
|
.input(z.object({ editionId: z.string(), limit: z.number().int().min(1).max(50).optional() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const take = input.limit ?? 10
|
||||||
|
|
||||||
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'SUBMITTED',
|
||||||
|
assignment: {
|
||||||
|
round: { competition: { programId: input.editionId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { submittedAt: 'desc' },
|
||||||
|
take,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
globalScore: true,
|
||||||
|
binaryDecision: true,
|
||||||
|
submittedAt: true,
|
||||||
|
feedbackText: true,
|
||||||
|
assignment: {
|
||||||
|
select: {
|
||||||
|
project: { select: { id: true, title: true } },
|
||||||
|
round: { select: { id: true, name: true } },
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return evaluations
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1067,9 +1067,27 @@ export const evaluationRouter = router({
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
label: z.string().min(1).max(255),
|
label: z.string().min(1).max(255),
|
||||||
description: z.string().max(2000).optional(),
|
description: z.string().max(2000).optional(),
|
||||||
|
type: z.enum(['numeric', 'text', 'boolean', 'section_header']).optional(),
|
||||||
|
// Numeric fields
|
||||||
weight: z.number().min(0).max(100).optional(),
|
weight: z.number().min(0).max(100).optional(),
|
||||||
minScore: z.number().int().min(0).optional(),
|
minScore: z.number().int().min(0).optional(),
|
||||||
maxScore: z.number().int().min(1).optional(),
|
maxScore: z.number().int().min(1).optional(),
|
||||||
|
scale: z.number().int().min(1).max(10).optional(),
|
||||||
|
required: z.boolean().optional(),
|
||||||
|
// Text fields
|
||||||
|
maxLength: z.number().int().min(1).max(10000).optional(),
|
||||||
|
placeholder: z.string().max(500).optional(),
|
||||||
|
// Boolean fields
|
||||||
|
trueLabel: z.string().max(100).optional(),
|
||||||
|
falseLabel: z.string().max(100).optional(),
|
||||||
|
// Conditional visibility
|
||||||
|
condition: z.object({
|
||||||
|
criterionId: z.string(),
|
||||||
|
operator: z.enum(['equals', 'greaterThan', 'lessThan']),
|
||||||
|
value: z.union([z.number(), z.string(), z.boolean()]),
|
||||||
|
}).optional(),
|
||||||
|
// Section grouping
|
||||||
|
sectionId: z.string().optional(),
|
||||||
})
|
})
|
||||||
).min(1),
|
).min(1),
|
||||||
})
|
})
|
||||||
@@ -1088,18 +1106,46 @@ export const evaluationRouter = router({
|
|||||||
})
|
})
|
||||||
const nextVersion = (latestForm?.version ?? 0) + 1
|
const nextVersion = (latestForm?.version ?? 0) + 1
|
||||||
|
|
||||||
// Build criteriaJson with defaults
|
// Build criteriaJson preserving all fields
|
||||||
const criteriaJson = criteria.map((c) => ({
|
const criteriaJson = criteria.map((c) => {
|
||||||
|
const type = c.type || 'numeric'
|
||||||
|
const base = {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
label: c.label,
|
label: c.label,
|
||||||
description: c.description || '',
|
description: c.description || '',
|
||||||
weight: c.weight ?? 1,
|
type,
|
||||||
scale: `${c.minScore ?? 1}-${c.maxScore ?? 10}`,
|
required: c.required ?? (type !== 'section_header'),
|
||||||
required: true,
|
}
|
||||||
}))
|
|
||||||
|
|
||||||
// Auto-generate scalesJson from criteria min/max ranges
|
if (type === 'numeric') {
|
||||||
const scaleSet = new Set(criteriaJson.map((c) => c.scale))
|
const scaleVal = c.scale ?? 10
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
weight: c.weight ?? 1,
|
||||||
|
scale: `${c.minScore ?? 1}-${c.maxScore ?? scaleVal}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === 'text') {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
maxLength: c.maxLength ?? 1000,
|
||||||
|
placeholder: c.placeholder || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === 'boolean') {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
trueLabel: c.trueLabel || 'Yes',
|
||||||
|
falseLabel: c.falseLabel || 'No',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// section_header
|
||||||
|
return base
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-generate scalesJson from numeric criteria
|
||||||
|
const numericCriteria = criteriaJson.filter((c) => c.type === 'numeric')
|
||||||
|
const scaleSet = new Set(numericCriteria.map((c) => (c as { scale: string }).scale))
|
||||||
const scalesJson: Record<string, { min: number; max: number }> = {}
|
const scalesJson: Record<string, { min: number; max: number }> = {}
|
||||||
for (const scale of scaleSet) {
|
for (const scale of scaleSet) {
|
||||||
const [min, max] = scale.split('-').map(Number)
|
const [min, max] = scale.split('-').map(Number)
|
||||||
|
|||||||
@@ -1141,7 +1141,8 @@ export const projectRouter = router({
|
|||||||
where: { projectId: input.id },
|
where: { projectId: input.id },
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true } },
|
user: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true } },
|
||||||
evaluation: { select: { status: true, submittedAt: true, globalScore: true, binaryDecision: true } },
|
round: { select: { id: true, name: true } },
|
||||||
|
evaluation: { select: { id: true, status: true, submittedAt: true, globalScore: true, binaryDecision: true, criterionScoresJson: true, feedbackText: true } },
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import type { PrismaClient, Prisma } from '@prisma/client'
|
|||||||
|
|
||||||
interface EvaluationForSummary {
|
interface EvaluationForSummary {
|
||||||
id: string
|
id: string
|
||||||
criterionScoresJson: Record<string, number> | null
|
criterionScoresJson: Record<string, number | boolean | string> | null
|
||||||
globalScore: number | null
|
globalScore: number | null
|
||||||
binaryDecision: boolean | null
|
binaryDecision: boolean | null
|
||||||
feedbackText: string | null
|
feedbackText: string | null
|
||||||
@@ -35,7 +35,7 @@ interface EvaluationForSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AnonymizedEvaluation {
|
interface AnonymizedEvaluation {
|
||||||
criterionScores: Record<string, number> | null
|
criterionScores: Record<string, number | boolean | string> | null
|
||||||
globalScore: number | null
|
globalScore: number | null
|
||||||
binaryDecision: boolean | null
|
binaryDecision: boolean | null
|
||||||
feedbackText: string | null
|
feedbackText: string | null
|
||||||
@@ -44,6 +44,9 @@ interface AnonymizedEvaluation {
|
|||||||
interface CriterionDef {
|
interface CriterionDef {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
|
type?: 'numeric' | 'text' | 'boolean' | 'section_header'
|
||||||
|
trueLabel?: string
|
||||||
|
falseLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AIResponsePayload {
|
interface AIResponsePayload {
|
||||||
@@ -58,10 +61,21 @@ interface AIResponsePayload {
|
|||||||
recommendation: string
|
recommendation: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BooleanStats {
|
||||||
|
yesCount: number
|
||||||
|
noCount: number
|
||||||
|
total: number
|
||||||
|
yesPercent: number
|
||||||
|
trueLabel: string
|
||||||
|
falseLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
interface ScoringPatterns {
|
interface ScoringPatterns {
|
||||||
averageGlobalScore: number | null
|
averageGlobalScore: number | null
|
||||||
consensus: number
|
consensus: number
|
||||||
criterionAverages: Record<string, number>
|
criterionAverages: Record<string, number>
|
||||||
|
booleanCriteria: Record<string, BooleanStats>
|
||||||
|
textResponses: Record<string, string[]>
|
||||||
evaluatorCount: number
|
evaluatorCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +98,7 @@ export function anonymizeEvaluations(
|
|||||||
evaluations: EvaluationForSummary[]
|
evaluations: EvaluationForSummary[]
|
||||||
): AnonymizedEvaluation[] {
|
): AnonymizedEvaluation[] {
|
||||||
return evaluations.map((ev) => ({
|
return evaluations.map((ev) => ({
|
||||||
criterionScores: ev.criterionScoresJson as Record<string, number> | null,
|
criterionScores: ev.criterionScoresJson as Record<string, number | boolean | string> | null,
|
||||||
globalScore: ev.globalScore,
|
globalScore: ev.globalScore,
|
||||||
binaryDecision: ev.binaryDecision,
|
binaryDecision: ev.binaryDecision,
|
||||||
feedbackText: ev.feedbackText ? sanitizeText(ev.feedbackText) : null,
|
feedbackText: ev.feedbackText ? sanitizeText(ev.feedbackText) : null,
|
||||||
@@ -99,15 +113,33 @@ export function anonymizeEvaluations(
|
|||||||
export function buildSummaryPrompt(
|
export function buildSummaryPrompt(
|
||||||
anonymizedEvaluations: AnonymizedEvaluation[],
|
anonymizedEvaluations: AnonymizedEvaluation[],
|
||||||
projectTitle: string,
|
projectTitle: string,
|
||||||
criteriaLabels: string[]
|
criteriaDefinitions: CriterionDef[]
|
||||||
): string {
|
): string {
|
||||||
const sanitizedTitle = sanitizeText(projectTitle)
|
const sanitizedTitle = sanitizeText(projectTitle)
|
||||||
|
|
||||||
|
// Build a descriptive criteria section that explains each criterion type
|
||||||
|
const criteriaDescription = criteriaDefinitions
|
||||||
|
.filter((c) => c.type !== 'section_header')
|
||||||
|
.map((c) => {
|
||||||
|
const type = c.type || 'numeric'
|
||||||
|
if (type === 'boolean') {
|
||||||
|
return `- "${c.label}" (Yes/No decision: ${c.trueLabel || 'Yes'} / ${c.falseLabel || 'No'})`
|
||||||
|
}
|
||||||
|
if (type === 'text') {
|
||||||
|
return `- "${c.label}" (Free-text response)`
|
||||||
|
}
|
||||||
|
return `- "${c.label}" (Numeric score)`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
return `You are analyzing jury evaluations for a project competition.
|
return `You are analyzing jury evaluations for a project competition.
|
||||||
|
|
||||||
PROJECT: "${sanitizedTitle}"
|
PROJECT: "${sanitizedTitle}"
|
||||||
|
|
||||||
EVALUATION CRITERIA: ${criteriaLabels.join(', ')}
|
EVALUATION CRITERIA:
|
||||||
|
${criteriaDescription}
|
||||||
|
|
||||||
|
Note: criterionScores values may be numbers (numeric scores), booleans (true/false for yes/no criteria), or strings (text responses).
|
||||||
|
|
||||||
EVALUATIONS (${anonymizedEvaluations.length} total):
|
EVALUATIONS (${anonymizedEvaluations.length} total):
|
||||||
${JSON.stringify(anonymizedEvaluations, null, 2)}
|
${JSON.stringify(anonymizedEvaluations, null, 2)}
|
||||||
@@ -123,17 +155,11 @@ Analyze these evaluations and return a JSON object with this exact structure:
|
|||||||
"recommendation": "A brief recommendation based on the evaluation consensus"
|
"recommendation": "A brief recommendation based on the evaluation consensus"
|
||||||
}
|
}
|
||||||
|
|
||||||
Example output:
|
|
||||||
{
|
|
||||||
"overallAssessment": "The project received strong scores (avg 7.8/10) with high consensus among evaluators. Key strengths in innovation were balanced by concerns about scalability.",
|
|
||||||
"strengths": ["Innovative approach to coral reef monitoring", "Strong team expertise in marine biology"],
|
|
||||||
"weaknesses": ["Limited scalability plan", "Budget projections need more detail"],
|
|
||||||
"themes": [{"theme": "Innovation", "sentiment": "positive", "frequency": 3}, {"theme": "Scalability", "sentiment": "negative", "frequency": 2}],
|
|
||||||
"recommendation": "Recommended for advancement with condition to address scalability concerns in next round."
|
|
||||||
}
|
|
||||||
|
|
||||||
Guidelines:
|
Guidelines:
|
||||||
- Base your analysis only on the provided evaluation data
|
- Base your analysis only on the provided evaluation data
|
||||||
|
- For numeric criteria, consider score averages and distribution
|
||||||
|
- For yes/no criteria, consider the proportion of yes vs no answers
|
||||||
|
- For text criteria, synthesize common themes from the responses
|
||||||
- Identify common themes across evaluator feedback
|
- Identify common themes across evaluator feedback
|
||||||
- Note areas of agreement and disagreement
|
- Note areas of agreement and disagreement
|
||||||
- Keep the assessment objective and balanced
|
- Keep the assessment objective and balanced
|
||||||
@@ -172,26 +198,72 @@ export function computeScoringPatterns(
|
|||||||
consensus = Math.max(0, 1 - stdDev / 4.5)
|
consensus = Math.max(0, 1 - stdDev / 4.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Criterion averages
|
// Criterion averages (numeric only)
|
||||||
const criterionAverages: Record<string, number> = {}
|
const criterionAverages: Record<string, number> = {}
|
||||||
|
// Boolean criteria stats
|
||||||
|
const booleanCriteria: Record<string, BooleanStats> = {}
|
||||||
|
// Text responses
|
||||||
|
const textResponses: Record<string, string[]> = {}
|
||||||
|
|
||||||
for (const criterion of criteriaLabels) {
|
for (const criterion of criteriaLabels) {
|
||||||
|
const type = criterion.type || 'numeric'
|
||||||
|
|
||||||
|
if (type === 'numeric') {
|
||||||
const scores: number[] = []
|
const scores: number[] = []
|
||||||
for (const ev of evaluations) {
|
for (const ev of evaluations) {
|
||||||
const criterionScores = ev.criterionScoresJson as Record<string, number> | null
|
const criterionScores = ev.criterionScoresJson as Record<string, number | boolean | string> | null
|
||||||
if (criterionScores && criterionScores[criterion.id] !== undefined) {
|
const val = criterionScores?.[criterion.id]
|
||||||
scores.push(criterionScores[criterion.id])
|
if (typeof val === 'number') {
|
||||||
|
scores.push(val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (scores.length > 0) {
|
if (scores.length > 0) {
|
||||||
criterionAverages[criterion.label] =
|
criterionAverages[criterion.label] =
|
||||||
scores.reduce((a, b) => a + b, 0) / scores.length
|
scores.reduce((a, b) => a + b, 0) / scores.length
|
||||||
}
|
}
|
||||||
|
} else if (type === 'boolean') {
|
||||||
|
let yesCount = 0
|
||||||
|
let noCount = 0
|
||||||
|
for (const ev of evaluations) {
|
||||||
|
const criterionScores = ev.criterionScoresJson as Record<string, number | boolean | string> | null
|
||||||
|
const val = criterionScores?.[criterion.id]
|
||||||
|
if (typeof val === 'boolean') {
|
||||||
|
if (val) yesCount++
|
||||||
|
else noCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const total = yesCount + noCount
|
||||||
|
if (total > 0) {
|
||||||
|
booleanCriteria[criterion.label] = {
|
||||||
|
yesCount,
|
||||||
|
noCount,
|
||||||
|
total,
|
||||||
|
yesPercent: Math.round((yesCount / total) * 100),
|
||||||
|
trueLabel: criterion.trueLabel || 'Yes',
|
||||||
|
falseLabel: criterion.falseLabel || 'No',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (type === 'text') {
|
||||||
|
const responses: string[] = []
|
||||||
|
for (const ev of evaluations) {
|
||||||
|
const criterionScores = ev.criterionScoresJson as Record<string, number | boolean | string> | null
|
||||||
|
const val = criterionScores?.[criterion.id]
|
||||||
|
if (typeof val === 'string' && val.trim()) {
|
||||||
|
responses.push(sanitizeText(val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (responses.length > 0) {
|
||||||
|
textResponses[criterion.label] = responses
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
averageGlobalScore,
|
averageGlobalScore,
|
||||||
consensus: Math.round(consensus * 100) / 100,
|
consensus: Math.round(consensus * 100) / 100,
|
||||||
criterionAverages,
|
criterionAverages,
|
||||||
|
booleanCriteria,
|
||||||
|
textResponses,
|
||||||
evaluatorCount: evaluations.length,
|
evaluatorCount: evaluations.length,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,7 +338,6 @@ export async function generateSummary({
|
|||||||
const criteria: CriterionDef[] = form?.criteriaJson
|
const criteria: CriterionDef[] = form?.criteriaJson
|
||||||
? (form.criteriaJson as unknown as CriterionDef[])
|
? (form.criteriaJson as unknown as CriterionDef[])
|
||||||
: []
|
: []
|
||||||
const criteriaLabels = criteria.map((c) => c.label)
|
|
||||||
|
|
||||||
// 2. Anonymize evaluations
|
// 2. Anonymize evaluations
|
||||||
const typedEvaluations = evaluations as unknown as EvaluationForSummary[]
|
const typedEvaluations = evaluations as unknown as EvaluationForSummary[]
|
||||||
@@ -282,7 +353,7 @@ export async function generateSummary({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const model = await getConfiguredModel(AI_MODELS.QUICK)
|
const model = await getConfiguredModel(AI_MODELS.QUICK)
|
||||||
const prompt = buildSummaryPrompt(anonymized, project.title, criteriaLabels)
|
const prompt = buildSummaryPrompt(anonymized, project.title, criteria)
|
||||||
|
|
||||||
let aiResponse: AIResponsePayload
|
let aiResponse: AIResponsePayload
|
||||||
let tokensUsed = 0
|
let tokensUsed = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user