diff --git a/src/components/admin/round/ranking-dashboard.tsx b/src/components/admin/round/ranking-dashboard.tsx index 6aa9f44..b61d5b1 100644 --- a/src/components/admin/round/ranking-dashboard.tsx +++ b/src/components/admin/round/ranking-dashboard.tsx @@ -33,11 +33,22 @@ import { SheetTitle, SheetDescription, } from '@/components/ui/sheet' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' import { GripVertical, BarChart3, Loader2, RefreshCw, + Trophy, } from 'lucide-react' import type { RankedProjectEntry } from '@/server/services/ai-ranking' @@ -158,6 +169,13 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran BUSINESS_CONCEPT: [], }) const initialized = useRef(false) + const pendingReorderCount = useRef(0) + + // ─── Advance dialog state ───────────────────────────────────────────────── + const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false) + const [topNStartup, setTopNStartup] = useState(3) + const [topNConceptual, setTopNConceptual] = useState(3) + const [includeReject, setIncludeReject] = useState(false) // ─── Sensors ────────────────────────────────────────────────────────────── const sensors = useSensors( @@ -188,6 +206,8 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran const utils = trpc.useUtils() const saveReorderMutation = trpc.ranking.saveReorder.useMutation({ + onMutate: () => { pendingReorderCount.current++ }, + onSettled: () => { pendingReorderCount.current-- }, onError: (err) => toast.error(`Failed to save order: ${err.message}`), // Do NOT invalidate getSnapshot — would reset localOrder }) @@ -201,6 +221,28 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran onError: (err) => toast.error(err.message), }) + const advanceMutation = trpc.round.advanceProjects.useMutation({ + onSuccess: (data) => { + toast.success(`Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`) + void utils.roundEngine.getProjectStates.invalidate({ roundId }) + setAdvanceDialogOpen(false) + }, + onError: (err) => toast.error(err.message), + }) + + const batchRejectMutation = trpc.roundEngine.batchTransition.useMutation({ + onSuccess: (data) => { + // MEMORY.md: use .length, not direct value comparison + toast.success(`Rejected ${data.succeeded.length} project(s)`) + if (data.failed.length > 0) { + toast.warning(`${data.failed.length} project(s) could not be rejected`) + } + void utils.roundEngine.getProjectStates.invalidate({ roundId }) + setAdvanceDialogOpen(false) + }, + onError: (err) => toast.error(err.message), + }) + // ─── rankingMap (O(1) lookup) ────────────────────────────────────────────── const rankingMap = useMemo(() => { const map = new Map() @@ -244,6 +286,26 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran }) } + // ─── handleAdvance ──────────────────────────────────────────────────────── + function handleAdvance() { + const advanceIds = [ + ...localOrder.STARTUP.slice(0, topNStartup), + ...localOrder.BUSINESS_CONCEPT.slice(0, topNConceptual), + ] + const advanceSet = new Set(advanceIds) + + advanceMutation.mutate({ roundId, projectIds: advanceIds }) + + if (includeReject) { + const rejectIds = [...localOrder.STARTUP, ...localOrder.BUSINESS_CONCEPT].filter( + (id) => !advanceSet.has(id), + ) + if (rejectIds.length > 0) { + batchRejectMutation.mutate({ projectIds: rejectIds, roundId, newState: 'REJECTED' }) + } + } + } + // ─── Loading state ──────────────────────────────────────────────────────── if (snapshotsLoading || snapshotLoading) { return ( @@ -318,20 +380,33 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran )} - +
+ + +
@@ -388,6 +463,114 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran ))} + {/* Advance Top N dialog */} + + + + Advance Top Projects + + Select how many top-ranked projects to advance to the next round per category. + Projects are advanced in the order shown in the ranking list. + + + +
+ {/* Top N for STARTUP */} + {localOrder.STARTUP.length > 0 && ( +
+ + + setTopNStartup( + Math.max(0, Math.min(localOrder.STARTUP.length, parseInt(e.target.value) || 0)), + ) + } + className="w-24" + /> + of {localOrder.STARTUP.length} +
+ )} + + {/* Top N for BUSINESS_CONCEPT */} + {localOrder.BUSINESS_CONCEPT.length > 0 && ( +
+ + + setTopNConceptual( + Math.max(0, Math.min(localOrder.BUSINESS_CONCEPT.length, parseInt(e.target.value) || 0)), + ) + } + className="w-24" + /> + of {localOrder.BUSINESS_CONCEPT.length} +
+ )} + + {/* Optional: also batch-reject non-advanced */} +
+ setIncludeReject(e.target.checked)} + className="h-4 w-4 accent-[#de0f1e]" + /> + +
+ + {/* Preview */} +
+

Advancing: {topNStartup + topNConceptual} projects

+ {includeReject && ( +

+ Rejecting:{' '} + {localOrder.STARTUP.length - topNStartup + (localOrder.BUSINESS_CONCEPT.length - topNConceptual)}{' '} + projects +

+ )} +
+
+ + + + + +
+
+ {/* Side panel Sheet */}