import { useState, useEffect } from "react"; import { Form, Link, redirect, useNavigation } from "react-router"; import { auth } from "~/lib/auth.server"; import { format } from "date-fns"; import { CalendarIcon } from "lucide-react"; import { logger } from "~/lib/logger"; import { findLeagueById, updateLeague, deleteLeague } from "~/models/league"; import { sendStandingsUpdateNotification } from "~/services/discord"; import { isCommissioner, hasCommissionerRecord, findCommissionersByLeagueId, createCommissioner, countCommissionersByLeagueId, removeCommissionerByLeagueAndUser, } from "~/models/commissioner"; import { findCurrentSeasonWithSports, updateSeason, type NewSeason } from "~/models/season"; import { findTeamsBySeasonId, deleteTeam, removeTeamOwner, claimTeam } from "~/models/team"; import { findAllUsers, findUsersByIds, findUserById, getUserDisplayName, isUserAdmin } from "~/models/user"; import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport"; import { findDraftableSportsSeasons, findSportsSeasonsByIds } from "~/models/sports-season"; import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { deleteAllDraftPicks } from "~/models/draft-pick"; import { clearAllQueuesForSeason } from "~/models/draft-queue"; import { deleteSeasonTimers } from "~/models/draft-timer"; import { logCommissionerAction } from "~/models/audit-log"; import { parseDraftSpeed } from "~/lib/draft-timer"; import type { Route } from "./+types/$leagueId.settings"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { Calendar } from "~/components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger, } from "~/components/ui/popover"; import { cn } from "~/lib/utils"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "~/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "~/components/ui/select"; import { Badge } from "~/components/ui/badge"; import { Checkbox } from "~/components/ui/checkbox"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "~/components/ui/alert-dialog"; import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor"; import { toast } from "sonner"; export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { return [{ title: `Settings — ${data?.league?.name ?? "League"} - Brackt` }]; } export async function loader(args: Route.LoaderArgs) { const session = await auth.api.getSession({ headers: args.request.headers }); const userId = session?.user.id ?? null; const { params } = args; const { leagueId } = params; if (!userId) { throw new Response("Unauthorized", { status: 401 }); } // Fetch league const league = await findLeagueById(leagueId); if (!league) { throw new Response("League not found", { status: 404 }); } // Check if user is authorized (creator or commissioner) const userIsCommissioner = await isCommissioner(leagueId, userId); const isCreator = league.createdBy === userId; if (!isCreator && !userIsCommissioner) { throw new Response("Forbidden - You must be a commissioner to access settings", { status: 403, }); } // Get current season with sports and draftable seasons in parallel const [season, draftableSportsSeasons] = await Promise.all([ findCurrentSeasonWithSports(leagueId), findDraftableSportsSeasons(), ]); const teams = season ? await findTeamsBySeasonId(season.id) : []; // Merge draftable seasons with any already-linked non-draftable seasons so that // previously-added seasons remain visible and checked in the UI even after being disabled. const today = new Date().toISOString().slice(0, 10); const draftableIds = new Set(draftableSportsSeasons.map((s) => s.id)); const linkedButNotDraftable = (season?.seasonSports ?? []) .map((s) => s.sportsSeason) .filter((ss) => !draftableIds.has(ss.id) && (ss.draftOff < today || ss.draftOn > today)); const allSportsSeasons = [...draftableSportsSeasons, ...linkedButNotDraftable]; const draftSlots = season ? await findDraftSlotsBySeasonId(season.id) : []; // Count teams with owners const teamsWithOwners = teams.filter(team => team.ownerId !== null).length; // Check if user is admin const isAdmin = await isUserAdmin(userId); // Get all users (only for admins - used in team assignment dropdown) const allUsers = isAdmin ? await findAllUsers() : []; // Get commissioners for this league with user info const commissioners = await findCommissionersByLeagueId(leagueId); // Batch-fetch all users needed for commissioner and owner lookups in one query const uniqueOwnerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))]; const commissionerUserIds = commissioners.map((c) => c.userId); const allUserIds = [...new Set([...commissionerUserIds, ...uniqueOwnerIds])]; const userRows = await findUsersByIds(allUserIds); const userById = new Map(userRows.map((u) => [u.id, u])); const commissionerUserData = commissioners.map((c) => { const user = userById.get(c.userId); return { ...c, userName: user ? (getUserDisplayName(user) ?? "Unknown User") : "Unknown User", }; }); const validOwners = uniqueOwnerIds .map((ownerId) => { const user = userById.get(ownerId); return user ? { id: user.id, name: getUserDisplayName(user) } : null; }) .filter((o): o is NonNullable => o !== null); const ownerMap = new Map(validOwners.map((o) => [o.id, o.name])); // League members (team owners) - available to all commissioners for adding co-commissioners const leagueMembers = validOwners.map((o) => ({ id: o.id, name: o.name, })); return { league, season, teams, teamCount: teams.length, teamsWithOwners, allSportsSeasons: allSportsSeasons as Array, draftSlots, isAdmin, allUsers, leagueMembers, ownerMap: Object.fromEntries(ownerMap), commissioners: commissionerUserData, currentUserId: userId, }; } export async function action(args: Route.ActionArgs) { const session = await auth.api.getSession({ headers: args.request.headers }); const userId = session?.user.id ?? null; const { params, request } = args; const { leagueId } = params; if (!userId) { throw new Response("Unauthorized", { status: 401 }); } // Verify authorization const league = await findLeagueById(leagueId); if (!league) { throw new Response("League not found", { status: 404 }); } const userIsCommissioner = await isCommissioner(leagueId, userId); const isCreator = league.createdBy === userId; if (!isCreator && !userIsCommissioner) { throw new Response("Forbidden", { status: 403 }); } const formData = await request.formData(); const intent = formData.get("intent"); // For league-level settings (name, public draft board), save immediately // before checking season, so they work even if no season exists if (intent === "update") { const name = formData.get("name"); const isPublicDraftBoard = formData.get("isPublicDraftBoard") === "on"; const discordWebhookUrl = formData.get("discordWebhookUrl"); if (typeof name !== "string" || !name.trim()) { return { error: "League name is required" }; } if (name.trim().length < 3 || name.trim().length > 50) { return { error: "League name must be between 3 and 50 characters" }; } const webhookUrl = typeof discordWebhookUrl === "string" ? discordWebhookUrl.trim() : ""; if (webhookUrl && !webhookUrl.startsWith("https://discord.com/api/webhooks/")) { return { error: "Discord webhook URL must start with https://discord.com/api/webhooks/" }; } try { const changedFields: string[] = []; if (name.trim() !== league.name) changedFields.push("name"); if (isPublicDraftBoard !== league.isPublicDraftBoard) changedFields.push("isPublicDraftBoard"); if ((webhookUrl || null) !== league.discordWebhookUrl) changedFields.push("discordWebhookUrl"); await updateLeague(leagueId, { name: name.trim(), isPublicDraftBoard, discordWebhookUrl: webhookUrl || null, }); if (changedFields.length > 0) { const currentSeason = await findCurrentSeasonWithSports(leagueId); if (currentSeason) { await logCommissionerAction({ seasonId: currentSeason.id, leagueId, actorUserId: userId, action: "league_settings_changed", details: { changedFields, previousValues: { name: league.name, isPublicDraftBoard: league.isPublicDraftBoard, discordWebhookUrl: league.discordWebhookUrl, }, newValues: { name: name.trim(), isPublicDraftBoard, discordWebhookUrl: webhookUrl || null, }, }, }); } } } catch (error) { logger.error("Error updating league:", error); return { error: "Failed to update league. Please try again." }; } } if (intent === "test-discord-webhook") { const webhookUrl = formData.get("webhookUrl"); if (typeof webhookUrl !== "string" || !webhookUrl.startsWith("https://discord.com/api/webhooks/")) { return { error: "Invalid Discord webhook URL" }; } try { const testSeason = await findCurrentSeasonWithSports(leagueId); const seasonName = testSeason ? `${league.name} ${testSeason.year}` : league.name; await sendStandingsUpdateNotification({ webhookUrl, seasonName, standings: [ { teamId: "1", teamName: "Team Alpha", totalPoints: 150, rank: 1 }, { teamId: "2", teamName: "Team Beta", totalPoints: 125, rank: 2 }, { teamId: "3", teamName: "Team Gamma", totalPoints: 100, rank: 3 }, ], previousStandings: new Map([ ["1", 125], ["2", 125], ["3", 100], ]), previousRanks: new Map([ ["1", 2], ["2", 1], ["3", 3], ]), eventName: "Test Notification", scoredMatches: [ { winnerName: "Team Alpha", loserName: "Team Beta", winnerUsername: "manager1", loserUsername: "manager2" }, ], }); return { testSuccess: true }; } catch (err) { logger.error("Discord test webhook failed:", err); return { error: "Failed to send test notification. Check your webhook URL." }; } } // Get current season to check status const season = await findCurrentSeasonWithSports(leagueId); if (!season) { if (intent === "update") { return redirect(`/leagues/${leagueId}?updated=true`); } return { error: "No active season found" }; } if (intent === "update-sports") { if (season.status !== "pre_draft") { return { error: "Cannot modify sports after draft has started" }; } const selectedSports = formData.getAll("sportsSeasons"); const currentSportIds = new Set( season.seasonSports?.map((s) => s.sportsSeason.id) || [] ); const newSportIds = new Set(selectedSports as string[]); // Remove sports that are no longer selected for (const sportId of currentSportIds) { if (!newSportIds.has(sportId)) { await unlinkSportFromSeason(season.id, sportId); } } // Add newly selected sports const sportsToAdd = []; for (const sportId of newSportIds) { if (!currentSportIds.has(sportId)) { sportsToAdd.push({ seasonId: season.id, sportsSeasonId: sportId as string, }); } } if (sportsToAdd.length > 0) { await linkMultipleSportsToSeason(sportsToAdd); } return { success: true }; } if (intent === "set-draft-order") { if (season.status !== "pre_draft") { return { error: "Cannot modify draft order after draft has started" }; } const teams = await findTeamsBySeasonId(season.id); const teamIds = formData.getAll("teamOrder") as string[]; // Validate that all teams are included if (teamIds.length !== teams.length) { return { error: "All teams must be included in the draft order" }; } // Validate that all team IDs are valid const validTeamIds = new Set(teams.map(t => t.id)); for (const teamId of teamIds) { if (!validTeamIds.has(teamId)) { return { error: "Invalid team ID in draft order" }; } } await setDraftOrder(season.id, teamIds); await logCommissionerAction({ seasonId: season.id, leagueId, actorUserId: userId, action: "draft_order_set", affectedTeamIds: teamIds, details: { order: teamIds.map((id, i) => ({ teamId: id, teamName: teams.find((t) => t.id === id)?.name ?? id, position: i + 1, })), }, }); return { success: true, message: "Draft order updated successfully" }; } if (intent === "randomize-draft-order") { if (season.status !== "pre_draft") { return { error: "Cannot modify draft order after draft has started" }; } const teams = await findTeamsBySeasonId(season.id); const teamIds = teams.map(t => t.id); await randomizeDraftOrder(season.id, teamIds); // Fetch the new order that was just written so we can log it accurately const newSlots = await findDraftSlotsBySeasonId(season.id); const sortedSlots = newSlots.toSorted((a, b) => a.draftOrder - b.draftOrder); await logCommissionerAction({ seasonId: season.id, leagueId, actorUserId: userId, action: "draft_order_randomized", affectedTeamIds: sortedSlots.map((s) => s.teamId), details: { order: sortedSlots.map((s) => ({ teamId: s.teamId, teamName: teams.find((t) => t.id === s.teamId)?.name ?? s.teamId, position: s.draftOrder, })), }, }); return { success: true, message: "Draft order randomized successfully" }; } if (intent === "remove-team-owner") { const teamId = formData.get("teamId") as string; if (!teamId) { return { error: "Team ID is required" }; } try { await removeTeamOwner(teamId); return { success: true, message: "Owner removed successfully" }; } catch (error) { logger.error("Error removing team owner:", error); return { error: "Failed to remove owner. Please try again." }; } } if (intent === "assign-team-owner") { const teamId = formData.get("teamId") as string; const assignedUserId = formData.get("userId") as string; if (!teamId || !assignedUserId) { return { error: "Team ID and User ID are required" }; } // Check if user is admin (only admins can assign owners) const isAdmin = await isUserAdmin(userId); if (!isAdmin) { return { error: "Only admins can assign team owners" }; } // Look up user before assigning to fail fast and get their name const assignedUser = await findUserById(assignedUserId); if (!assignedUser) { return { error: "User not found. Please try again." }; } // Check if user is already assigned to a team in this season const teams = await findTeamsBySeasonId(season.id); const userAlreadyHasTeam = teams.some(team => team.ownerId === assignedUserId); if (userAlreadyHasTeam) { return { error: "This user is already assigned to a team in this league" }; } // Check if the target team already has an owner const targetTeam = teams.find(team => team.id === teamId); if (targetTeam?.ownerId) { return { error: "This team already has an owner. Remove the current owner first." }; } try { const teamName = `Team ${getUserDisplayName(assignedUser) ?? "Member"}`; await claimTeam(teamId, assignedUserId, teamName); return { success: true, message: "Owner assigned successfully" }; } catch (error) { logger.error("Error assigning team owner:", error); return { error: "Failed to assign owner. Please try again." }; } } if (intent === "reset-draft") { // Check if user is admin (only admins can reset draft) const isAdmin = await isUserAdmin(userId); if (!isAdmin) { return { error: "Only admins can reset the draft" }; } try { // Delete all draft picks await deleteAllDraftPicks(season.id); // Clear all draft queues await clearAllQueuesForSeason(season.id); // Delete all draft timers await deleteSeasonTimers(season.id); const previousPickNumber = season.currentPickNumber; // Set season status back to pre_draft and reset draft state (keeps draft order intact) await updateSeason(season.id, { status: "pre_draft", currentPickNumber: null, draftStartedAt: null, draftPaused: false, }); await logCommissionerAction({ seasonId: season.id, leagueId, actorUserId: userId, action: "draft_reset", details: { previousPickNumber }, }); return { success: true, message: "Draft has been reset successfully. Draft order preserved." }; } catch (error) { logger.error("Error resetting draft:", error); return { error: "Failed to reset draft. Please try again." }; } } if (intent === "delete") { // Delete the league await deleteLeague(leagueId); // Redirect to home with success message return redirect("/?deleted=true"); } if (intent === "update") { const teamCount = formData.get("teamCount"); const draftDateTime = formData.get("draftDateTime"); const draftRounds = formData.get("draftRounds"); const draftSpeed = formData.get("draftSpeed"); const draftTimerMode = formData.get("draftTimerMode") as "chess_clock" | "standard" | null; // League-level fields (name, isPublicDraftBoard) are already saved above. // Now handle season-specific updates. try { // Update season settings const seasonUpdates: Partial = {}; // Handle draft rounds (only if value actually changed) if (typeof draftRounds === "string") { const draftRoundsNum = parseInt(draftRounds, 10); if (!isNaN(draftRoundsNum)) { const sportsCount = season.seasonSports?.length || 0; if (draftRoundsNum < sportsCount) { return { error: `Draft rounds must be at least ${sportsCount} (number of sports selected)` }; } if (draftRoundsNum < 1 || draftRoundsNum > 50) { return { error: "Draft rounds must be between 1 and 50" }; } if (draftRoundsNum !== season.draftRounds) { seasonUpdates.draftRounds = draftRoundsNum; } } } // Handle draft date/time (only if value actually changed) if (typeof draftDateTime === "string") { const newDateTime = draftDateTime ? new Date(draftDateTime) : null; const currentDateTime = season.draftDateTime ? new Date(season.draftDateTime) : null; const changed = newDateTime?.getTime() !== currentDateTime?.getTime(); if (changed) { seasonUpdates.draftDateTime = newDateTime; } } // Set draft times from speed preset (only if draftSpeed was submitted and values changed) if (draftSpeed !== null) { const { draftInitialTime, draftIncrementTime } = parseDraftSpeed( draftSpeed as string | null, draftTimerMode ?? "chess_clock" ); if (draftInitialTime !== season.draftInitialTime) { seasonUpdates.draftInitialTime = draftInitialTime; } if (draftIncrementTime !== season.draftIncrementTime) { seasonUpdates.draftIncrementTime = draftIncrementTime; } } // Timer mode (only if submitted and changed) if (draftTimerMode !== null && draftTimerMode !== season.draftTimerMode) { seasonUpdates.draftTimerMode = draftTimerMode; } // Handle scoring rules (only if in pre_draft status and values changed) if (season.status === "pre_draft") { const scoringFields = [ "pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th", "pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th" ]; for (const field of scoringFields) { const value = formData.get(field); if (typeof value === "string") { const points = parseInt(value, 10); if (!isNaN(points)) { if (points < 0 || points > 1000) { return { error: `${field} must be between 0 and 1000 points` }; } if (points !== (season as unknown as Record)[field]) { (seasonUpdates as Record)[field] = points; } } } } } // Update season if there are changes if (Object.keys(seasonUpdates).length > 0) { await updateSeason(season.id, seasonUpdates); const scoringFields = ["pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th", "pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th"]; const draftFields = Object.keys(seasonUpdates).filter((k) => !scoringFields.includes(k)); const scoringChangedFields = Object.keys(seasonUpdates).filter((k) => scoringFields.includes(k)); const seasonAsMap = season as unknown as Record; const updatesAsMap = seasonUpdates as Record; if (draftFields.length > 0) { await logCommissionerAction({ seasonId: season.id, leagueId, actorUserId: userId, action: "draft_settings_changed", details: { changedFields: draftFields, previousValues: Object.fromEntries(draftFields.map((k) => [k, seasonAsMap[k]])), newValues: Object.fromEntries(draftFields.map((k) => [k, updatesAsMap[k]])), }, }); } if (scoringChangedFields.length > 0) { await logCommissionerAction({ seasonId: season.id, leagueId, actorUserId: userId, action: "scoring_rules_changed", details: { changedFields: scoringChangedFields, previousValues: Object.fromEntries(scoringChangedFields.map((k) => [k, seasonAsMap[k]])), newValues: Object.fromEntries(scoringChangedFields.map((k) => [k, updatesAsMap[k]])), }, }); } } // Handle sports changes (only valid in pre_draft) if (season.status === "pre_draft") { const selectedSports = formData.getAll("sportsSeasons"); const currentSportIds = new Set( season.seasonSports?.map((s) => s.sportsSeason.id) || [] ); const newSportIds = new Set(selectedSports as string[]); for (const sportId of currentSportIds) { if (!newSportIds.has(sportId)) { await unlinkSportFromSeason(season.id, sportId); } } const sportsToAdd = []; for (const sportId of newSportIds) { if (!currentSportIds.has(sportId)) { sportsToAdd.push({ seasonId: season.id, sportsSeasonId: sportId as string, }); } } if (sportsToAdd.length > 0) { await linkMultipleSportsToSeason(sportsToAdd); } const removedIds = [...currentSportIds].filter((id) => !newSportIds.has(id)); const addedIds = [...newSportIds].filter((id) => !currentSportIds.has(id)); if (removedIds.length > 0 || addedIds.length > 0) { const nameMap = new Map( season.seasonSports?.map((s) => [s.sportsSeason.id, `${s.sportsSeason.sport.name} ${s.sportsSeason.year}`]) ?? [] ); const addedSeasons = await findSportsSeasonsByIds(addedIds); const addedNameMap = new Map(addedSeasons.map((ss) => [ss.id, `${ss.sport.name} ${ss.year}`])); const addedNames = addedIds.map((id) => addedNameMap.get(id) ?? id); await logCommissionerAction({ seasonId: season.id, leagueId, actorUserId: userId, action: "sports_changed", details: { added: addedNames, removed: removedIds.map((id) => nameMap.get(id) ?? id), }, }); } } // Handle team count changes if provided if (typeof teamCount === "string") { const newTeamCount = parseInt(teamCount, 10); if (!isNaN(newTeamCount)) { const teams = await findTeamsBySeasonId(season.id); const currentTeamCount = teams.length; const teamsWithOwners = teams.filter(t => t.ownerId !== null).length; // Validate team count if (newTeamCount < 6 || newTeamCount > 16) { return { error: "Number of teams must be between 6 and 16" }; } if (newTeamCount < teamsWithOwners) { return { error: `Cannot reduce team count below ${teamsWithOwners} (number of teams with owners)` }; } // Add or remove teams as needed if (newTeamCount > currentTeamCount) { const teamsToAdd = Array.from( { length: newTeamCount - currentTeamCount }, (_, i) => ({ seasonId: season.id, name: `Team ${currentTeamCount + i + 1}`, ownerId: null as null, }) ); // Fetch existing slots before the transaction to determine append position const existingSlots = await findDraftSlotsBySeasonId(season.id); const maxOrder = existingSlots.reduce((max, s) => Math.max(max, s.draftOrder), 0); // Create teams and their draft slots atomically const db = database(); await db.transaction(async (tx) => { const newTeams = await tx .insert(schema.teams) .values(teamsToAdd) .returning(); await tx.insert(schema.draftSlots).values( newTeams.map((team, i) => ({ seasonId: season.id, teamId: team.id, draftOrder: maxOrder + i + 1, })) ); }); } else if (newTeamCount < currentTeamCount) { // Remove teams without owners (from the end) const teamsToRemove = teams .filter(t => t.ownerId === null) .slice(-(currentTeamCount - newTeamCount)); for (const team of teamsToRemove) { await deleteTeam(team.id); } } } } return redirect(`/leagues/${leagueId}?updated=true`); } catch (error) { logger.error("Error updating season settings:", error); return { error: "Failed to update season settings. Please try again." }; } } if (intent === "add-commissioner") { const newCommissionerUserId = formData.get("userId") as string; if (!newCommissionerUserId) { return { error: "User is required" }; } // Check if user already has a commissioner record (don't use isCommissioner — it // returns true for site admins, causing a false "already a commissioner" error) const alreadyCommissioner = await hasCommissionerRecord(leagueId, newCommissionerUserId); if (alreadyCommissioner) { return { error: "This user is already a commissioner" }; } try { await createCommissioner({ leagueId, userId: newCommissionerUserId }); return { success: true, message: "Commissioner added successfully" }; } catch (error) { logger.error("Error adding commissioner:", error); return { error: "Failed to add commissioner. Please try again." }; } } if (intent === "remove-commissioner") { const commissionerUserId = formData.get("commissionerUserId") as string; if (!commissionerUserId) { return { error: "User is required" }; } // Prevent self-removal if (commissionerUserId === userId) { return { error: "You cannot remove yourself as a commissioner" }; } // Prevent removing the last commissioner const count = await countCommissionersByLeagueId(leagueId); if (count <= 1) { return { error: "Cannot remove the last commissioner" }; } try { await removeCommissionerByLeagueAndUser(leagueId, commissionerUserId); return { success: true, message: "Commissioner removed successfully" }; } catch (error) { logger.error("Error removing commissioner:", error); return { error: "Failed to remove commissioner. Please try again." }; } } return { error: "Invalid action" }; } export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) { const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, leagueMembers, ownerMap, commissioners, currentUserId } = loaderData; const navigation = useNavigation(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock"); const [selectedSports, setSelectedSports] = useState>( new Set(season?.seasonSports?.map((s) => s.sportsSeason.id) || []) ); const [draftOrderTeams, setDraftOrderTeams] = useState( draftSlots.length > 0 ? draftSlots.map((slot) => slot.teamId) : teams.map((team) => team.id) ); const [draftRounds, setDraftRounds] = useState(season?.draftRounds || 20); const [draftDate, setDraftDate] = useState( season?.draftDateTime ? new Date(season.draftDateTime) : undefined ); const [draftTime, setDraftTime] = useState( season?.draftDateTime ? format(new Date(season.draftDateTime), "HH:mm") : "" ); // Update draft order when loader data changes (after randomization) useEffect(() => { const newOrder = draftSlots.length > 0 ? draftSlots.map((slot) => slot.teamId) : teams.map((team) => team.id); setDraftOrderTeams(newOrder); }, [draftSlots, teams]); const canEditSports = season && season.status === "pre_draft"; const canEditTeamCount = season && season.status === "pre_draft"; const canEditDraftOrder = season && season.status === "pre_draft"; const canEditDraftRounds = season && season.status === "pre_draft"; const minTeamCount = Math.max(6, teamsWithOwners); // Calculate flex spots and minimum rounds const sportsCount = selectedSports.size; const minRounds = sportsCount; const recommendedRounds = Math.ceil(sportsCount * 1.25); const flexSpots = Math.max(0, draftRounds - sportsCount); const handleSportToggle = (sportId: string) => { const newSelected = new Set(selectedSports); if (newSelected.has(sportId)) { newSelected.delete(sportId); } else { newSelected.add(sportId); } setSelectedSports(newSelected); }; const handleDelete = () => { setIsDeleteDialogOpen(false); // The form submission will handle the actual deletion }; const moveDraftSlot = (fromIndex: number, toIndex: number) => { const newOrder = [...draftOrderTeams]; const [movedTeam] = newOrder.splice(fromIndex, 1); newOrder.splice(toIndex, 0, movedTeam); setDraftOrderTeams(newOrder); }; const getTeamById = (teamId: string) => { return teams.find((t) => t.id === teamId); }; const [copied, setCopied] = useState(false); const handleCopyInviteLink = async () => { if (!season?.inviteCode) return; const inviteUrl = `${window.location.origin}/i/${season.inviteCode}`; try { await navigator.clipboard.writeText(inviteUrl); setCopied(true); toast.success("Invite link copied to clipboard!"); setTimeout(() => setCopied(false), 2000); } catch { toast.error("Failed to copy invite link"); } }; return (

League Settings

{league.name}

{season?.inviteCode && ( Invite Link Share this link to invite people to join your league {teamCount - teamsWithOwners > 0 && ` (${teamCount - teamsWithOwners} spot${teamCount - teamsWithOwners !== 1 ? "s" : ""} available)`}
e.currentTarget.select()} />

Anyone with this link can join your league

)}
{/* General Settings */} General Settings Update your league's basic information

{canEditTeamCount ? `Can be changed before draft starts (minimum ${minTeamCount} based on registered teams)` : "Team count cannot be changed after draft has started"}

{/* Notifications */} Notifications Get Discord alerts when standings change after scoring events

Create a webhook in your Discord server under Server Settings → Integrations → Webhooks.

{league.discordWebhookUrl && ( )} {actionData && "testSuccess" in actionData && actionData.testSuccess && (

Test notification sent to Discord!

)}
{/* Draft Rounds */} Draft Rounds {canEditDraftRounds ? "Set the number of draft rounds for your league" : "Draft rounds cannot be modified after the draft has started"}

Minimum: {minRounds} (number of sports selected) {recommendedRounds > minRounds && ` • Recommended: ${recommendedRounds}`}

{sportsCount > 0 && draftRounds < sportsCount && (

⚠️ You need at least {sportsCount} rounds for the {sportsCount} sports selected

)} {sportsCount > 0 && draftRounds >= sportsCount && (

Flex Spots: {flexSpots}

)}
date < new Date(new Date().setHours(0, 0, 0, 0))} /> setDraftTime(e.target.value)} placeholder="Select time" disabled={!canEditDraftRounds} />
{draftDate && draftTime && ( )} {!draftDate && !draftTime && ( )}

{canEditDraftRounds ? "You must set a draft date and time before starting the draft" : "Draft date cannot be changed after draft has started"}

Chess Clock: unused time carries over between picks. Standard: timer resets each pick.

{timerMode === "standard" ? "Time per pick — resets to this value after each selection" : "Initial time bank + increment added after each pick"}

{/* Scoring Rules */} {/* Sports Seasons */} Sports Seasons {canEditSports ? "Select the sports seasons that teams can draft from" : "Sports cannot be modified after the draft has started"}

Recommendation: Select 16-20 sports seasons for optimal league balance and variety.

{allSportsSeasons.length > 0 ? ( allSportsSeasons.map((ss) => (
handleSportToggle(ss.id)} disabled={!canEditSports} />
)) ) : (

No sports seasons available

)}
{/* Save Button */} {actionData?.error && (
{actionData.error}
)} {actionData?.success && (
Settings updated successfully!
)}
{/* Draft Order Management */} Draft Order {canEditDraftOrder ? "Set the draft order for your league. Drag teams to reorder or randomize." : "Draft order cannot be changed after the draft has started"}
{draftSlots.length === 0 && canEditDraftOrder && (

Note: Draft order has not been set yet. Use the form below to set the order manually or click "Randomize" to generate a random order.

)}
{draftOrderTeams.map((teamId, index) => { const team = getTeamById(teamId); if (!team) return null; return (
{index + 1}

{team.name}

{team.ownerId && (

{ownerMap[team.ownerId] ?? "Unknown"}

)}
{canEditDraftOrder && (
)}
); })}
{canEditDraftOrder && (
)} {actionData?.success && actionData?.message && (
{actionData.message}
)} {actionData?.error && (
{actionData.error}
)}
{canEditDraftOrder && (
)}
{/* Team Management */} Team Management Manage team ownership for this league
{teams.map((team) => { const ownerName = team.ownerId ? ownerMap[team.ownerId] : null; return (

{team.name}

{team.ownerId ? ( Owner: {ownerName || "Unknown"} ) : ( No owner )}

{team.ownerId && (
)} {isAdmin && (
)}
); })}
{/* Commissioner Management */} Commissioner Management Commissioners can manage league settings and oversee the draft. They do not need to own a team. {actionData?.error && !actionData?.success && (
{actionData.error}
)} {actionData?.success && actionData?.message && (
{actionData.message}
)}
{commissioners.map((commissioner) => (

{commissioner.userName} {commissioner.userId === currentUserId && ( (you) )}

{!teams.some((t) => t.ownerId === commissioner.userId) && (

No team in current season

)}
{commissioners.length > 1 && commissioner.userId !== currentUserId && (
)}
))}

Add Commissioner

{/* Audit Log */} Audit Log View the full history of commissioner actions for this season. All league members can see this log. {/* Danger Zone */} Danger Zone Irreversible and destructive actions {/* Reset Draft - Admin Only */} {isAdmin && season && (

Reset Draft

Delete all draft picks, queues, and timers, and reset the season to pre-draft status. The draft order will be preserved.

Reset the draft? This will delete all draft picks, draft queues, and draft timers, and set the season back to pre-draft status. The draft order will remain intact. This action cannot be undone. Cancel
Reset Draft
)} {/* Delete League */}

Delete League

Permanently delete this league and all associated data.

Are you absolutely sure? This will permanently delete {league.name} and all associated data including seasons, teams, and commissioners. This action cannot be undone. Cancel
Delete League
); }