diff --git a/src/components/admin/assignment/assignment-preview-sheet.tsx b/src/components/admin/assignment/assignment-preview-sheet.tsx index 82998bc..d0734c6 100644 --- a/src/components/admin/assignment/assignment-preview-sheet.tsx +++ b/src/components/admin/assignment/assignment-preview-sheet.tsx @@ -1,7 +1,19 @@ 'use client' -import { useState, useEffect } from 'react' -import { AlertTriangle, Bot, CheckCircle2 } from 'lucide-react' +import { useState, useEffect, useMemo } from 'react' +import { + AlertTriangle, + Bot, + CheckCircle2, + ChevronDown, + ChevronRight, + Loader2, + Plus, + Sparkles, + Tag, + User, + X, +} from 'lucide-react' import { toast } from 'sonner' import { trpc } from '@/lib/trpc/client' import { @@ -17,8 +29,41 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' -interface AssignmentPreviewSheetProps { +type EditableAssignment = { + localId: string + userId: string + userName: string + projectId: string + projectTitle: string + score: number + reasoning: string[] + matchingTags: string[] + policyViolations: string[] + fromIntent: boolean + isManual: boolean +} + +type AssignmentPreviewSheetProps = { roundId: string open: boolean onOpenChange: (open: boolean) => void @@ -33,153 +78,707 @@ export function AssignmentPreviewSheet({ }: AssignmentPreviewSheetProps) { const utils = trpc.useUtils() + const [assignments, setAssignments] = useState([]) + const [initialized, setInitialized] = useState(false) + const [expandedJurors, setExpandedJurors] = useState>(new Set()) + const [addJurorId, setAddJurorId] = useState('') + const [addProjectId, setAddProjectId] = useState('') + + // ── Queries ────────────────────────────────────────────────────────────── const { data: preview, isLoading, refetch, } = trpc.roundAssignment.preview.useQuery( { roundId, honorIntents: true, requiredReviews }, - { enabled: open } + { enabled: open }, ) - const { mutate: execute, isPending: isExecuting } = trpc.roundAssignment.execute.useMutation({ - onSuccess: (result) => { - toast.success(`Created ${result.created} assignments`) - utils.roundAssignment.coverageReport.invalidate({ roundId }) - utils.roundAssignment.unassignedQueue.invalidate({ roundId }) - utils.assignment.listByStage.invalidate({ roundId }) - utils.roundEngine.getProjectStates.invalidate({ roundId }) - onOpenChange(false) - }, - onError: (err) => { - toast.error(err.message) - }, - }) + // Fetch round data for jury group members + const { data: round } = trpc.round.getById.useQuery( + { id: roundId }, + { enabled: open }, + ) + // Fetch projects in this round + const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery( + { roundId }, + { enabled: open }, + ) + + // Fetch existing assignments to exclude already-assigned pairs + const { data: existingAssignments } = trpc.assignment.listByStage.useQuery( + { roundId }, + { enabled: open }, + ) + + const { mutate: execute, isPending: isExecuting } = + trpc.roundAssignment.execute.useMutation({ + onSuccess: (result) => { + toast.success(`Created ${result.created} assignments`) + utils.roundAssignment.coverageReport.invalidate({ roundId }) + utils.roundAssignment.unassignedQueue.invalidate({ roundId }) + utils.assignment.listByStage.invalidate({ roundId }) + utils.roundEngine.getProjectStates.invalidate({ roundId }) + onOpenChange(false) + }, + onError: (err) => { + toast.error(err.message) + }, + }) + + // ── Initialize local state from preview ────────────────────────────────── useEffect(() => { - if (open) { - refetch() + if (preview && !initialized) { + const mapped: EditableAssignment[] = preview.assignments.map( + (a: any, idx: number) => ({ + localId: `ai-${idx}`, + userId: a.userId, + userName: a.userName, + projectId: a.projectId, + projectTitle: a.projectTitle, + score: a.score, + reasoning: a.reasoning ?? [], + matchingTags: a.matchingTags ?? [], + policyViolations: a.policyViolations ?? [], + fromIntent: a.fromIntent ?? false, + isManual: false, + }), + ) + setAssignments(mapped) + // Auto-expand all jurors + const jurorIds = new Set(mapped.map((a) => a.userId)) + setExpandedJurors(jurorIds) + setInitialized(true) } - }, [open, refetch]) + }, [preview, initialized]) + + // Reset when sheet closes + useEffect(() => { + if (!open) { + setInitialized(false) + setAssignments([]) + setExpandedJurors(new Set()) + setAddJurorId('') + setAddProjectId('') + } + }, [open]) + + // ── Derived data ───────────────────────────────────────────────────────── + const juryMembers = useMemo(() => { + if (!round?.juryGroup?.members) return [] + return round.juryGroup.members.map((m: any) => ({ + userId: m.userId, + name: m.user?.name ?? m.userId, + role: m.role, + })) + }, [round]) + + const projects = useMemo((): Array<{ id: string; title: string; category?: string }> => { + if (!projectStates) return [] + return projectStates.map((ps: any) => ({ + id: ps.project?.id ?? ps.projectId, + title: ps.project?.title ?? ps.projectId, + category: ps.project?.competitionCategory, + })) + }, [projectStates]) + + // Build set of existing assignment pairs (already committed) + const existingPairs = useMemo(() => { + const pairs = new Set() + for (const a of existingAssignments ?? []) { + pairs.add(`${a.userId}:${a.projectId}`) + } + return pairs + }, [existingAssignments]) + + // Build set of current preview assignment pairs + const currentPairs = useMemo(() => { + const pairs = new Set() + for (const a of assignments) { + pairs.add(`${a.userId}:${a.projectId}`) + } + return pairs + }, [assignments]) + + // Group assignments by juror + const groupedByJuror = useMemo(() => { + const map = new Map< + string, + { userId: string; userName: string; assignments: EditableAssignment[] } + >() + for (const a of assignments) { + if (!map.has(a.userId)) { + map.set(a.userId, { userId: a.userId, userName: a.userName, assignments: [] }) + } + map.get(a.userId)!.assignments.push(a) + } + return Array.from(map.values()).sort((a, b) => + b.assignments.length - a.assignments.length, + ) + }, [assignments]) + + // Stats + const totalAssignments = assignments.length + const uniqueProjects = new Set(assignments.map((a) => a.projectId)).size + const uniqueJurors = new Set(assignments.map((a) => a.userId)).size + const manualCount = assignments.filter((a) => a.isManual).length + const removedCount = (preview?.stats.assignmentsGenerated ?? 0) - assignments.filter((a) => !a.isManual).length + + // Projects available for a specific juror (not already assigned in preview or existing) + const getAvailableProjectsForJuror = (userId: string) => { + return projects.filter((p) => { + const pairKey = `${userId}:${p.id}` + return !currentPairs.has(pairKey) && !existingPairs.has(pairKey) + }) + } + + // ── Actions ────────────────────────────────────────────────────────────── + const removeAssignment = (localId: string) => { + setAssignments((prev) => prev.filter((a) => a.localId !== localId)) + } + + const addAssignment = () => { + if (!addJurorId || !addProjectId) { + toast.error('Select both a juror and a project') + return + } + + const juror = juryMembers.find((m) => m.userId === addJurorId) + const project = projects.find((p) => p.id === addProjectId) + if (!juror || !project) return + + const pairKey = `${addJurorId}:${addProjectId}` + if (currentPairs.has(pairKey) || existingPairs.has(pairKey)) { + toast.error('This assignment already exists') + return + } + + setAssignments((prev) => [ + ...prev, + { + localId: `manual-${Date.now()}`, + userId: addJurorId, + userName: juror.name, + projectId: addProjectId, + projectTitle: project.title, + score: 0, + reasoning: ['Manually added by admin'], + matchingTags: [], + policyViolations: [], + fromIntent: false, + isManual: true, + }, + ]) + + // Expand the juror's section + setExpandedJurors((prev) => new Set([...prev, addJurorId])) + setAddProjectId('') + } + + const addProjectToJuror = (userId: string, projectId: string) => { + const juror = juryMembers.find((m) => m.userId === userId) + const project = projects.find((p) => p.id === projectId) + if (!juror || !project) return + + setAssignments((prev) => [ + ...prev, + { + localId: `manual-${Date.now()}`, + userId, + userName: juror.name, + projectId, + projectTitle: project.title, + score: 0, + reasoning: ['Manually added by admin'], + matchingTags: [], + policyViolations: [], + fromIntent: false, + isManual: true, + }, + ]) + } + + const toggleJuror = (userId: string) => { + setExpandedJurors((prev) => { + const next = new Set(prev) + if (next.has(userId)) next.delete(userId) + else next.add(userId) + return next + }) + } const handleExecute = () => { - if (!preview?.assignments || preview.assignments.length === 0) { + if (assignments.length === 0) { toast.error('No assignments to execute') return } execute({ roundId, - assignments: preview.assignments.map((a: any) => ({ + assignments: assignments.map((a) => ({ userId: a.userId, projectId: a.projectId, })), }) } + // ── Render ─────────────────────────────────────────────────────────────── return ( - - - Assignment Preview - + + + Assignment Preview + AI Suggested - Review the proposed assignments before executing. All assignments are admin-approved on execute. + Review and fine-tune before executing. - - {isLoading ? ( -
- {[1, 2, 3].map((i) => ( - - ))} -
- ) : preview ? ( -
- - - - - {preview.stats.assignmentsGenerated || 0} Assignments Proposed - - - -

- {preview.stats.totalJurors || 0} jurors will receive assignments -

-
-
+ +
+ {isLoading ? ( +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ ) : preview ? ( + <> + {/* ── Summary stats ── */} +
+ {[ + { label: 'Assignments', value: totalAssignments }, + { label: 'Projects', value: uniqueProjects }, + { label: 'Jurors', value: uniqueJurors }, + { label: 'Unassigned', value: Math.max(0, (preview.stats.totalProjects ?? 0) - uniqueProjects) }, + ].map((stat) => ( +
+

{stat.value}

+

{stat.label}

+
+ ))} +
- {preview.warnings && preview.warnings.length > 0 && ( - - - - - Warnings ({preview.warnings.length}) + {/* Modification indicator */} + {(manualCount > 0 || removedCount > 0) && ( +
+ {manualCount > 0 && ( + + + {manualCount} added manually + + )} + {removedCount > 0 && ( + + + {removedCount} removed + + )} +
+ )} + + {/* ── Warnings ── */} + {preview.warnings && preview.warnings.length > 0 && ( + + +
+ +
+ {preview.warnings.map((w: string, idx: number) => ( +

+ {w} +

+ ))} +
+
+
+
+ )} + + {/* ── Juror groups ── */} +
+

+ Assignments by Juror +

+ {groupedByJuror.length === 0 ? ( + + + No assignments. Add some manually below. + + + ) : ( + groupedByJuror.map((group) => ( + toggleJuror(group.userId)} + onRemove={removeAssignment} + onAddProject={(projectId) => + addProjectToJuror(group.userId, projectId) + } + availableProjects={getAvailableProjectsForJuror(group.userId)} + requiredReviews={requiredReviews} + /> + )) + )} +
+ + {/* ── Add assignment manually ── */} + + + + + Add Assignment - -
    - {preview.warnings.map((warning: string, idx: number) => ( -
  • - - {warning} -
  • - ))} -
-
-
- )} - - {preview.assignments && preview.assignments.length > 0 && ( - - - Assignment Summary - - -
-
- Total assignments: - {preview.assignments.length} -
-
- Unique projects: - - {new Set(preview.assignments.map((a: any) => a.projectId)).size} - -
-
- Unique jurors: - - {new Set(preview.assignments.map((a: any) => a.userId)).size} - -
+ +
+ + +
- )} -
- ) : ( -

No preview data available

- )} + + ) : ( +

+ No preview data available +

+ )} +
- + ) } + +// ─── Juror Group Card ──────────────────────────────────────────────────────── + +type JurorGroupProps = { + group: { + userId: string + userName: string + assignments: EditableAssignment[] + } + expanded: boolean + onToggle: () => void + onRemove: (localId: string) => void + onAddProject: (projectId: string) => void + availableProjects: Array<{ id: string; title: string; category?: string }> + requiredReviews: number +} + +function JurorGroup({ + group, + expanded, + onToggle, + onRemove, + onAddProject, + availableProjects, + requiredReviews, +}: JurorGroupProps) { + const [inlineProjectId, setInlineProjectId] = useState('') + + const handleInlineAdd = () => { + if (!inlineProjectId) return + onAddProject(inlineProjectId) + setInlineProjectId('') + } + + const aiCount = group.assignments.filter((a) => !a.isManual).length + const manualCount = group.assignments.filter((a) => a.isManual).length + const avgScore = + group.assignments.length > 0 + ? Math.round( + group.assignments.reduce((sum, a) => sum + a.score, 0) / + group.assignments.length, + ) + : 0 + + return ( + + + + + + + +
+ {group.assignments.map((a) => ( + + ))} + + {/* Inline add project */} + {availableProjects.length > 0 && ( +
+ + +
+ )} +
+
+
+
+ ) +} + +// ─── Assignment Row ────────────────────────────────────────────────────────── + +function AssignmentRow({ + assignment, + onRemove, +}: { + assignment: EditableAssignment + onRemove: (localId: string) => void +}) { + const a = assignment + + return ( +
+
+
+ {a.projectTitle} + {a.isManual ? ( + + Manual + + ) : a.fromIntent ? ( + + Intent + + ) : null} +
+ + {/* Score + tags + reasoning */} +
+ {!a.isManual && a.score > 0 && ( + + + + = 50 + ? 'border-green-300 text-green-700' + : a.score >= 25 + ? 'border-amber-300 text-amber-700' + : 'border-red-300 text-red-700', + )} + > + + {Math.round(a.score)} + + + +

Match Score Breakdown

+
    + {a.reasoning.map((r, i) => ( +
  • • {r}
  • + ))} +
+
+
+
+ )} + {a.matchingTags.slice(0, 3).map((tag) => ( + + + {tag} + + ))} + {a.matchingTags.length > 3 && ( + + +{a.matchingTags.length - 3} more + + )} +
+ + {/* Policy violations */} + {a.policyViolations.length > 0 && ( +
+ + + {a.policyViolations.join(', ')} + +
+ )} +
+ + +
+ ) +} diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts index 95797c8..577efc8 100644 --- a/src/server/routers/round.ts +++ b/src/server/routers/round.ts @@ -99,7 +99,13 @@ export const roundRouter = router({ where: { id: input.id }, include: { juryGroup: { - include: { members: true }, + include: { + members: { + include: { + user: { select: { id: true, name: true, email: true } }, + }, + }, + }, }, submissionWindow: { include: { fileRequirements: true },