diff --git a/scripts/backfill-binary-decision.ts b/scripts/backfill-binary-decision.ts index 82fb608..9f31458 100644 --- a/scripts/backfill-binary-decision.ts +++ b/scripts/backfill-binary-decision.ts @@ -8,9 +8,11 @@ * 1. Finds all rounds with a boolean criterion labeled "Move to the Next Stage?" * 2. For evaluations in those rounds where binaryDecision IS NULL, * copies the boolean value from criterionScoresJson into binaryDecision + * + * Safe to re-run: only updates evaluations where binaryDecision is still null. */ -import { PrismaClient } from '@prisma/client' +import { PrismaClient, Prisma } from '@prisma/client' const prisma = new PrismaClient() @@ -28,6 +30,7 @@ async function main() { }) let totalUpdated = 0 + let totalSkipped = 0 for (const round of rounds) { const config = round.configJson as Record | null @@ -47,36 +50,55 @@ async function main() { console.log(`Round "${round.name}" (${round.id}): found criterion "${boolCriterion.label}" (${boolCriterion.id})`) // Find evaluations in this round where binaryDecision is null + // Use Prisma.JsonNull for proper null filtering const evaluations = await prisma.evaluation.findMany({ where: { assignment: { roundId: round.id }, binaryDecision: null, status: 'SUBMITTED', - criterionScoresJson: { not: undefined }, }, select: { id: true, criterionScoresJson: true }, }) let updated = 0 + let skipped = 0 for (const ev of evaluations) { const scores = ev.criterionScoresJson as Record | null - if (!scores) continue + if (!scores) { + skipped++ + continue + } const value = scores[boolCriterion.id] - if (typeof value !== 'boolean') continue + let resolved: boolean | null = null + + if (typeof value === 'boolean') { + resolved = value + } else if (value === 'true' || value === 1) { + resolved = true + } else if (value === 'false' || value === 0) { + resolved = false + } + + if (resolved === null) { + console.log(` Skipping eval ${ev.id}: criterion value is ${JSON.stringify(value)}`) + skipped++ + continue + } await prisma.evaluation.update({ where: { id: ev.id }, - data: { binaryDecision: value }, + data: { binaryDecision: resolved }, }) updated++ } - console.log(` Updated ${updated}/${evaluations.length} evaluations`) + console.log(` Updated ${updated}/${evaluations.length} evaluations (skipped ${skipped})`) totalUpdated += updated + totalSkipped += skipped } - console.log(`\nDone. Total evaluations updated: ${totalUpdated}`) + console.log(`\nDone. Total updated: ${totalUpdated}, Total skipped: ${totalSkipped}`) } main() diff --git a/src/app/(admin)/admin/juries/[groupId]/page.tsx b/src/app/(admin)/admin/juries/[groupId]/page.tsx index 54efaac..9d4c01d 100644 --- a/src/app/(admin)/admin/juries/[groupId]/page.tsx +++ b/src/app/(admin)/admin/juries/[groupId]/page.tsx @@ -289,7 +289,9 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps {group.members.map((member) => ( - {member.user.name || 'Unnamed'} + + {member.user.name || 'Unnamed'} + {member.user.email} diff --git a/src/app/(admin)/admin/members/[id]/page.tsx b/src/app/(admin)/admin/members/[id]/page.tsx index abea22a..73cd8ef 100644 --- a/src/app/(admin)/admin/members/[id]/page.tsx +++ b/src/app/(admin)/admin/members/[id]/page.tsx @@ -33,8 +33,10 @@ import { TableRow, } from '@/components/ui/table' import { toast } from 'sonner' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { TagInput } from '@/components/shared/tag-input' import { UserActivityLog } from '@/components/shared/user-activity-log' +import { EvaluationEditSheet } from '@/components/admin/evaluation-edit-sheet' import { AlertDialog, AlertDialogAction, @@ -53,6 +55,10 @@ import { Shield, Loader2, AlertCircle, + ClipboardList, + Eye, + ThumbsUp, + ThumbsDown, } from 'lucide-react' export default function MemberDetailPage() { @@ -73,6 +79,16 @@ export default function MemberDetailPage() { { enabled: user?.role === 'MENTOR' } ) + // Juror evaluations (only fetched for jury members) + const isJuror = user?.role === 'JURY_MEMBER' || user?.roles?.includes('JURY_MEMBER') + const { data: jurorEvaluations } = trpc.evaluation.getJurorEvaluations.useQuery( + { userId }, + { enabled: !!user && !!isJuror } + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [selectedEvaluation, setSelectedEvaluation] = useState(null) + const [name, setName] = useState('') const [email, setEmail] = useState('') const [role, setRole] = useState('JURY_MEMBER') @@ -207,6 +223,26 @@ export default function MemberDetailPage() { )} + + + + + Profile + + {isJuror && ( + + + Evaluations + {jurorEvaluations && jurorEvaluations.length > 0 && ( + + {jurorEvaluations.length} + + )} + + )} + + +
{/* Basic Info */} @@ -430,6 +466,121 @@ export default function MemberDetailPage() { Save Changes
+
+ + {/* Evaluations Tab */} + {isJuror && ( + + {!jurorEvaluations || jurorEvaluations.length === 0 ? ( + + + +

No evaluations submitted yet

+
+
+ ) : ( + (() => { + // Group evaluations by round + const byRound = new Map() + for (const ev of jurorEvaluations) { + const key = ev.roundName + if (!byRound.has(key)) byRound.set(key, []) + byRound.get(key)!.push(ev) + } + return Array.from(byRound.entries()).map(([roundName, evals]) => ( + + + {roundName} + {evals.length} evaluation{evals.length !== 1 ? 's' : ''} + + + + + + Project + Score + Decision + Status + Submitted + + + + + {evals.map((ev) => ( + + + + {ev.projectTitle} + + + + {ev.evaluation.globalScore !== null && ev.evaluation.globalScore !== undefined + ? {ev.evaluation.globalScore}/10 + : -} + + + {ev.evaluation.binaryDecision !== null && ev.evaluation.binaryDecision !== undefined ? ( + ev.evaluation.binaryDecision ? ( +
+ + Yes +
+ ) : ( +
+ + No +
+ ) + ) : ( + - + )} +
+ + + {ev.evaluation.status.replace('_', ' ')} + + + + {ev.evaluation.submittedAt + ? new Date(ev.evaluation.submittedAt).toLocaleDateString() + : '-'} + + + + +
+ ))} +
+
+
+
+ )) + })() + )} + + { if (!open) setSelectedEvaluation(null) }} + onSaved={() => utils.evaluation.getJurorEvaluations.invalidate({ userId })} + /> +
+ )} +
+ {/* Super Admin Confirmation Dialog */} diff --git a/src/app/(admin)/admin/projects/[id]/edit/page.tsx b/src/app/(admin)/admin/projects/[id]/edit/page.tsx index 8c18e33..eea6b8b 100644 --- a/src/app/(admin)/admin/projects/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/edit/page.tsx @@ -267,12 +267,13 @@ function EditProjectContent({ projectId }: { projectId: string }) { return } + const statusChanged = data.status !== previousStatus await updateProject.mutateAsync({ id: projectId, title: data.title, teamName: data.teamName || null, description: data.description || null, - status: data.status, + ...(statusChanged && { status: data.status }), tags: data.tags, competitionCategory: (data.competitionCategory || null) as 'STARTUP' | 'BUSINESS_CONCEPT' | null, oceanIssue: (data.oceanIssue || null) as 'POLLUTION_REDUCTION' | 'CLIMATE_MITIGATION' | 'TECHNOLOGY_INNOVATION' | 'SUSTAINABLE_SHIPPING' | 'BLUE_CARBON' | 'HABITAT_RESTORATION' | 'COMMUNITY_CAPACITY' | 'SUSTAINABLE_FISHING' | 'CONSUMER_AWARENESS' | 'OCEAN_ACIDIFICATION' | 'OTHER' | null, diff --git a/src/app/(admin)/admin/projects/[id]/page.tsx b/src/app/(admin)/admin/projects/[id]/page.tsx index e56c742..b821c1e 100644 --- a/src/app/(admin)/admin/projects/[id]/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/page.tsx @@ -28,13 +28,7 @@ import { FileUpload } from '@/components/shared/file-upload' import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url' import { UserAvatar } from '@/components/shared/user-avatar' import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card' -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from '@/components/ui/sheet' +import { EvaluationEditSheet } from '@/components/admin/evaluation-edit-sheet' import { AnimatedCard } from '@/components/shared/animated-container' import { ArrowLeft, @@ -56,7 +50,6 @@ import { Loader2, ScanSearch, Eye, - MessageSquare, } from 'lucide-react' import { toast } from 'sonner' import { formatDate, formatDateOnly } from '@/lib/utils' @@ -742,10 +735,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { )} {/* Evaluation Detail Sheet */} - { if (!open) setSelectedEvalAssignment(null) }} + onSaved={() => utils.project.getFullDetail.invalidate({ id: projectId })} /> {/* AI Evaluation Summary */} @@ -830,173 +824,6 @@ 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 - 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() - 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 ( - - - - - - {assignment.user.name || assignment.user.email} - - - {ev.submittedAt - ? `Submitted ${formatDate(ev.submittedAt)}` - : 'Evaluation details'} - - - -
- {/* Global stats */} -
-
-

Score

-

- {ev.globalScore !== null ? `${ev.globalScore}/10` : '-'} -

-
-
-

Decision

-
- {ev.binaryDecision !== null ? ( - ev.binaryDecision ? ( -
- - Yes -
- ) : ( -
- - No -
- ) - ) : ( - - - )} -
-
-
- - {/* Criterion Scores */} - {hasScores && ( -
-

- - Criterion Scores -

-
- {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' || type === 'advance') { - return ( -
- {label} - {value === true ? ( - - - {meta?.trueLabel || 'Yes'} - - ) : ( - - - {meta?.falseLabel || 'No'} - - )} -
- ) - } - - if (type === 'text') { - return ( -
- {label} -
- {typeof value === 'string' ? value : String(value)} -
-
- ) - } - - // Numeric - return ( -
- {label} -
-
-
-
- - {typeof value === 'number' ? value : '-'} - -
-
- ) - })} -
-
- )} - - {/* Feedback Text */} - {ev.feedbackText && ( -
-

- - Feedback -

-
- {ev.feedbackText} -
-
- )} -
- - - ) -} - export default function ProjectDetailPage({ params }: PageProps) { const { id } = use(params) diff --git a/src/components/admin/evaluation-edit-sheet.tsx b/src/components/admin/evaluation-edit-sheet.tsx new file mode 100644 index 0000000..1d67cb1 --- /dev/null +++ b/src/components/admin/evaluation-edit-sheet.tsx @@ -0,0 +1,305 @@ +'use client' + +import { useState } from 'react' +import { trpc } from '@/lib/trpc/client' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' +import { UserAvatar } from '@/components/shared/user-avatar' +import { toast } from 'sonner' +import { formatDate } from '@/lib/utils' +import { + BarChart3, + ThumbsUp, + ThumbsDown, + MessageSquare, + Pencil, + Loader2, + Check, + X, +} from 'lucide-react' + +type EvaluationEditSheetProps = { + /** The assignment object with user, evaluation, and roundId */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assignment: any + open: boolean + onOpenChange: (open: boolean) => void + /** Called after a successful feedback edit */ + onSaved?: () => void +} + +export function EvaluationEditSheet({ + assignment, + open, + onOpenChange, + onSaved, +}: EvaluationEditSheetProps) { + const [isEditing, setIsEditing] = useState(false) + const [editedFeedback, setEditedFeedback] = useState('') + + const editMutation = trpc.evaluation.adminEditEvaluation.useMutation({ + onSuccess: () => { + toast.success('Feedback updated') + setIsEditing(false) + onSaved?.() + }, + onError: (err) => toast.error(err.message), + }) + + if (!assignment?.evaluation) return null + + const ev = assignment.evaluation + const criterionScores = (ev.criterionScoresJson || {}) as Record + const hasScores = Object.keys(criterionScores).length > 0 + + const roundId = assignment.roundId as string | undefined + + return ( + { + if (!v) setIsEditing(false) + onOpenChange(v) + }}> + + + + {assignment.user && ( + + )} + {assignment.user?.name || assignment.user?.email || 'Juror'} + + + {ev.submittedAt + ? `Submitted ${formatDate(ev.submittedAt)}` + : 'Evaluation details'} + + + +
+ {/* Global stats */} +
+
+

Score

+

+ {ev.globalScore !== null && ev.globalScore !== undefined ? `${ev.globalScore}/10` : '-'} +

+
+
+

Decision

+
+ {ev.binaryDecision !== null && ev.binaryDecision !== undefined ? ( + ev.binaryDecision ? ( +
+ + Yes +
+ ) : ( +
+ + No +
+ ) + ) : ( + - + )} +
+
+
+ + {/* Criterion Scores */} + {hasScores && ( + + )} + + {/* Feedback Text — editable */} + { + setEditedFeedback(ev.feedbackText || '') + setIsEditing(true) + }} + onCancelEdit={() => setIsEditing(false)} + onSave={() => { + editMutation.mutate({ + evaluationId: ev.id, + feedbackText: editedFeedback, + }) + }} + onChangeFeedback={setEditedFeedback} + isSaving={editMutation.isPending} + /> +
+
+
+ ) +} + +function CriterionScoresSection({ + criterionScores, + roundId, +}: { + criterionScores: Record + roundId?: string +}) { + const { data: activeForm } = trpc.evaluation.getStageForm.useQuery( + { roundId: roundId ?? '' }, + { enabled: !!roundId } + ) + + const criteriaMap = new Map() + 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 ( +
+

+ + Criterion Scores +

+
+ {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' || type === 'advance') { + return ( +
+ {label} + {value === true ? ( + + + {meta?.trueLabel || 'Yes'} + + ) : ( + + + {meta?.falseLabel || 'No'} + + )} +
+ ) + } + + if (type === 'text') { + return ( +
+ {label} +
+ {typeof value === 'string' ? value : String(value)} +
+
+ ) + } + + // Numeric + return ( +
+ {label} +
+
+
+
+ + {typeof value === 'number' ? value : '-'} + +
+
+ ) + })} +
+
+ ) +} + +function FeedbackSection({ + evaluationId, + feedbackText, + isEditing, + editedFeedback, + onStartEdit, + onCancelEdit, + onSave, + onChangeFeedback, + isSaving, +}: { + evaluationId: string + feedbackText: string | null + isEditing: boolean + editedFeedback: string + onStartEdit: () => void + onCancelEdit: () => void + onSave: () => void + onChangeFeedback: (v: string) => void + isSaving: boolean +}) { + return ( +
+
+

+ + Feedback +

+ {!isEditing && evaluationId && ( + + )} +
+ {isEditing ? ( +
+