feat(02-03): add Advance Top N dialog + batch-reject to RankingDashboard

- Add pendingReorderCount ref + onMutate/onSettled to saveReorderMutation (DASH-07)
- Add advanceMutation (trpc.round.advanceProjects) with getProjectStates invalidation
- Add batchRejectMutation (trpc.roundEngine.batchTransition) using .length per MEMORY.md
- Add handleAdvance: advances top N per category, optionally batch-rejects the rest
- Add Advance Top N button in header (disabled when saveReorderMutation.isPending)
- Add Dialog with per-category N inputs, batch-reject checkbox, and count preview
- Import Dialog, Input, Label, Trophy from shadcn/lucide
This commit is contained in:
2026-02-27 09:53:49 +01:00
parent 84031a4e04
commit a6f3945337

View File

@@ -33,11 +33,22 @@ import {
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} from '@/components/ui/sheet' } 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 { import {
GripVertical, GripVertical,
BarChart3, BarChart3,
Loader2, Loader2,
RefreshCw, RefreshCw,
Trophy,
} from 'lucide-react' } from 'lucide-react'
import type { RankedProjectEntry } from '@/server/services/ai-ranking' import type { RankedProjectEntry } from '@/server/services/ai-ranking'
@@ -158,6 +169,13 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
BUSINESS_CONCEPT: [], BUSINESS_CONCEPT: [],
}) })
const initialized = useRef(false) 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 ────────────────────────────────────────────────────────────── // ─── Sensors ──────────────────────────────────────────────────────────────
const sensors = useSensors( const sensors = useSensors(
@@ -188,6 +206,8 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
const utils = trpc.useUtils() const utils = trpc.useUtils()
const saveReorderMutation = trpc.ranking.saveReorder.useMutation({ const saveReorderMutation = trpc.ranking.saveReorder.useMutation({
onMutate: () => { pendingReorderCount.current++ },
onSettled: () => { pendingReorderCount.current-- },
onError: (err) => toast.error(`Failed to save order: ${err.message}`), onError: (err) => toast.error(`Failed to save order: ${err.message}`),
// Do NOT invalidate getSnapshot — would reset localOrder // Do NOT invalidate getSnapshot — would reset localOrder
}) })
@@ -201,6 +221,28 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
onError: (err) => toast.error(err.message), 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) ────────────────────────────────────────────── // ─── rankingMap (O(1) lookup) ──────────────────────────────────────────────
const rankingMap = useMemo(() => { const rankingMap = useMemo(() => {
const map = new Map<string, RankedProjectEntry>() const map = new Map<string, RankedProjectEntry>()
@@ -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 ──────────────────────────────────────────────────────── // ─── Loading state ────────────────────────────────────────────────────────
if (snapshotsLoading || snapshotLoading) { if (snapshotsLoading || snapshotLoading) {
return ( return (
@@ -318,12 +380,12 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
</CardDescription> </CardDescription>
)} )}
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => triggerRankMutation.mutate({ roundId })} onClick={() => triggerRankMutation.mutate({ roundId })}
disabled={triggerRankMutation.isPending} disabled={triggerRankMutation.isPending}
className="flex-shrink-0"
> >
{triggerRankMutation.isPending ? ( {triggerRankMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -332,6 +394,19 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
)} )}
Run Ranking Run Ranking
</Button> </Button>
<Button
size="sm"
disabled={saveReorderMutation.isPending || advanceMutation.isPending || !latestSnapshotId}
onClick={() => setAdvanceDialogOpen(true)}
className="bg-[#053d57] hover:bg-[#053d57]/90"
>
{advanceMutation.isPending ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Advancing...</>
) : (
<><Trophy className="h-4 w-4 mr-2" /> Advance Top N</>
)}
</Button>
</div>
</CardHeader> </CardHeader>
</Card> </Card>
@@ -388,6 +463,114 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
))} ))}
</div> </div>
{/* Advance Top N dialog */}
<Dialog open={advanceDialogOpen} onOpenChange={setAdvanceDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Advance Top Projects</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Top N for STARTUP */}
{localOrder.STARTUP.length > 0 && (
<div className="flex items-center gap-3">
<Label className="w-40 text-sm">Startups to advance</Label>
<Input
type="number"
min={0}
max={localOrder.STARTUP.length}
value={topNStartup}
onChange={(e) =>
setTopNStartup(
Math.max(0, Math.min(localOrder.STARTUP.length, parseInt(e.target.value) || 0)),
)
}
className="w-24"
/>
<span className="text-xs text-muted-foreground">of {localOrder.STARTUP.length}</span>
</div>
)}
{/* Top N for BUSINESS_CONCEPT */}
{localOrder.BUSINESS_CONCEPT.length > 0 && (
<div className="flex items-center gap-3">
<Label className="w-40 text-sm">Business concepts to advance</Label>
<Input
type="number"
min={0}
max={localOrder.BUSINESS_CONCEPT.length}
value={topNConceptual}
onChange={(e) =>
setTopNConceptual(
Math.max(0, Math.min(localOrder.BUSINESS_CONCEPT.length, parseInt(e.target.value) || 0)),
)
}
className="w-24"
/>
<span className="text-xs text-muted-foreground">of {localOrder.BUSINESS_CONCEPT.length}</span>
</div>
)}
{/* Optional: also batch-reject non-advanced */}
<div className="flex items-center gap-2 pt-2 border-t">
<input
type="checkbox"
id="includeReject"
checked={includeReject}
onChange={(e) => setIncludeReject(e.target.checked)}
className="h-4 w-4 accent-[#de0f1e]"
/>
<Label htmlFor="includeReject" className="text-sm cursor-pointer">
Also batch-reject non-advanced projects
</Label>
</div>
{/* Preview */}
<div className="text-xs text-muted-foreground bg-muted/50 rounded-md p-3">
<p>Advancing: {topNStartup + topNConceptual} projects</p>
{includeReject && (
<p>
Rejecting:{' '}
{localOrder.STARTUP.length - topNStartup + (localOrder.BUSINESS_CONCEPT.length - topNConceptual)}{' '}
projects
</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setAdvanceDialogOpen(false)}
disabled={advanceMutation.isPending || batchRejectMutation.isPending}
>
Cancel
</Button>
<Button
onClick={handleAdvance}
disabled={
advanceMutation.isPending ||
batchRejectMutation.isPending ||
topNStartup + topNConceptual === 0
}
className="bg-[#053d57] hover:bg-[#053d57]/90"
>
{advanceMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Advancing...
</>
) : (
`Advance ${topNStartup + topNConceptual} Project${topNStartup + topNConceptual !== 1 ? 's' : ''}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Side panel Sheet */} {/* Side panel Sheet */}
<Sheet <Sheet
open={!!selectedProjectId} open={!!selectedProjectId}