* Add commissioner time bank adjustment via right-click on draft board team headers Commissioners can right-click any team header on the Draft Board tab to open an "Adjust Time Bank..." dialog. The dialog supports adding or removing an arbitrary amount of time in seconds, minutes, or hours. The change is applied immediately to the database and broadcast to all clients via the existing timer-update Socket.IO event so every participant sees the updated clock in real time. - New API route: POST /api/draft/adjust-time-bank (commissioner-only) - DraftGridSection: wraps team headers in a ContextMenu for commissioners - Draft room: dialog state, handler, and updated DraftGridSection props https://claude.ai/code/session_013wxPKzLUCx3nC3LpxgjvQL * Fix code review issues in commissioner time bank adjustment - Wrap fetch in try/finally so isAdjustingTimeBank is always reset, even on network errors that cause fetch to throw - Guard against totalSeconds rounding to 0 for tiny fractional inputs - Reject API requests when the draft is not in 'draft' status (409) - Set input min to 0.001 so the browser rejects zero in native validation https://claude.ai/code/session_013wxPKzLUCx3nC3LpxgjvQL --------- Co-authored-by: Claude <noreply@anthropic.com>
1602 lines
57 KiB
TypeScript
1602 lines
57 KiB
TypeScript
import { useLoaderData, Link, useRevalidator } from "react-router";
|
|
import { eq, and, asc, desc, inArray } from "drizzle-orm";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
|
import { useEffect, useState, useMemo, useRef } from "react";
|
|
import { Card } from "~/components/ui/card";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
|
|
import { getAuth } from "@clerk/react-router/server";
|
|
import { getTeamQueue } from "~/models/draft-queue";
|
|
import { DraftSidebar } from "~/components/DraftSidebar";
|
|
import { TeamsDraftedGrid } from "~/components/draft/TeamsDraftedGrid";
|
|
import { QueueSection } from "~/components/draft/QueueSection";
|
|
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
|
|
import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks";
|
|
import { DraftGridSection } from "~/components/draft/DraftGridSection";
|
|
import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay";
|
|
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
|
import { getTeamForPick } from "~/lib/draft-order";
|
|
import { useDraftNotifications } from "~/hooks/useDraftNotifications";
|
|
import { NotificationSettings } from "~/components/NotificationSettings";
|
|
import { toast } from "sonner";
|
|
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
|
|
|
|
export async function loader(args: any) {
|
|
const { params } = args;
|
|
const { seasonId, leagueId } = params;
|
|
const auth = await getAuth(args);
|
|
const userId = (auth as any).userId as string | null;
|
|
|
|
if (!seasonId) {
|
|
throw new Response("Season ID is required", { status: 400 });
|
|
}
|
|
|
|
if (!userId) {
|
|
throw new Response("You must be logged in to view the draft room", {
|
|
status: 401,
|
|
});
|
|
}
|
|
|
|
const db = database();
|
|
|
|
// Get season details
|
|
const season = await db.query.seasons.findFirst({
|
|
where: eq(schema.seasons.id, seasonId),
|
|
with: {
|
|
league: true,
|
|
teams: true,
|
|
},
|
|
});
|
|
|
|
if (!season) {
|
|
throw new Response("Season not found", { status: 404 });
|
|
}
|
|
|
|
// Verify user access: must be team owner or commissioner
|
|
const userTeam = season.teams.find((team: any) => team.ownerId === userId);
|
|
const isCommissioner = await db.query.commissioners.findFirst({
|
|
where: and(
|
|
eq(schema.commissioners.leagueId, leagueId),
|
|
eq(schema.commissioners.userId, userId)
|
|
),
|
|
});
|
|
|
|
if (!userTeam && !isCommissioner) {
|
|
throw new Response("You do not have access to this draft room", {
|
|
status: 403,
|
|
});
|
|
}
|
|
|
|
// Get draft slots (draft order) - using select instead of query builder
|
|
const draftSlots = await db
|
|
.select({
|
|
id: schema.draftSlots.id,
|
|
draftOrder: schema.draftSlots.draftOrder,
|
|
team: schema.teams,
|
|
})
|
|
.from(schema.draftSlots)
|
|
.innerJoin(schema.teams, eq(schema.draftSlots.teamId, schema.teams.id))
|
|
.where(eq(schema.draftSlots.seasonId, seasonId))
|
|
.orderBy(asc(schema.draftSlots.draftOrder));
|
|
|
|
// Get all draft picks - using select instead of query builder
|
|
const draftPicks = await db
|
|
.select({
|
|
id: schema.draftPicks.id,
|
|
pickNumber: schema.draftPicks.pickNumber,
|
|
round: schema.draftPicks.round,
|
|
pickInRound: schema.draftPicks.pickInRound,
|
|
timeUsed: schema.draftPicks.timeUsed,
|
|
team: schema.teams,
|
|
participant: schema.participants,
|
|
sport: schema.sports,
|
|
})
|
|
.from(schema.draftPicks)
|
|
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
|
|
.innerJoin(
|
|
schema.participants,
|
|
eq(schema.draftPicks.participantId, schema.participants.id)
|
|
)
|
|
.innerJoin(
|
|
schema.sportsSeasons,
|
|
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
|
|
)
|
|
.innerJoin(
|
|
schema.sports,
|
|
eq(schema.sportsSeasons.sportId, schema.sports.id)
|
|
)
|
|
.where(eq(schema.draftPicks.seasonId, seasonId))
|
|
.orderBy(asc(schema.draftPicks.pickNumber));
|
|
|
|
// Get sports seasons for this season
|
|
const seasonSports = await db.query.seasonSports.findMany({
|
|
where: eq(schema.seasonSports.seasonId, seasonId),
|
|
});
|
|
|
|
const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId);
|
|
|
|
// Get ALL participants (both drafted and undrafted) - sorted by EV desc, then name
|
|
// Filtering will be done on the client side based on the "Show Drafted" toggle
|
|
const availableParticipants = sportsSeasonIds.length > 0
|
|
? await db
|
|
.select({
|
|
id: schema.participants.id,
|
|
name: schema.participants.name,
|
|
sport: schema.sports,
|
|
})
|
|
.from(schema.participants)
|
|
.innerJoin(
|
|
schema.sportsSeasons,
|
|
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
|
|
)
|
|
.innerJoin(
|
|
schema.sports,
|
|
eq(schema.sportsSeasons.sportId, schema.sports.id)
|
|
)
|
|
.where(
|
|
sportsSeasonIds.length === 1
|
|
? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0])
|
|
: inArray(schema.participants.sportsSeasonId, sportsSeasonIds)
|
|
)
|
|
.orderBy(
|
|
desc(schema.participants.expectedValue),
|
|
asc(schema.participants.name)
|
|
)
|
|
: [];
|
|
|
|
// Load user's team queue if they have a team
|
|
const userQueue = userTeam ? await getTeamQueue(userTeam.id) : [];
|
|
|
|
// Load draft timers for all teams
|
|
const timers = await db.query.draftTimers.findMany({
|
|
where: eq(schema.draftTimers.seasonId, seasonId),
|
|
});
|
|
|
|
// Load autodraft settings for all teams
|
|
const autodraftSettings = await db.query.autodraftSettings.findMany({
|
|
where: eq(schema.autodraftSettings.seasonId, seasonId),
|
|
});
|
|
|
|
// Derive user's autodraft settings from the already-loaded list
|
|
const userAutodraftSettings = userTeam
|
|
? (autodraftSettings.find((s) => s.teamId === userTeam.id) ?? null)
|
|
: null;
|
|
|
|
return {
|
|
season,
|
|
draftSlots,
|
|
draftPicks,
|
|
availableParticipants,
|
|
userTeam,
|
|
userQueue,
|
|
timers,
|
|
autodraftSettings,
|
|
userAutodraftSettings,
|
|
isCommissioner: !!isCommissioner,
|
|
currentUserId: userId,
|
|
numFlexPicks: Math.max(0, season.draftRounds - seasonSports.length),
|
|
};
|
|
}
|
|
|
|
export default function DraftRoom() {
|
|
const {
|
|
season,
|
|
draftSlots,
|
|
draftPicks,
|
|
availableParticipants,
|
|
userTeam,
|
|
userQueue,
|
|
timers,
|
|
autodraftSettings,
|
|
userAutodraftSettings,
|
|
isCommissioner,
|
|
currentUserId,
|
|
numFlexPicks,
|
|
} = useLoaderData<typeof loader>();
|
|
const { revalidate } = useRevalidator();
|
|
const { isConnected, connectionError, isReconnecting, on, off } = useDraftSocket(season.id, userTeam?.id);
|
|
const {
|
|
permissionState: notificationsPermission,
|
|
enabled: notificationsEnabled,
|
|
setEnabled: setNotificationsEnabled,
|
|
mode: notificationsMode,
|
|
setMode: setNotificationsMode,
|
|
sendNotification,
|
|
} = useDraftNotifications(season.id, currentUserId);
|
|
|
|
// Use refs so the socket effect doesn't re-register all handlers when the user
|
|
// changes notification settings (which would change the sendNotification reference).
|
|
const sendNotificationRef = useRef(sendNotification);
|
|
useEffect(() => {
|
|
sendNotificationRef.current = sendNotification;
|
|
}, [sendNotification]);
|
|
const notificationsModeRef = useRef(notificationsMode);
|
|
useEffect(() => {
|
|
notificationsModeRef.current = notificationsMode;
|
|
}, [notificationsMode]);
|
|
const [picks, setPicks] = useState(draftPicks);
|
|
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [hideDrafted, setHideDrafted] = useState(true);
|
|
const [hideIneligible, setHideIneligible] = useState(true);
|
|
const [sportFilter, setSportFilter] = useState<string>("all");
|
|
const [queue, setQueue] = useState(userQueue);
|
|
const [isPaused, setIsPaused] = useState(season.draftPaused || false);
|
|
const [isDraftComplete, setIsDraftComplete] = useState(
|
|
season.status === "active" || season.status === "completed"
|
|
);
|
|
|
|
// Sidebar and tabs state
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
|
if (typeof window === "undefined") return false;
|
|
const stored = localStorage.getItem("draftSidebarCollapsed");
|
|
return stored ? JSON.parse(stored) : false;
|
|
});
|
|
const [activeTab, setActiveTab] = useState<"participants" | "board" | "teams">("participants");
|
|
|
|
// Track autodraft status for all teams
|
|
const [autodraftStatus, setAutodraftStatus] = useState<Record<string, boolean>>(() => {
|
|
const status: Record<string, boolean> = {};
|
|
autodraftSettings.forEach((setting: any) => {
|
|
status[setting.teamId] = setting.isEnabled;
|
|
});
|
|
return status;
|
|
});
|
|
|
|
// Track connected teams
|
|
const [connectedTeams, setConnectedTeams] = useState<Set<string>>(new Set());
|
|
|
|
// Track user's autodraft settings
|
|
const [userAutodraft, setUserAutodraft] = useState({
|
|
isEnabled: userAutodraftSettings?.isEnabled || false,
|
|
mode: (userAutodraftSettings?.mode || "next_pick") as "next_pick" | "while_on",
|
|
});
|
|
|
|
const [teamTimers, setTeamTimers] = useState<Record<string, number>>(() => {
|
|
// Initialize with timers from loader data, or use initial time if draft hasn't started
|
|
const timersMap: Record<string, number> = {};
|
|
const initialTime = season.draftInitialTime || 120;
|
|
|
|
if (timers.length > 0) {
|
|
// Draft has started, use actual timers
|
|
timers.forEach((timer: any) => {
|
|
timersMap[timer.teamId] = timer.timeRemaining;
|
|
});
|
|
} else {
|
|
// Draft hasn't started, show initial time for all teams
|
|
draftSlots.forEach((slot) => {
|
|
timersMap[slot.team.id] = initialTime;
|
|
});
|
|
}
|
|
|
|
return timersMap;
|
|
});
|
|
const [forcePickDialogOpen, setForcePickDialogOpen] = useState(false);
|
|
const [selectedPickSlot, setSelectedPickSlot] = useState<{
|
|
pickNumber: number;
|
|
teamId: string;
|
|
} | null>(null);
|
|
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);
|
|
|
|
// Time bank adjustment dialog state
|
|
const [timeBankDialogOpen, setTimeBankDialogOpen] = useState(false);
|
|
const [timeBankTeamId, setTimeBankTeamId] = useState<string | null>(null);
|
|
const [timeBankAmount, setTimeBankAmount] = useState("1");
|
|
const [timeBankUnit, setTimeBankUnit] = useState<"seconds" | "minutes" | "hours">("minutes");
|
|
const [timeBankDirection, setTimeBankDirection] = useState<"add" | "remove">("add");
|
|
const [isAdjustingTimeBank, setIsAdjustingTimeBank] = useState(false);
|
|
|
|
// Shared transforms for eligibility calculations
|
|
const transformedPicks = useMemo(
|
|
() =>
|
|
picks.map((p: any) => ({
|
|
teamId: p.team.id,
|
|
participant: {
|
|
id: p.participant.id,
|
|
sport: { id: p.sport.id, name: p.sport.name },
|
|
},
|
|
})),
|
|
[picks]
|
|
);
|
|
|
|
const transformedParticipants = useMemo(
|
|
() =>
|
|
availableParticipants.map((p: any) => ({
|
|
id: p.id,
|
|
sport: { id: p.sport.id, name: p.sport.name },
|
|
})),
|
|
[availableParticipants]
|
|
);
|
|
|
|
const seasonSportsData = useMemo(() => {
|
|
const sportsMap = new Map<string, { id: string; name: string }>();
|
|
availableParticipants.forEach((p) => {
|
|
if (!sportsMap.has(p.sport.id)) {
|
|
sportsMap.set(p.sport.id, { id: p.sport.id, name: p.sport.name });
|
|
}
|
|
});
|
|
return Array.from(sportsMap.values()).sort((a, b) =>
|
|
a.name.localeCompare(b.name)
|
|
);
|
|
}, [availableParticipants]);
|
|
|
|
// Calculate draft eligibility for current user's team
|
|
const eligibility = useMemo(() => {
|
|
if (!userTeam) return null;
|
|
const userTeamPicks = transformedPicks.filter(
|
|
(p: any) => p.teamId === userTeam.id
|
|
);
|
|
return calculateDraftEligibility(
|
|
userTeam.id,
|
|
userTeamPicks,
|
|
transformedPicks,
|
|
transformedParticipants,
|
|
seasonSportsData,
|
|
season.draftRounds,
|
|
season.teams
|
|
);
|
|
}, [userTeam, transformedPicks, transformedParticipants, seasonSportsData, season]);
|
|
|
|
// Calculate eligibility for force manual pick dialog (selected team)
|
|
const forcePickEligibility = useMemo(() => {
|
|
if (!selectedPickSlot) return null;
|
|
const selectedTeamPicks = transformedPicks.filter(
|
|
(p: any) => p.teamId === selectedPickSlot.teamId
|
|
);
|
|
return calculateDraftEligibility(
|
|
selectedPickSlot.teamId,
|
|
selectedTeamPicks,
|
|
transformedPicks,
|
|
transformedParticipants,
|
|
seasonSportsData,
|
|
season.draftRounds,
|
|
season.teams
|
|
);
|
|
}, [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) => {
|
|
setPicks((prev: any) => [...prev, data.pick]);
|
|
setCurrentPick(data.nextPickNumber);
|
|
|
|
// No meaningful "next turn" notification once the draft is over
|
|
if (data.isDraftComplete) return;
|
|
|
|
const notifTitle = `${season.league.name} Draft`;
|
|
const nextSlot = getTeamForPick(data.nextPickNumber, draftSlots);
|
|
const isNowMyTurn = !!(
|
|
userTeam && nextSlot && nextSlot.team.id === userTeam.id
|
|
);
|
|
|
|
if (isNowMyTurn) {
|
|
sendNotificationRef.current(notifTitle, `It's your turn to pick!`);
|
|
} else if (
|
|
notificationsModeRef.current === "all_picks" &&
|
|
data.pick?.team?.id !== userTeam?.id
|
|
) {
|
|
// Only notify about other teams' picks, not the user's own
|
|
const pickerName = data.pick?.team?.name || "A team";
|
|
const participantName = data.pick?.participant?.name || "a participant";
|
|
sendNotificationRef.current(
|
|
notifTitle,
|
|
`${pickerName} picked ${participantName}`
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleTimerUpdate = (data: any) => {
|
|
// Update the specific team's timer
|
|
setTeamTimers((prev) => ({
|
|
...prev,
|
|
[data.teamId]: data.timeRemaining,
|
|
}));
|
|
};
|
|
|
|
const handleDraftPaused = () => {
|
|
setIsPaused(true);
|
|
};
|
|
|
|
const handleDraftResumed = () => {
|
|
setIsPaused(false);
|
|
};
|
|
|
|
const handleDraftCompleted = () => {
|
|
setIsDraftComplete(true);
|
|
};
|
|
|
|
const handleAutodraftUpdated = (data: { teamId: string; isEnabled: boolean; mode: "next_pick" | "while_on" }) => {
|
|
setAutodraftStatus((prev) => ({
|
|
...prev,
|
|
[data.teamId]: data.isEnabled,
|
|
}));
|
|
|
|
// Update user's local state if it's their team
|
|
if (userTeam && data.teamId === userTeam.id) {
|
|
setUserAutodraft({
|
|
isEnabled: data.isEnabled,
|
|
mode: data.mode,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleTeamConnected = (data: { teamId: string }) => {
|
|
setConnectedTeams((prev) => new Set(prev).add(data.teamId));
|
|
};
|
|
|
|
const handleTeamDisconnected = (data: { teamId: string }) => {
|
|
setConnectedTeams((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(data.teamId);
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
const handleConnectedTeamsList = (data: { teamIds: string[] }) => {
|
|
setConnectedTeams(new Set(data.teamIds));
|
|
};
|
|
|
|
const handleParticipantRemovedFromQueues = (data: { participantId: string }) => {
|
|
setQueue((prev: any) =>
|
|
prev.filter((item: any) => item.participantId !== data.participantId)
|
|
);
|
|
};
|
|
|
|
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);
|
|
on("draft-resumed", handleDraftResumed);
|
|
on("draft-completed", handleDraftCompleted);
|
|
on("autodraft-updated", handleAutodraftUpdated);
|
|
on("team-connected", handleTeamConnected);
|
|
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);
|
|
off("timer-update", handleTimerUpdate);
|
|
off("draft-paused", handleDraftPaused);
|
|
off("draft-resumed", handleDraftResumed);
|
|
off("draft-completed", handleDraftCompleted);
|
|
off("autodraft-updated", handleAutodraftUpdated);
|
|
off("team-connected", handleTeamConnected);
|
|
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]);
|
|
|
|
// Persist sidebar collapsed state
|
|
useEffect(() => {
|
|
if (typeof window !== "undefined") {
|
|
localStorage.setItem("draftSidebarCollapsed", JSON.stringify(sidebarCollapsed));
|
|
}
|
|
}, [sidebarCollapsed]);
|
|
|
|
// Queue handlers
|
|
const handleAddToQueue = async (participantId: string) => {
|
|
if (!userTeam) return;
|
|
|
|
// Check if participant is already in queue (client-side)
|
|
const alreadyInQueue = queue.some((item: any) => item.participantId === participantId);
|
|
if (alreadyInQueue) {
|
|
toast.error("This participant is already in your queue");
|
|
return;
|
|
}
|
|
|
|
// Optimistic update - create a temporary queue item
|
|
const tempQueueItem = {
|
|
id: `temp-${Date.now()}`, // Temporary ID
|
|
participantId,
|
|
queuePosition: queue.length + 1,
|
|
};
|
|
|
|
// Immediately update the UI
|
|
setQueue((prev: any) => [...prev, tempQueueItem]);
|
|
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("teamId", userTeam.id);
|
|
formData.append("participantId", participantId);
|
|
|
|
try {
|
|
const response = await fetch("/api/queue/add", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
// Replace temp item with real item from server
|
|
setQueue((prev: any) =>
|
|
prev.map((item: any) =>
|
|
item.id === tempQueueItem.id ? data.queueItem : item
|
|
)
|
|
);
|
|
} else {
|
|
// Revert the optimistic update on error
|
|
setQueue((prev: any) =>
|
|
prev.filter((item: any) => item.id !== tempQueueItem.id)
|
|
);
|
|
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to add to queue");
|
|
}
|
|
} catch (error) {
|
|
// Revert the optimistic update on network error
|
|
setQueue((prev: any) =>
|
|
prev.filter((item: any) => item.id !== tempQueueItem.id)
|
|
);
|
|
toast.error("Network error - failed to add to queue");
|
|
}
|
|
};
|
|
|
|
const handleRemoveFromQueue = async (queueId: string) => {
|
|
if (!userTeam) return;
|
|
|
|
const formData = new FormData();
|
|
formData.append("queueId", queueId);
|
|
formData.append("teamId", userTeam.id);
|
|
|
|
try {
|
|
const response = await fetch("/api/queue/remove", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setQueue(data.queue);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to remove from queue");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to remove from queue");
|
|
}
|
|
};
|
|
|
|
const handleReorderQueue = async (participantIds: string[]) => {
|
|
if (!userTeam) return;
|
|
|
|
const formData = new FormData();
|
|
formData.append("teamId", userTeam.id);
|
|
formData.append("seasonId", season.id);
|
|
formData.append("participantIds", JSON.stringify(participantIds));
|
|
|
|
try {
|
|
const response = await fetch("/api/queue/reorder", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setQueue(data.queue);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to reorder queue");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to reorder queue");
|
|
}
|
|
};
|
|
|
|
const handleStartDraft = async () => {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
|
|
const response = await fetch("/api/draft/start", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
revalidate();
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to start draft");
|
|
}
|
|
};
|
|
|
|
const handlePauseDraft = async () => {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
|
|
try {
|
|
const response = await fetch("/api/draft/pause", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
setIsPaused(true);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to pause draft");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to pause draft");
|
|
}
|
|
};
|
|
|
|
const handleResumeDraft = async () => {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
|
|
try {
|
|
const response = await fetch("/api/draft/resume", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
setIsPaused(false);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to resume draft");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to resume draft");
|
|
}
|
|
};
|
|
|
|
const handleMakePick = async (participantId: string) => {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("participantId", participantId);
|
|
|
|
const response = await fetch("/api/draft/make-pick", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to make pick");
|
|
}
|
|
};
|
|
|
|
const handleForceAutopick = async (pickNumber: number, teamId: string) => {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("teamId", teamId);
|
|
formData.append("pickNumber", pickNumber.toString());
|
|
|
|
const response = await fetch("/api/draft/force-autopick", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to force autopick");
|
|
}
|
|
};
|
|
|
|
const handleForceManualPick = async (participantId: string) => {
|
|
if (!selectedPickSlot) return;
|
|
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("teamId", selectedPickSlot.teamId);
|
|
formData.append("participantId", participantId);
|
|
formData.append("pickNumber", selectedPickSlot.pickNumber.toString());
|
|
|
|
const response = await fetch("/api/draft/force-manual-pick", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
setForcePickDialogOpen(false);
|
|
setSelectedPickSlot(null);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to force manual pick");
|
|
}
|
|
};
|
|
|
|
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");
|
|
}
|
|
};
|
|
|
|
const handleAdjustTimeBankOpen = (teamId: string) => {
|
|
setTimeBankTeamId(teamId);
|
|
setTimeBankAmount("1");
|
|
setTimeBankUnit("minutes");
|
|
setTimeBankDirection("add");
|
|
setTimeBankDialogOpen(true);
|
|
};
|
|
|
|
const handleConfirmAdjustTimeBank = async () => {
|
|
if (!timeBankTeamId || isAdjustingTimeBank) return;
|
|
|
|
const amount = parseFloat(timeBankAmount);
|
|
if (isNaN(amount) || amount <= 0) {
|
|
toast.error("Please enter a valid positive amount");
|
|
return;
|
|
}
|
|
|
|
const unitMultipliers = { seconds: 1, minutes: 60, hours: 3600 };
|
|
const totalSeconds = Math.round(amount * unitMultipliers[timeBankUnit]);
|
|
|
|
if (totalSeconds === 0) {
|
|
toast.error("Adjustment rounds to 0 seconds — please enter a larger value");
|
|
return;
|
|
}
|
|
|
|
const adjustment = timeBankDirection === "add" ? totalSeconds : -totalSeconds;
|
|
|
|
setIsAdjustingTimeBank(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("teamId", timeBankTeamId);
|
|
formData.append("adjustment", adjustment.toString());
|
|
|
|
const response = await fetch("/api/draft/adjust-time-bank", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setTeamTimers((prev) => ({ ...prev, [timeBankTeamId]: data.timeRemaining }));
|
|
const teamName = draftSlots.find((s) => s.team.id === timeBankTeamId)?.team.name;
|
|
toast.success(`Time bank adjusted for ${teamName}`);
|
|
setTimeBankDialogOpen(false);
|
|
setTimeBankTeamId(null);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to adjust time bank");
|
|
}
|
|
} catch {
|
|
toast.error("Network error — failed to adjust time bank");
|
|
} finally {
|
|
setIsAdjustingTimeBank(false);
|
|
}
|
|
};
|
|
|
|
// Calculate current round
|
|
const currentRound = draftSlots.length > 0 ? Math.ceil(currentPick / draftSlots.length) : 1;
|
|
|
|
// Determine whose turn it is
|
|
const currentDraftSlot = getTeamForPick(currentPick, draftSlots);
|
|
const isMyTurn = !!(
|
|
userTeam && currentDraftSlot && currentDraftSlot.team.id === userTeam.id
|
|
);
|
|
const canPick = isMyTurn && season.status === "draft" && !isPaused; // Only team owner on their turn when draft is active
|
|
|
|
const currentClockTime = currentDraftSlot ? teamTimers[currentDraftSlot.team.id] : undefined;
|
|
const currentClockColor = getTimerColorClass(currentClockTime);
|
|
|
|
// Generate snake draft grid structure
|
|
const draftGrid = useMemo(() => {
|
|
const teamCount = draftSlots.length;
|
|
const rounds = season.draftRounds;
|
|
const grid: Array<
|
|
Array<{
|
|
pickNumber: number;
|
|
round: number;
|
|
pickInRound: number;
|
|
teamId: string;
|
|
pick?: any;
|
|
}>
|
|
> = [];
|
|
|
|
for (let round = 1; round <= rounds; round++) {
|
|
const roundPicks = [];
|
|
const isOddRound = round % 2 === 1;
|
|
|
|
for (let i = 0; i < teamCount; i++) {
|
|
const pickInRound = i + 1;
|
|
const teamIndex = isOddRound ? i : teamCount - 1 - i;
|
|
const pickNumber = (round - 1) * teamCount + pickInRound;
|
|
const teamId = draftSlots[teamIndex]?.team.id;
|
|
|
|
const pick = picks.find((p: any) => p.pickNumber === pickNumber);
|
|
|
|
roundPicks.push({ pickNumber, round, pickInRound, teamId, pick });
|
|
}
|
|
grid.push(roundPicks);
|
|
}
|
|
|
|
return grid;
|
|
}, [picks, draftSlots, season.draftRounds]);
|
|
|
|
// Get drafted participant IDs for filtering
|
|
const draftedParticipantIds = useMemo(
|
|
() => new Set(picks.map((p: any) => p.participant.id)),
|
|
[picks]
|
|
);
|
|
|
|
// Get unique sports for filter dropdown
|
|
const uniqueSports = useMemo(
|
|
() =>
|
|
Array.from(
|
|
new Set(availableParticipants.map((p: any) => p.sport.name))
|
|
).sort(),
|
|
[availableParticipants]
|
|
);
|
|
|
|
// Participants filtered for the force pick dialog (always excludes drafted, uses dialog-local filters)
|
|
const dialogFilteredParticipants = useMemo(
|
|
() =>
|
|
availableParticipants.filter((p: any) => {
|
|
if (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, 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(
|
|
() =>
|
|
availableParticipants.filter((participant: any) => {
|
|
// Drafted filter - hide drafted participants by default
|
|
if (hideDrafted && draftedParticipantIds.has(participant.id)) {
|
|
return false;
|
|
}
|
|
// Eligibility filter - hide ineligible participants by default (if user has a team)
|
|
if (hideIneligible && eligibility) {
|
|
const isEligible = eligibility.eligibleSportIds.has(participant.sport.id);
|
|
if (!isEligible) {
|
|
return false;
|
|
}
|
|
}
|
|
// Search filter
|
|
if (
|
|
searchQuery &&
|
|
!participant.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
) {
|
|
return false;
|
|
}
|
|
// Sport filter
|
|
if (sportFilter !== "all" && participant.sport.name !== sportFilter) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}),
|
|
[availableParticipants, hideDrafted, hideIneligible, eligibility, draftedParticipantIds, searchQuery, sportFilter]
|
|
);
|
|
|
|
return (
|
|
<div className="h-screen bg-background flex flex-col overflow-hidden">
|
|
{/* Draft Completion Banner */}
|
|
{isDraftComplete && (
|
|
<div className="bg-emerald-500/20 border-b border-emerald-500/30 text-emerald-400 px-4 py-3 text-center font-semibold flex-shrink-0">
|
|
🎉 Draft Complete! The season is now active.
|
|
{season.league.isPublicDraftBoard && (
|
|
<>
|
|
{" "}
|
|
<a
|
|
href={`/leagues/${season.leagueId}/draft-board/${season.id}`}
|
|
className="underline hover:text-emerald-300"
|
|
>
|
|
View Draft Board
|
|
</a>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Standalone Header - No Main Nav */}
|
|
<div className="border-b bg-card flex-shrink-0">
|
|
<div className="w-full px-4 py-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
{/* Mobile menu button - only show if user has team and sidebar is collapsed */}
|
|
{userTeam && sidebarCollapsed && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setSidebarCollapsed(false)}
|
|
className="lg:hidden"
|
|
aria-label="Open sidebar"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<line x1="3" y1="12" x2="21" y2="12"></line>
|
|
<line x1="3" y1="6" x2="21" y2="6"></line>
|
|
<line x1="3" y1="18" x2="21" y2="18"></line>
|
|
</svg>
|
|
</Button>
|
|
)}
|
|
<div>
|
|
<h1 className="text-3xl font-bold">
|
|
{season.league.name} - {season.year} Draft
|
|
</h1>
|
|
<div className="flex gap-4 text-sm text-muted-foreground mt-1">
|
|
<span>Round: {currentRound}</span>
|
|
<span>Pick: {currentPick}</span>
|
|
<span className="capitalize">
|
|
{season.status.replace("_", " ")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{/* Commissioner Controls */}
|
|
{isCommissioner && season.status === "pre_draft" && (
|
|
<Button onClick={handleStartDraft}>Start Draft</Button>
|
|
)}
|
|
|
|
{isCommissioner && season.status === "draft" && !isDraftComplete && (
|
|
<>
|
|
{isPaused ? (
|
|
<Button onClick={handleResumeDraft} variant="default">
|
|
Resume Draft
|
|
</Button>
|
|
) : (
|
|
<Button onClick={handlePauseDraft} variant="outline">
|
|
Pause Draft
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={`w-3 h-3 rounded-full ${
|
|
isConnected ? "bg-emerald-500" : "bg-coral-accent"
|
|
}`}
|
|
/>
|
|
<span className="text-sm font-medium">
|
|
{isConnected ? "Connected" : "Disconnected"}
|
|
</span>
|
|
</div>
|
|
<Button variant="outline" asChild>
|
|
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content - Flex Row: Sidebar + Tabs */}
|
|
<div className="flex-1 flex overflow-hidden">
|
|
{/* Sidebar */}
|
|
{userTeam && (
|
|
<DraftSidebar
|
|
collapsed={sidebarCollapsed}
|
|
onCollapsedChange={setSidebarCollapsed}
|
|
queueSection={
|
|
<QueueSection
|
|
queue={queue}
|
|
availableParticipants={availableParticipants}
|
|
eligibility={eligibility}
|
|
seasonId={season.id}
|
|
teamId={userTeam.id}
|
|
isMyTurn={isMyTurn}
|
|
userAutodraft={userAutodraft}
|
|
onRemoveFromQueue={handleRemoveFromQueue}
|
|
onAutodraftUpdate={(isEnabled, mode) => {
|
|
setUserAutodraft({ isEnabled, mode });
|
|
}}
|
|
onReorder={handleReorderQueue}
|
|
/>
|
|
}
|
|
recentPicksSection={
|
|
<SidebarRecentPicks picks={picks} />
|
|
}
|
|
settingsSection={
|
|
<NotificationSettings
|
|
enabled={notificationsEnabled}
|
|
onEnabledChange={setNotificationsEnabled}
|
|
mode={notificationsMode}
|
|
onModeChange={setNotificationsMode}
|
|
permissionState={notificationsPermission}
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{/* Main Tabbed Content */}
|
|
<div className="flex-1 overflow-hidden">
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={(value) =>
|
|
setActiveTab(value as "participants" | "board" | "teams")
|
|
}
|
|
className="h-full flex flex-col"
|
|
>
|
|
<div className={`mt-4 transition-all duration-300 ${
|
|
isMyTurn && season.status === "draft" && !isDraftComplete
|
|
? "bg-electric/25 border-y-2 border-electric shadow-[0_0_24px_0_rgb(0_200_255_/_0.18)]"
|
|
: ""
|
|
}`}>
|
|
<div className="flex items-center gap-3 px-4 py-2">
|
|
<TabsList>
|
|
<TabsTrigger value="participants">Available Participants</TabsTrigger>
|
|
<TabsTrigger value="board">Draft Board</TabsTrigger>
|
|
<TabsTrigger value="teams">Teams Drafted</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{season.status === "draft" && !isDraftComplete && currentDraftSlot && (
|
|
<div className={`ml-auto flex items-center gap-2.5 rounded-lg px-3 py-1.5 flex-shrink-0 transition-all ${
|
|
isMyTurn
|
|
? "bg-electric/30 border-2 border-electric shadow-md shadow-electric/30"
|
|
: "border border-border/50"
|
|
}`}>
|
|
<div className="leading-tight">
|
|
<div className={`text-xs font-bold uppercase tracking-wider ${isMyTurn ? "text-electric" : "text-muted-foreground"}`}>
|
|
{isMyTurn ? "Your Turn!" : "On the Clock"}
|
|
</div>
|
|
<div className={`text-sm font-bold ${isMyTurn ? "text-foreground" : "text-muted-foreground"}`}>
|
|
{currentDraftSlot.team.name}
|
|
</div>
|
|
</div>
|
|
{isPaused ? (
|
|
<span className="text-sm font-bold text-amber-accent">Paused</span>
|
|
) : (
|
|
<span className={`text-2xl font-mono font-bold tabular-nums ${currentClockColor}`}>
|
|
{formatClockTime(currentClockTime)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<TabsContent value="participants" className="flex-1 overflow-hidden m-0">
|
|
<div className="h-full">
|
|
<AvailableParticipantsSection
|
|
participants={filteredParticipants}
|
|
searchQuery={searchQuery}
|
|
sportFilter={sportFilter}
|
|
hideDrafted={hideDrafted}
|
|
hideIneligible={hideIneligible}
|
|
uniqueSports={uniqueSports}
|
|
draftedParticipantIds={draftedParticipantIds}
|
|
queue={queue}
|
|
eligibility={eligibility}
|
|
canPick={canPick}
|
|
hasTeam={!!userTeam}
|
|
onSearchChange={setSearchQuery}
|
|
onSportFilterChange={setSportFilter}
|
|
onHideDraftedChange={setHideDrafted}
|
|
onHideIneligibleChange={setHideIneligible}
|
|
onMakePick={handleMakePick}
|
|
onAddToQueue={handleAddToQueue}
|
|
onRemoveFromQueue={handleRemoveFromQueue}
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="board" className="flex-1 overflow-hidden m-0">
|
|
<DraftGridSection
|
|
draftSlots={draftSlots}
|
|
draftGrid={draftGrid}
|
|
currentPick={currentPick}
|
|
teamTimers={teamTimers}
|
|
autodraftStatus={autodraftStatus}
|
|
connectedTeams={connectedTeams}
|
|
isCommissioner={isCommissioner}
|
|
onAdjustTimeBankOpen={isCommissioner ? handleAdjustTimeBankOpen : undefined}
|
|
onForceAutopick={handleForceAutopick}
|
|
onForceManualPickOpen={(pickNumber, teamId) => {
|
|
setSelectedPickSlot({ pickNumber, teamId });
|
|
setDialogSearchQuery("");
|
|
setDialogSportFilter("all");
|
|
setForcePickDialogOpen(true);
|
|
}}
|
|
onReplacePick={isCommissioner ? handleReplacePickOpen : undefined}
|
|
onRollbackToPick={isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="teams" className="flex-1 overflow-hidden m-0">
|
|
<div className="h-full">
|
|
<TeamsDraftedGrid
|
|
draftSlots={draftSlots}
|
|
picks={picks}
|
|
sports={seasonSportsData}
|
|
season={{ numFlexPicks }}
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Force Manual Pick Dialog */}
|
|
<Dialog
|
|
open={forcePickDialogOpen && !!selectedPickSlot}
|
|
onOpenChange={(open) => {
|
|
setForcePickDialogOpen(open);
|
|
if (!open) setSelectedPickSlot(null);
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col gap-0 p-0">
|
|
<DialogHeader className="p-6 pb-4">
|
|
<DialogTitle>Force Manual Pick</DialogTitle>
|
|
<DialogDescription>
|
|
Select a participant to draft for Pick #{selectedPickSlot?.pickNumber}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="px-6 pb-4">
|
|
{/* Search and Filter */}
|
|
<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>
|
|
|
|
{/* Participant List */}
|
|
<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>
|
|
{dialogFilteredParticipants.map((participant: any) => {
|
|
const isEligible = forcePickEligibility
|
|
? forcePickEligibility.eligibleSportIds.has(participant.sport.id)
|
|
: true;
|
|
const ineligibleReason = forcePickEligibility?.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>
|
|
{!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={() => handleForceManualPick(participant.id)}
|
|
disabled={!isEligible}
|
|
title={!isEligible ? ineligibleReason : undefined}
|
|
>
|
|
Draft
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="px-6 pb-6">
|
|
<Button variant="outline" onClick={() => setForcePickDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
</DialogFooter>
|
|
</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>
|
|
|
|
{/* Time Bank Adjustment Dialog */}
|
|
<Dialog
|
|
open={timeBankDialogOpen}
|
|
onOpenChange={(open) => {
|
|
setTimeBankDialogOpen(open);
|
|
if (!open) setTimeBankTeamId(null);
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Adjust Time Bank</DialogTitle>
|
|
<DialogDescription>
|
|
{timeBankTeamId
|
|
? `Adjusting time for ${draftSlots.find((s) => s.team.id === timeBankTeamId)?.team.name}`
|
|
: "Adjust a team's time bank"}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-col gap-4 py-2">
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setTimeBankDirection("add")}
|
|
className={`flex-1 py-2 rounded-md border text-sm font-medium transition-colors ${
|
|
timeBankDirection === "add"
|
|
? "bg-emerald-500/20 border-emerald-500 text-emerald-400"
|
|
: "border-border text-muted-foreground hover:bg-muted"
|
|
}`}
|
|
>
|
|
Add Time
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setTimeBankDirection("remove")}
|
|
className={`flex-1 py-2 rounded-md border text-sm font-medium transition-colors ${
|
|
timeBankDirection === "remove"
|
|
? "bg-destructive/20 border-destructive text-destructive"
|
|
: "border-border text-muted-foreground hover:bg-muted"
|
|
}`}
|
|
>
|
|
Remove Time
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="number"
|
|
min="0.001"
|
|
step="any"
|
|
value={timeBankAmount}
|
|
onChange={(e) => setTimeBankAmount(e.target.value)}
|
|
className="flex-1 px-3 py-2 border rounded-md bg-background text-sm"
|
|
placeholder="Amount"
|
|
/>
|
|
<select
|
|
value={timeBankUnit}
|
|
onChange={(e) =>
|
|
setTimeBankUnit(e.target.value as "seconds" | "minutes" | "hours")
|
|
}
|
|
className="px-3 py-2 border rounded-md bg-background text-sm"
|
|
>
|
|
<option value="seconds">Seconds</option>
|
|
<option value="minutes">Minutes</option>
|
|
<option value="hours">Hours</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setTimeBankDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleConfirmAdjustTimeBank}
|
|
disabled={isAdjustingTimeBank}
|
|
variant={timeBankDirection === "remove" ? "destructive" : "default"}
|
|
>
|
|
{isAdjustingTimeBank
|
|
? "Adjusting..."
|
|
: timeBankDirection === "add"
|
|
? "Add Time"
|
|
: "Remove Time"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Connection Overlay - blocks interaction until socket connects */}
|
|
<ConnectionOverlay
|
|
isConnected={isConnected}
|
|
isReconnecting={isReconnecting}
|
|
connectionError={connectionError}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|