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:
parent
285981ab9d
commit
41f3654956
5 changed files with 691 additions and 65 deletions
|
|
@ -37,6 +37,8 @@ interface DraftGridSectionProps {
|
||||||
isCommissioner: boolean;
|
isCommissioner: boolean;
|
||||||
onForceAutopick: (pickNumber: number, teamId: string) => void;
|
onForceAutopick: (pickNumber: number, teamId: string) => void;
|
||||||
onForceManualPickOpen: (pickNumber: number, teamId: string) => void;
|
onForceManualPickOpen: (pickNumber: number, teamId: string) => void;
|
||||||
|
onReplacePick?: (pickNumber: number, teamId: string) => void;
|
||||||
|
onRollbackToPick?: (pickNumber: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DraftGridSection({
|
export function DraftGridSection({
|
||||||
|
|
@ -49,6 +51,8 @@ export function DraftGridSection({
|
||||||
isCommissioner,
|
isCommissioner,
|
||||||
onForceAutopick,
|
onForceAutopick,
|
||||||
onForceManualPickOpen,
|
onForceManualPickOpen,
|
||||||
|
onReplacePick,
|
||||||
|
onRollbackToPick,
|
||||||
}: DraftGridSectionProps) {
|
}: DraftGridSectionProps) {
|
||||||
const formatTime = (seconds: number | null) => {
|
const formatTime = (seconds: number | null) => {
|
||||||
if (seconds === null) return "--:--";
|
if (seconds === null) return "--:--";
|
||||||
|
|
@ -120,71 +124,16 @@ export function DraftGridSection({
|
||||||
const isCurrent = cell.pickNumber === currentPick;
|
const isCurrent = cell.pickNumber === currentPick;
|
||||||
const isPicked = !!cell.pick;
|
const isPicked = !!cell.pick;
|
||||||
|
|
||||||
return isCommissioner && !isPicked && isCurrent ? (
|
const cellClassName = `flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${
|
||||||
<ContextMenu key={cell.pickNumber}>
|
isCurrent
|
||||||
<ContextMenuTrigger asChild>
|
? "border-electric bg-electric/15 shadow-lg shadow-electric/10"
|
||||||
<div
|
: isPicked
|
||||||
className={`flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${
|
? "bg-emerald-500/10 border-emerald-500/30"
|
||||||
isCurrent
|
: "border-border bg-card"
|
||||||
? "border-electric bg-electric/15 shadow-lg shadow-electric/10"
|
}`;
|
||||||
: isPicked
|
|
||||||
? "bg-emerald-500/10 border-emerald-500/30"
|
const cellInner = (
|
||||||
: "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}`}
|
|
||||||
>
|
|
||||||
<div className="text-xs font-mono text-muted-foreground mb-1">
|
<div className="text-xs font-mono text-muted-foreground mb-1">
|
||||||
{cell.round}.
|
{cell.round}.
|
||||||
{String(cell.pickInRound).padStart(2, "0")}
|
{String(cell.pickInRound).padStart(2, "0")}
|
||||||
|
|
@ -203,6 +152,86 @@ export function DraftGridSection({
|
||||||
On Clock
|
On Clock
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ export default [
|
||||||
route("api/draft/resume", "routes/api/draft.resume.ts"),
|
route("api/draft/resume", "routes/api/draft.resume.ts"),
|
||||||
route("api/draft/force-autopick", "routes/api/draft.force-autopick.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/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("api/autodraft/update", "routes/api/autodraft.update.ts"),
|
||||||
route("user-profile", "routes/user-profile.tsx"),
|
route("user-profile", "routes/user-profile.tsx"),
|
||||||
route("how-to-play", "routes/how-to-play.tsx"),
|
route("how-to-play", "routes/how-to-play.tsx"),
|
||||||
|
|
|
||||||
192
app/routes/api/draft.replace-pick.ts
Normal file
192
app/routes/api/draft.replace-pick.ts
Normal 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 });
|
||||||
|
}
|
||||||
125
app/routes/api/draft.rollback.ts
Normal file
125
app/routes/api/draft.rollback.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -282,6 +282,19 @@ export default function DraftRoom() {
|
||||||
const [dialogSearchQuery, setDialogSearchQuery] = useState("");
|
const [dialogSearchQuery, setDialogSearchQuery] = useState("");
|
||||||
const [dialogSportFilter, setDialogSportFilter] = useState<string>("all");
|
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
|
// Shared transforms for eligibility calculations
|
||||||
const transformedPicks = useMemo(
|
const transformedPicks = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -350,6 +363,26 @@ export default function DraftRoom() {
|
||||||
);
|
);
|
||||||
}, [selectedPickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]);
|
}, [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
|
// Listen for new picks from other users
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePickMade = (data: any) => {
|
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("pick-made", handlePickMade);
|
||||||
on("timer-update", handleTimerUpdate);
|
on("timer-update", handleTimerUpdate);
|
||||||
on("draft-paused", handleDraftPaused);
|
on("draft-paused", handleDraftPaused);
|
||||||
|
|
@ -448,6 +496,8 @@ export default function DraftRoom() {
|
||||||
on("team-disconnected", handleTeamDisconnected);
|
on("team-disconnected", handleTeamDisconnected);
|
||||||
on("connected-teams-list", handleConnectedTeamsList);
|
on("connected-teams-list", handleConnectedTeamsList);
|
||||||
on("participant-removed-from-queues", handleParticipantRemovedFromQueues);
|
on("participant-removed-from-queues", handleParticipantRemovedFromQueues);
|
||||||
|
on("pick-replaced", handlePickReplaced);
|
||||||
|
on("draft-rolled-back", handleDraftRolledBack);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
off("pick-made", handlePickMade);
|
off("pick-made", handlePickMade);
|
||||||
|
|
@ -460,6 +510,8 @@ export default function DraftRoom() {
|
||||||
off("team-disconnected", handleTeamDisconnected);
|
off("team-disconnected", handleTeamDisconnected);
|
||||||
off("connected-teams-list", handleConnectedTeamsList);
|
off("connected-teams-list", handleConnectedTeamsList);
|
||||||
off("participant-removed-from-queues", handleParticipantRemovedFromQueues);
|
off("participant-removed-from-queues", handleParticipantRemovedFromQueues);
|
||||||
|
off("pick-replaced", handlePickReplaced);
|
||||||
|
off("draft-rolled-back", handleDraftRolledBack);
|
||||||
};
|
};
|
||||||
}, [on, off, currentPick, userTeam, draftSlots, season.league.name]);
|
}, [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
|
// Calculate current round
|
||||||
const currentRound = draftSlots.length > 0 ? Math.ceil(currentPick / draftSlots.length) : 1;
|
const currentRound = draftSlots.length > 0 ? Math.ceil(currentPick / draftSlots.length) : 1;
|
||||||
|
|
||||||
|
|
@ -770,6 +885,24 @@ export default function DraftRoom() {
|
||||||
[availableParticipants, draftedParticipantIds, dialogSearchQuery, dialogSportFilter]
|
[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
|
// Filter participants based on search, sport, drafted status, and eligibility
|
||||||
const filteredParticipants = useMemo(
|
const filteredParticipants = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -997,6 +1130,8 @@ export default function DraftRoom() {
|
||||||
setDialogSportFilter("all");
|
setDialogSportFilter("all");
|
||||||
setForcePickDialogOpen(true);
|
setForcePickDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
|
onReplacePick={isCommissioner ? handleReplacePickOpen : undefined}
|
||||||
|
onRollbackToPick={isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
@ -1124,6 +1259,149 @@ export default function DraftRoom() {
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 */}
|
{/* Connection Overlay - blocks interaction until socket connects */}
|
||||||
<ConnectionOverlay
|
<ConnectionOverlay
|
||||||
isConnected={isConnected}
|
isConnected={isConnected}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue