Add commissioner replace pick and draft rollback features (#18)

Commissioners can now right-click any picked cell in the draft grid to
replace the drafted participant (available during and after draft) or
roll back the draft to that pick number (available during draft only).

Replace pick enforces sport eligibility with the replaced slot treated
as free, updates the pick in-place, and broadcasts a pick-replaced
socket event so all clients update in real time. Rollback deletes all
picks from the selected pick onward, resets the season pick number,
clears all timers, and creates a fresh timer for the team now on the
clock.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-02-20 22:47:29 -08:00 committed by GitHub
parent 285981ab9d
commit 41f3654956
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 691 additions and 65 deletions

View file

@ -37,6 +37,8 @@ interface DraftGridSectionProps {
isCommissioner: boolean;
onForceAutopick: (pickNumber: number, teamId: string) => void;
onForceManualPickOpen: (pickNumber: number, teamId: string) => void;
onReplacePick?: (pickNumber: number, teamId: string) => void;
onRollbackToPick?: (pickNumber: number) => void;
}
export function DraftGridSection({
@ -49,6 +51,8 @@ export function DraftGridSection({
isCommissioner,
onForceAutopick,
onForceManualPickOpen,
onReplacePick,
onRollbackToPick,
}: DraftGridSectionProps) {
const formatTime = (seconds: number | null) => {
if (seconds === null) return "--:--";
@ -120,71 +124,16 @@ export function DraftGridSection({
const isCurrent = cell.pickNumber === currentPick;
const isPicked = !!cell.pick;
return isCommissioner && !isPicked && isCurrent ? (
<ContextMenu key={cell.pickNumber}>
<ContextMenuTrigger asChild>
<div
className={`flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${
isCurrent
? "border-electric bg-electric/15 shadow-lg shadow-electric/10"
: isPicked
? "bg-emerald-500/10 border-emerald-500/30"
: "border-border bg-card"
}`}
title={`Overall Pick #${cell.pickNumber}`}
>
<div className="text-xs font-mono text-muted-foreground mb-1">
{cell.round}.
{String(cell.pickInRound).padStart(2, "0")}
</div>
{isPicked ? (
<div className="text-xs">
<div className="font-semibold truncate">
{cell.pick?.participant.name}
</div>
<div className="text-muted-foreground truncate">
{cell.pick?.sport.name}
</div>
</div>
) : isCurrent ? (
<div className="text-xs font-semibold text-electric">
On Clock
</div>
) : null}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() =>
onForceAutopick(cell.pickNumber, cell.teamId)
}
>
Force Auto Pick
</ContextMenuItem>
<ContextMenuItem
onClick={() =>
onForceManualPickOpen(
cell.pickNumber,
cell.teamId
)
}
>
Force Manual Pick
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
) : (
<div
key={cell.pickNumber}
className={`flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${
isCurrent
? "border-electric bg-electric/15 shadow-lg shadow-electric/10"
: isPicked
? "bg-emerald-500/10 border-emerald-500/30"
: "border-border bg-card"
}`}
title={`Overall Pick #${cell.pickNumber}`}
>
const cellClassName = `flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${
isCurrent
? "border-electric bg-electric/15 shadow-lg shadow-electric/10"
: isPicked
? "bg-emerald-500/10 border-emerald-500/30"
: "border-border bg-card"
}`;
const cellInner = (
<>
<div className="text-xs font-mono text-muted-foreground mb-1">
{cell.round}.
{String(cell.pickInRound).padStart(2, "0")}
@ -203,6 +152,86 @@ export function DraftGridSection({
On Clock
</div>
) : null}
</>
);
// Commissioner context menu on the current unpicked cell
if (isCommissioner && !isPicked && isCurrent) {
return (
<ContextMenu key={cell.pickNumber}>
<ContextMenuTrigger asChild>
<div
className={cellClassName}
title={`Overall Pick #${cell.pickNumber}`}
>
{cellInner}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() =>
onForceAutopick(cell.pickNumber, cell.teamId)
}
>
Force Auto Pick
</ContextMenuItem>
<ContextMenuItem
onClick={() =>
onForceManualPickOpen(
cell.pickNumber,
cell.teamId
)
}
>
Force Manual Pick
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
// Commissioner context menu on already-picked cells
if (isCommissioner && isPicked && (onReplacePick || onRollbackToPick)) {
return (
<ContextMenu key={cell.pickNumber}>
<ContextMenuTrigger asChild>
<div
className={cellClassName}
title={`Overall Pick #${cell.pickNumber}`}
>
{cellInner}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{onReplacePick && (
<ContextMenuItem
onClick={() =>
onReplacePick(cell.pickNumber, cell.teamId)
}
>
Replace Pick
</ContextMenuItem>
)}
{onRollbackToPick && (
<ContextMenuItem
onClick={() => onRollbackToPick(cell.pickNumber)}
className="text-destructive focus:text-destructive"
>
Roll Back to This Pick
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
}
return (
<div
key={cell.pickNumber}
className={cellClassName}
title={`Overall Pick #${cell.pickNumber}`}
>
{cellInner}
</div>
);
})}

View file

@ -38,6 +38,8 @@ export default [
route("api/draft/resume", "routes/api/draft.resume.ts"),
route("api/draft/force-autopick", "routes/api/draft.force-autopick.ts"),
route("api/draft/force-manual-pick", "routes/api/draft.force-manual-pick.ts"),
route("api/draft/replace-pick", "routes/api/draft.replace-pick.ts"),
route("api/draft/rollback", "routes/api/draft.rollback.ts"),
route("api/autodraft/update", "routes/api/autodraft.update.ts"),
route("user-profile", "routes/user-profile.tsx"),
route("how-to-play", "routes/how-to-play.tsx"),

View file

@ -0,0 +1,192 @@
import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
import { getSocketIO } from "../../../server/socket";
export async function action(args: any) {
const { request } = args;
const auth = await getAuth(args);
const userId = (auth as any).userId as string | null;
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const seasonId = formData.get("seasonId") as string;
const participantId = formData.get("participantId") as string;
const pickNumber = parseInt(formData.get("pickNumber") as string);
if (!seasonId || !participantId || isNaN(pickNumber) || pickNumber < 1) {
return Response.json({ error: "Missing required fields" }, { status: 400 });
}
const db = database();
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (!season) {
return Response.json({ error: "Season not found" }, { status: 404 });
}
const isCommissioner = await db.query.commissioners.findFirst({
where: and(
eq(schema.commissioners.leagueId, season.leagueId),
eq(schema.commissioners.userId, userId)
),
});
if (!isCommissioner) {
return Response.json({ error: "Only commissioners can replace picks" }, { status: 403 });
}
if (season.status === "pre_draft") {
return Response.json({ error: "Draft has not started" }, { status: 400 });
}
// Get the existing pick at this slot
const existingPick = await db.query.draftPicks.findFirst({
where: and(
eq(schema.draftPicks.seasonId, seasonId),
eq(schema.draftPicks.pickNumber, pickNumber)
),
});
if (!existingPick) {
return Response.json({ error: "No pick found at this slot" }, { status: 404 });
}
// Use the team from the DB record — don't trust the client-supplied teamId
const teamId = existingPick.teamId;
const oldParticipantId = existingPick.participantId;
// Check new participant isn't already drafted elsewhere (skip if same participant)
if (participantId !== oldParticipantId) {
const alreadyDrafted = await db.query.draftPicks.findFirst({
where: and(
eq(schema.draftPicks.seasonId, seasonId),
eq(schema.draftPicks.participantId, participantId)
),
});
if (alreadyDrafted) {
return Response.json({ error: "Participant already drafted" }, { status: 400 });
}
}
const participant = await db.query.participants.findFirst({
where: eq(schema.participants.id, participantId),
with: {
sportsSeason: { with: { sport: true } },
},
});
if (!participant) {
return Response.json({ error: "Participant not found" }, { status: 404 });
}
// Eligibility check — exclude the pick being replaced so its slot is treated as free
const draftSlots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, seasonId),
orderBy: schema.draftSlots.draftOrder,
with: { team: true },
});
const allPicksWithSports = await getDraftPicksWithSports(seasonId);
const teamPicksWithSports = await getTeamDraftPicksWithSports(teamId, seasonId);
const allParticipants = await getParticipantsForSeasonWithSports(seasonId);
const seasonSports = await getSeasonSportsSimple(seasonId);
// Exclude old participant so that slot is "open" for eligibility purposes
const allPicksExcluding = allPicksWithSports.filter(
(p) => p.participant.id !== oldParticipantId
);
const teamPicksExcluding = teamPicksWithSports.filter(
(p) => p.participant.id !== oldParticipantId
);
const allTeams = draftSlots.map((slot) => ({ id: slot.teamId }));
const eligibility = calculateDraftEligibility(
teamId,
teamPicksExcluding,
allPicksExcluding,
allParticipants,
seasonSports,
season.draftRounds,
allTeams
);
const sportId = participant.sportsSeason.sport.id;
if (!eligibility.eligibleSportIds.has(sportId)) {
const reason = eligibility.ineligibleReasons[sportId] || "Cannot draft from this sport";
return Response.json({ error: reason }, { status: 400 });
}
// Update the pick in-place
const [updatedPick] = await db
.update(schema.draftPicks)
.set({
participantId,
pickedByUserId: userId,
pickedByType: "commissioner",
})
.where(
and(
eq(schema.draftPicks.seasonId, seasonId),
eq(schema.draftPicks.pickNumber, pickNumber)
)
)
.returning();
// Remove new participant from all team queues
await db
.delete(schema.draftQueue)
.where(
and(
eq(schema.draftQueue.seasonId, seasonId),
eq(schema.draftQueue.participantId, participantId)
)
);
// Emit socket events
try {
const io = getSocketIO();
const team = draftSlots.find((slot) => slot.team.id === teamId)?.team;
io.to(`draft-${seasonId}`).emit("pick-replaced", {
seasonId,
pickNumber,
oldParticipantId,
pick: {
id: updatedPick.id,
pickNumber: updatedPick.pickNumber,
round: updatedPick.round,
pickInRound: updatedPick.pickInRound,
timeUsed: updatedPick.timeUsed,
team,
participant: {
id: participant.id,
name: participant.name,
sport: participant.sportsSeason.sport,
},
sport: participant.sportsSeason.sport,
},
});
io.to(`draft-${seasonId}`).emit("participant-removed-from-queues", {
participantId,
});
} catch (error) {
console.error("Socket.IO error:", error);
}
return Response.json({ success: true });
}

View file

@ -0,0 +1,125 @@
import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, gte } from "drizzle-orm";
import { getSocketIO } from "../../../server/socket";
export async function action(args: any) {
const { request } = args;
const auth = await getAuth(args);
const userId = (auth as any).userId as string | null;
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const seasonId = formData.get("seasonId") as string;
const pickNumber = parseInt(formData.get("pickNumber") as string);
if (!seasonId || isNaN(pickNumber) || pickNumber < 1) {
return Response.json({ error: "Missing required fields" }, { status: 400 });
}
const db = database();
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (!season) {
return Response.json({ error: "Season not found" }, { status: 404 });
}
const isCommissioner = await db.query.commissioners.findFirst({
where: and(
eq(schema.commissioners.leagueId, season.leagueId),
eq(schema.commissioners.userId, userId)
),
});
if (!isCommissioner) {
return Response.json({ error: "Only commissioners can roll back the draft" }, { status: 403 });
}
if (season.status !== "draft") {
return Response.json(
{ error: "Cannot roll back a draft that is not in progress" },
{ status: 400 }
);
}
const draftSlots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, seasonId),
orderBy: schema.draftSlots.draftOrder,
});
const totalTeams = draftSlots.length;
// Calculate which team is on the clock at the rollback pick (snake draft)
const round = Math.ceil(pickNumber / totalTeams);
const isEvenRound = round % 2 === 0;
let pickInRound = ((pickNumber - 1) % totalTeams) + 1;
if (isEvenRound) {
pickInRound = totalTeams - pickInRound + 1;
}
const rollbackSlot = draftSlots.find((slot) => slot.draftOrder === pickInRound);
// Delete picks from pickNumber onwards
await db
.delete(schema.draftPicks)
.where(
and(
eq(schema.draftPicks.seasonId, seasonId),
gte(schema.draftPicks.pickNumber, pickNumber)
)
);
// Clear all timers and create a fresh one for the team now on the clock
await db
.delete(schema.draftTimers)
.where(eq(schema.draftTimers.seasonId, seasonId));
const initialTime = season.draftInitialTime || 120;
if (rollbackSlot) {
await db.insert(schema.draftTimers).values({
seasonId,
teamId: rollbackSlot.teamId,
timeRemaining: initialTime,
});
}
// Update season: reset pick number and unpause
await db
.update(schema.seasons)
.set({
currentPickNumber: pickNumber,
draftPaused: false,
})
.where(eq(schema.seasons.id, seasonId));
// Emit socket events
try {
const io = getSocketIO();
io.to(`draft-${seasonId}`).emit("draft-rolled-back", {
seasonId,
pickNumber,
teamId: rollbackSlot?.teamId,
});
if (rollbackSlot) {
io.to(`draft-${seasonId}`).emit("timer-update", {
seasonId,
teamId: rollbackSlot.teamId,
timeRemaining: initialTime,
currentPickNumber: pickNumber,
});
}
} catch (error) {
console.error("Socket.IO error:", error);
}
return Response.json({ success: true, pickNumber });
}

View file

@ -282,6 +282,19 @@ export default function DraftRoom() {
const [dialogSearchQuery, setDialogSearchQuery] = useState("");
const [dialogSportFilter, setDialogSportFilter] = useState<string>("all");
// Replace pick dialog state
const [replacePickDialogOpen, setReplacePickDialogOpen] = useState(false);
const [replacePickSlot, setReplacePickSlot] = useState<{
pickNumber: number;
teamId: string;
oldParticipantId: string;
} | null>(null);
// Rollback confirm dialog state
const [rollbackConfirmOpen, setRollbackConfirmOpen] = useState(false);
const [rollbackPickNumber, setRollbackPickNumber] = useState<number | null>(null);
const [isRollingBack, setIsRollingBack] = useState(false);
// Shared transforms for eligibility calculations
const transformedPicks = useMemo(
() =>
@ -350,6 +363,26 @@ export default function DraftRoom() {
);
}, [selectedPickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]);
// Calculate eligibility for replace pick dialog — exclude the old pick so its slot is free
const replacePickEligibility = useMemo(() => {
if (!replacePickSlot) return null;
const allPicksExcluding = transformedPicks.filter(
(p: any) => p.participant.id !== replacePickSlot.oldParticipantId
);
const selectedTeamPicks = allPicksExcluding.filter(
(p: any) => p.teamId === replacePickSlot.teamId
);
return calculateDraftEligibility(
replacePickSlot.teamId,
selectedTeamPicks,
allPicksExcluding,
transformedParticipants,
seasonSportsData,
season.draftRounds,
season.teams
);
}, [replacePickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]);
// Listen for new picks from other users
useEffect(() => {
const handlePickMade = (data: any) => {
@ -438,6 +471,21 @@ export default function DraftRoom() {
);
};
const handlePickReplaced = (data: any) => {
setPicks((prev: any) =>
prev.map((p: any) => (p.pickNumber === data.pickNumber ? data.pick : p))
);
};
const handleDraftRolledBack = (data: any) => {
setPicks((prev: any) =>
prev.filter((p: any) => p.pickNumber < data.pickNumber)
);
setCurrentPick(data.pickNumber);
setIsDraftComplete(false);
setIsPaused(false);
};
on("pick-made", handlePickMade);
on("timer-update", handleTimerUpdate);
on("draft-paused", handleDraftPaused);
@ -448,6 +496,8 @@ export default function DraftRoom() {
on("team-disconnected", handleTeamDisconnected);
on("connected-teams-list", handleConnectedTeamsList);
on("participant-removed-from-queues", handleParticipantRemovedFromQueues);
on("pick-replaced", handlePickReplaced);
on("draft-rolled-back", handleDraftRolledBack);
return () => {
off("pick-made", handlePickMade);
@ -460,6 +510,8 @@ export default function DraftRoom() {
off("team-disconnected", handleTeamDisconnected);
off("connected-teams-list", handleConnectedTeamsList);
off("participant-removed-from-queues", handleParticipantRemovedFromQueues);
off("pick-replaced", handlePickReplaced);
off("draft-rolled-back", handleDraftRolledBack);
};
}, [on, off, currentPick, userTeam, draftSlots, season.league.name]);
@ -694,6 +746,69 @@ export default function DraftRoom() {
}
};
const handleReplacePickOpen = (pickNumber: number, teamId: string) => {
const pick = picks.find((p: any) => p.pickNumber === pickNumber);
if (!pick) {
toast.error("Pick not found — try refreshing the page");
return;
}
setReplacePickSlot({ pickNumber, teamId, oldParticipantId: pick.participant.id });
setDialogSearchQuery("");
setDialogSportFilter("all");
setReplacePickDialogOpen(true);
};
const handleReplacePick = async (participantId: string) => {
if (!replacePickSlot) return;
const formData = new FormData();
formData.append("seasonId", season.id);
formData.append("teamId", replacePickSlot.teamId);
formData.append("participantId", participantId);
formData.append("pickNumber", replacePickSlot.pickNumber.toString());
const response = await fetch("/api/draft/replace-pick", {
method: "POST",
body: formData,
});
if (response.ok) {
setReplacePickDialogOpen(false);
setReplacePickSlot(null);
} else {
const error = await response.json();
toast.error(error.error || "Failed to replace pick");
}
};
const handleRollbackToPick = (pickNumber: number) => {
setRollbackPickNumber(pickNumber);
setRollbackConfirmOpen(true);
};
const handleConfirmRollback = async () => {
if (!rollbackPickNumber || isRollingBack) return;
setIsRollingBack(true);
const formData = new FormData();
formData.append("seasonId", season.id);
formData.append("pickNumber", rollbackPickNumber.toString());
const response = await fetch("/api/draft/rollback", {
method: "POST",
body: formData,
});
setIsRollingBack(false);
if (response.ok) {
setRollbackConfirmOpen(false);
setRollbackPickNumber(null);
} else {
const error = await response.json();
toast.error(error.error || "Failed to roll back draft");
}
};
// Calculate current round
const currentRound = draftSlots.length > 0 ? Math.ceil(currentPick / draftSlots.length) : 1;
@ -770,6 +885,24 @@ export default function DraftRoom() {
[availableParticipants, draftedParticipantIds, dialogSearchQuery, dialogSportFilter]
);
// Participants for the replace pick dialog — excludes drafted except the old pick (which is freed)
const replaceDialogFilteredParticipants = useMemo(() => {
if (!replacePickSlot) return [];
return availableParticipants.filter((p: any) => {
// Exclude drafted players, but allow the old participant through since they'll be freed
if (p.id !== replacePickSlot.oldParticipantId && draftedParticipantIds.has(p.id))
return false;
if (
dialogSearchQuery &&
!p.name.toLowerCase().includes(dialogSearchQuery.toLowerCase())
)
return false;
if (dialogSportFilter !== "all" && p.sport.name !== dialogSportFilter)
return false;
return true;
});
}, [availableParticipants, replacePickSlot, draftedParticipantIds, dialogSearchQuery, dialogSportFilter]);
// Filter participants based on search, sport, drafted status, and eligibility
const filteredParticipants = useMemo(
() =>
@ -997,6 +1130,8 @@ export default function DraftRoom() {
setDialogSportFilter("all");
setForcePickDialogOpen(true);
}}
onReplacePick={isCommissioner ? handleReplacePickOpen : undefined}
onRollbackToPick={isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined}
/>
</TabsContent>
@ -1124,6 +1259,149 @@ export default function DraftRoom() {
</DialogContent>
</Dialog>
{/* Replace Pick Dialog */}
<Dialog
open={replacePickDialogOpen && !!replacePickSlot}
onOpenChange={(open) => {
setReplacePickDialogOpen(open);
if (!open) setReplacePickSlot(null);
}}
>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col gap-0 p-0">
<DialogHeader className="p-6 pb-4">
<DialogTitle>Replace Pick</DialogTitle>
<DialogDescription>
Select a participant to replace the current pick at slot #{replacePickSlot?.pickNumber}
</DialogDescription>
</DialogHeader>
<div className="px-6 pb-4">
<div className="flex gap-2 mb-4">
<input
type="text"
placeholder="Search participants..."
className="flex-1 px-3 py-2 border rounded-md bg-background"
value={dialogSearchQuery}
onChange={(e) => setDialogSearchQuery(e.target.value)}
/>
<select
className="px-3 py-2 border rounded-md bg-background"
value={dialogSportFilter}
onChange={(e) => setDialogSportFilter(e.target.value)}
>
<option value="all">All Sports</option>
{uniqueSports.map((sport) => (
<option key={sport} value={sport}>
{sport}
</option>
))}
</select>
</div>
<div className="max-h-96 overflow-y-auto border rounded-lg">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="text-left p-3 font-semibold">Participant</th>
<th className="text-left p-3 font-semibold">Sport</th>
<th className="text-right p-3 font-semibold">Action</th>
</tr>
</thead>
<tbody>
{replaceDialogFilteredParticipants.map((participant: any) => {
const isCurrentPick = participant.id === replacePickSlot?.oldParticipantId;
const isEligible = replacePickEligibility
? replacePickEligibility.eligibleSportIds.has(participant.sport.id)
: true;
const ineligibleReason = replacePickEligibility?.ineligibleReasons[participant.sport.id];
return (
<tr
key={participant.id}
className={`border-t ${
isEligible ? "hover:bg-muted/50" : "bg-destructive/10 opacity-60"
}`}
title={!isEligible ? ineligibleReason : undefined}
>
<td className="p-3">
<div className="flex items-center gap-2">
<span className={`font-medium ${!isEligible ? "text-muted-foreground" : ""}`}>
{participant.name}
</span>
{isCurrentPick && (
<Badge variant="outline" className="text-xs">
Current
</Badge>
)}
{!isEligible && (
<Badge
variant="destructive"
className="text-xs"
title={ineligibleReason}
>
Ineligible
</Badge>
)}
</div>
</td>
<td className="p-3 text-muted-foreground">
{participant.sport.name}
</td>
<td className="p-3 text-right">
<Button
size="sm"
onClick={() => handleReplacePick(participant.id)}
disabled={!isEligible}
title={!isEligible ? ineligibleReason : undefined}
>
{isCurrentPick ? "Keep" : "Draft"}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<DialogFooter className="px-6 pb-6">
<Button variant="outline" onClick={() => setReplacePickDialogOpen(false)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rollback Confirmation Dialog */}
<Dialog
open={rollbackConfirmOpen}
onOpenChange={(open) => {
setRollbackConfirmOpen(open);
if (!open) setRollbackPickNumber(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Roll Back Draft</DialogTitle>
<DialogDescription>
{rollbackPickNumber !== null && (() => {
const count = picks.filter((p: any) => p.pickNumber >= rollbackPickNumber).length;
return `This will erase ${count} pick${count === 1 ? "" : "s"} (pick #${rollbackPickNumber} through the most recent) and return the draft to pick #${rollbackPickNumber}. This cannot be undone.`;
})()}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRollbackConfirmOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirmRollback} disabled={isRollingBack}>
{isRollingBack ? "Rolling Back..." : "Roll Back"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Connection Overlay - blocks interaction until socket connects */}
<ConnectionOverlay
isConnected={isConnected}