1002 lines
37 KiB
TypeScript
1002 lines
37 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Form, Link, redirect, useNavigation } from "react-router";
|
|
import { getAuth } from "@clerk/react-router/server";
|
|
import { format } from "date-fns";
|
|
import { CalendarIcon } from "lucide-react";
|
|
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
|
|
import { isCommissioner } from "~/models/commissioner";
|
|
import { findCurrentSeasonWithSports, updateSeason } from "~/models/season";
|
|
import { findTeamsBySeasonId, createManyTeams, deleteTeam, removeTeamOwner, assignTeamOwner } from "~/models/team";
|
|
import { findAllUsers, findUserByClerkId, isUserAdminByClerkId } from "~/models/user";
|
|
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
|
|
import { findAllSportsSeasons } from "~/models/sports-season";
|
|
import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot";
|
|
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";
|
|
|
|
export async function loader(args: Route.LoaderArgs) {
|
|
const { userId } = await getAuth(args);
|
|
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
|
|
const season = await findCurrentSeasonWithSports(leagueId);
|
|
const teams = season ? await findTeamsBySeasonId(season.id) : [];
|
|
const allSportsSeasons = await findAllSportsSeasons();
|
|
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 isUserAdminByClerkId(userId);
|
|
|
|
// Get all users if admin (for team assignment dropdown)
|
|
const allUsers = isAdmin ? await findAllUsers() : [];
|
|
|
|
// Get owner details for teams
|
|
const ownerIds = teams
|
|
.map((t) => t.ownerId)
|
|
.filter((id): id is string => id !== null);
|
|
const uniqueOwnerIds = [...new Set(ownerIds)];
|
|
const owners = await Promise.all(
|
|
uniqueOwnerIds.map(async (ownerId) => {
|
|
const user = await findUserByClerkId(ownerId);
|
|
return user
|
|
? { clerkId: ownerId, name: user.username || user.displayName, id: user.id }
|
|
: null;
|
|
})
|
|
);
|
|
const ownerMap = new Map(
|
|
owners
|
|
.filter((o): o is NonNullable<typeof o> => o !== null)
|
|
.map((o) => [o.clerkId, o.name])
|
|
);
|
|
|
|
return {
|
|
league,
|
|
season,
|
|
teams,
|
|
teamCount: teams.length,
|
|
teamsWithOwners,
|
|
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>,
|
|
draftSlots,
|
|
isAdmin,
|
|
allUsers,
|
|
ownerMap: Object.fromEntries(ownerMap),
|
|
};
|
|
}
|
|
|
|
export async function action(args: Route.ActionArgs) {
|
|
const { userId } = await getAuth(args);
|
|
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");
|
|
|
|
// Get current season to check status
|
|
const season = await findCurrentSeasonWithSports(leagueId);
|
|
if (!season) {
|
|
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: any) => 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);
|
|
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);
|
|
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) {
|
|
console.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 userClerkId = formData.get("userClerkId") as string;
|
|
|
|
if (!teamId || !userClerkId) {
|
|
return { error: "Team ID and User ID are required" };
|
|
}
|
|
|
|
// Check if user is admin (only admins can assign owners)
|
|
const isAdmin = await isUserAdminByClerkId(userId);
|
|
if (!isAdmin) {
|
|
return { error: "Only admins can assign team owners" };
|
|
}
|
|
|
|
try {
|
|
await assignTeamOwner(teamId, userClerkId);
|
|
return { success: true, message: "Owner assigned successfully" };
|
|
} catch (error) {
|
|
console.error("Error assigning team owner:", error);
|
|
return { error: "Failed to assign owner. 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 name = formData.get("name");
|
|
const teamCount = formData.get("teamCount");
|
|
const draftDateTime = formData.get("draftDateTime");
|
|
const draftRounds = formData.get("draftRounds");
|
|
const draftSpeed = formData.get("draftSpeed");
|
|
const isPublicDraftBoard = formData.get("isPublicDraftBoard") === "on";
|
|
|
|
// Map draft speed to time values
|
|
let draftInitialTime: number;
|
|
let draftIncrementTime: number;
|
|
|
|
switch (draftSpeed) {
|
|
case "fast":
|
|
draftInitialTime = 60;
|
|
draftIncrementTime = 10;
|
|
break;
|
|
case "standard":
|
|
draftInitialTime = 120;
|
|
draftIncrementTime = 15;
|
|
break;
|
|
case "slow":
|
|
draftInitialTime = 28800; // 8 hours
|
|
draftIncrementTime = 3600; // 1 hour
|
|
break;
|
|
case "very-slow":
|
|
draftInitialTime = 43200; // 12 hours
|
|
draftIncrementTime = 3600; // 1 hour
|
|
break;
|
|
default:
|
|
draftInitialTime = 120;
|
|
draftIncrementTime = 15;
|
|
}
|
|
|
|
// Validation
|
|
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" };
|
|
}
|
|
|
|
try {
|
|
await updateLeague(leagueId, {
|
|
name: name.trim(),
|
|
isPublicDraftBoard,
|
|
});
|
|
|
|
// Update season settings
|
|
const seasonUpdates: any = {};
|
|
|
|
// Handle draft rounds
|
|
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" };
|
|
}
|
|
seasonUpdates.draftRounds = draftRoundsNum;
|
|
}
|
|
}
|
|
|
|
// Handle draft date/time
|
|
if (typeof draftDateTime === "string") {
|
|
seasonUpdates.draftDateTime = draftDateTime ? new Date(draftDateTime) : null;
|
|
}
|
|
|
|
// Set draft times from speed preset
|
|
seasonUpdates.draftInitialTime = draftInitialTime;
|
|
seasonUpdates.draftIncrementTime = draftIncrementTime;
|
|
|
|
// Update season if there are changes
|
|
if (Object.keys(seasonUpdates).length > 0) {
|
|
await updateSeason(season.id, seasonUpdates);
|
|
}
|
|
|
|
// 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) {
|
|
// Add new teams
|
|
const teamsToAdd = Array.from(
|
|
{ length: newTeamCount - currentTeamCount },
|
|
(_, i) => ({
|
|
seasonId: season.id,
|
|
name: `Team ${currentTeamCount + i + 1}`,
|
|
ownerId: null,
|
|
})
|
|
);
|
|
await createManyTeams(teamsToAdd);
|
|
} 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) {
|
|
console.error("Error updating league:", error);
|
|
return { error: "Failed to update league. 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, ownerMap } = loaderData;
|
|
const navigation = useNavigation();
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
const [selectedSports, setSelectedSports] = useState<Set<string>>(
|
|
new Set(season?.seasonSports?.map((s: any) => s.sportsSeason.id) || [])
|
|
);
|
|
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
|
|
draftSlots.length > 0
|
|
? draftSlots.map((slot: any) => slot.teamId)
|
|
: teams.map((team: any) => team.id)
|
|
);
|
|
const [draftRounds, setDraftRounds] = useState<number>(season?.draftRounds || 20);
|
|
const [draftDate, setDraftDate] = useState<Date | undefined>(
|
|
season?.draftDateTime ? new Date(season.draftDateTime) : undefined
|
|
);
|
|
const [draftTime, setDraftTime] = useState<string>(
|
|
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: any) => slot.teamId)
|
|
: teams.map((team: any) => 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: any) => t.id === teamId);
|
|
};
|
|
|
|
return (
|
|
<div className="container max-w-2xl mx-auto py-8 px-4">
|
|
<div className="mb-8">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h1 className="text-4xl font-bold">League Settings</h1>
|
|
<Button variant="outline" asChild>
|
|
<Link to={`/leagues/${league.id}`}>Back to League</Link>
|
|
</Button>
|
|
</div>
|
|
<p className="text-muted-foreground">{league.name}</p>
|
|
</div>
|
|
|
|
<Form method="post" className="space-y-6">
|
|
<input type="hidden" name="intent" value="update" />
|
|
|
|
{/* General Settings */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>General Settings</CardTitle>
|
|
<CardDescription>
|
|
Update your league's basic information
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">League Name</Label>
|
|
<Input
|
|
id="name"
|
|
name="name"
|
|
type="text"
|
|
defaultValue={league.name}
|
|
placeholder="Enter league name"
|
|
required
|
|
minLength={3}
|
|
maxLength={50}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
id="isPublicDraftBoard"
|
|
name="isPublicDraftBoard"
|
|
defaultChecked={league.isPublicDraftBoard}
|
|
className="h-4 w-4 rounded border-gray-300"
|
|
/>
|
|
<Label htmlFor="isPublicDraftBoard" className="font-normal cursor-pointer">
|
|
Make draft board publicly accessible (no login required)
|
|
</Label>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="teamCount">Number of Teams</Label>
|
|
<Select
|
|
name="teamCount"
|
|
defaultValue={teamCount.toString()}
|
|
disabled={!canEditTeamCount}
|
|
>
|
|
<SelectTrigger id="teamCount">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Array.from({ length: 11 }, (_, i) => i + 6).map((num) => (
|
|
<SelectItem
|
|
key={num}
|
|
value={num.toString()}
|
|
disabled={num < minTeamCount}
|
|
>
|
|
{num} Teams
|
|
{num < minTeamCount && ` (${teamsWithOwners} teams have owners)`}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-sm text-muted-foreground">
|
|
{canEditTeamCount
|
|
? `Can be changed before draft starts (minimum ${minTeamCount} based on registered teams)`
|
|
: "Team count cannot be changed after draft has started"}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Draft Rounds */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Draft Rounds</CardTitle>
|
|
<CardDescription>
|
|
{canEditDraftRounds
|
|
? "Set the number of draft rounds for your league"
|
|
: "Draft rounds cannot be modified after the draft has started"}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="draftRounds">Number of Draft Rounds</Label>
|
|
<Select
|
|
name="draftRounds"
|
|
value={draftRounds.toString()}
|
|
onValueChange={(value) => setDraftRounds(parseInt(value))}
|
|
disabled={!canEditDraftRounds}
|
|
required
|
|
>
|
|
<SelectTrigger id="draftRounds">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Array.from({ length: 50 }, (_, i) => i + 1)
|
|
.filter(num => num >= minRounds)
|
|
.map((num) => (
|
|
<SelectItem
|
|
key={num}
|
|
value={num.toString()}
|
|
>
|
|
{num} Round{num !== 1 ? 's' : ''}
|
|
{num === recommendedRounds && ' (recommended)'}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<div className="space-y-1">
|
|
<p className="text-sm text-muted-foreground">
|
|
Minimum: {minRounds} (number of sports selected)
|
|
{recommendedRounds > minRounds && ` • Recommended: ${recommendedRounds}`}
|
|
</p>
|
|
{sportsCount > 0 && draftRounds < sportsCount && (
|
|
<p className="text-sm font-medium text-destructive">
|
|
⚠️ You need at least {sportsCount} rounds for the {sportsCount} sports selected
|
|
</p>
|
|
)}
|
|
{sportsCount > 0 && draftRounds >= sportsCount && (
|
|
<p className="text-sm font-medium text-primary">
|
|
Flex Spots: {flexSpots}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="draftDate">Draft Date & Time</Label>
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant={"outline"}
|
|
className={cn(
|
|
"justify-start text-left font-normal",
|
|
!draftDate && "text-muted-foreground"
|
|
)}
|
|
disabled={!canEditDraftRounds}
|
|
>
|
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
{draftDate ? format(draftDate, "PPP") : <span>Pick a date</span>}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0">
|
|
<Calendar
|
|
mode="single"
|
|
selected={draftDate}
|
|
onSelect={setDraftDate}
|
|
initialFocus
|
|
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<Input
|
|
type="time"
|
|
value={draftTime}
|
|
onChange={(e) => setDraftTime(e.target.value)}
|
|
placeholder="Select time"
|
|
disabled={!canEditDraftRounds}
|
|
/>
|
|
</div>
|
|
{draftDate && draftTime && (
|
|
<input
|
|
type="hidden"
|
|
name="draftDateTime"
|
|
value={new Date(`${format(draftDate, "yyyy-MM-dd")}T${draftTime}`).toISOString()}
|
|
/>
|
|
)}
|
|
{!draftDate && !draftTime && (
|
|
<input type="hidden" name="draftDateTime" value="" />
|
|
)}
|
|
<p className="text-sm text-muted-foreground">
|
|
{canEditDraftRounds
|
|
? "You must set a draft date and time before starting the draft"
|
|
: "Draft date cannot be changed after draft has started"}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="draftSpeed">Draft Speed</Label>
|
|
<select
|
|
id="draftSpeed"
|
|
name="draftSpeed"
|
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
defaultValue={
|
|
season?.draftInitialTime === 60 && season?.draftIncrementTime === 10
|
|
? "fast"
|
|
: season?.draftInitialTime === 120 && season?.draftIncrementTime === 15
|
|
? "standard"
|
|
: season?.draftInitialTime === 28800 && season?.draftIncrementTime === 3600
|
|
? "slow"
|
|
: season?.draftInitialTime === 43200 && season?.draftIncrementTime === 3600
|
|
? "very-slow"
|
|
: "standard"
|
|
}
|
|
disabled={!canEditDraftRounds}
|
|
required
|
|
>
|
|
<option value="fast">Fast (1 min + 10 sec)</option>
|
|
<option value="standard">Standard (2 min + 15 sec)</option>
|
|
<option value="slow">Slow (8 hours + 1 hour)</option>
|
|
<option value="very-slow">Very Slow (12 hours + 1 hour)</option>
|
|
</select>
|
|
<p className="text-sm text-muted-foreground">
|
|
Initial time + increment per pick
|
|
</p>
|
|
<input type="hidden" name="draftInitialTime" id="draftInitialTime" />
|
|
<input type="hidden" name="draftIncrementTime" id="draftIncrementTime" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Sports Seasons */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Sports Seasons</CardTitle>
|
|
<CardDescription>
|
|
{canEditSports
|
|
? "Select the sports seasons that teams can draft from"
|
|
: "Sports cannot be modified after the draft has started"}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
|
|
<div className="space-y-3">
|
|
<div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
<strong>Recommendation:</strong> Select 16-20 sports seasons for optimal league balance and variety.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">
|
|
Sports Seasons ({selectedSports.size} selected)
|
|
</Label>
|
|
<div className="max-h-96 overflow-y-auto border rounded-md p-3 space-y-2">
|
|
{allSportsSeasons.length > 0 ? (
|
|
allSportsSeasons.map((season) => (
|
|
<div key={season.id} className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id={season.id}
|
|
name="sportsSeasons"
|
|
value={season.id}
|
|
checked={selectedSports.has(season.id)}
|
|
onCheckedChange={() => handleSportToggle(season.id)}
|
|
disabled={!canEditSports}
|
|
/>
|
|
<Label
|
|
htmlFor={season.id}
|
|
className={`font-normal flex-1 ${canEditSports ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}
|
|
>
|
|
<span className="font-medium">{season.sport.name}</span> - {season.name} ({season.year})
|
|
<Badge variant="outline" className="ml-2 text-xs">
|
|
{season.scoringType.replace("_", " ")}
|
|
</Badge>
|
|
</Label>
|
|
</div>
|
|
))
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
No sports seasons available
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Save Button */}
|
|
{actionData?.error && (
|
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
{actionData.error}
|
|
</div>
|
|
)}
|
|
|
|
{actionData?.success && (
|
|
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
|
|
Settings updated successfully!
|
|
</div>
|
|
)}
|
|
|
|
<Button type="submit" size="lg" className="w-full">
|
|
Save All Settings
|
|
</Button>
|
|
</Form>
|
|
|
|
<div className="space-y-6">
|
|
{/* Draft Order Management */}
|
|
<Card id="draft-order">
|
|
<CardHeader>
|
|
<CardTitle>Draft Order</CardTitle>
|
|
<CardDescription>
|
|
{canEditDraftOrder
|
|
? "Set the draft order for your league. Drag teams to reorder or randomize."
|
|
: "Draft order cannot be changed after the draft has started"}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{draftSlots.length === 0 && canEditDraftOrder && (
|
|
<div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
<strong>Note:</strong> Draft order has not been set yet. Use the form below to set the order manually or click "Randomize" to generate a random order.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<Form method="post" className="space-y-4">
|
|
<input type="hidden" name="intent" value="set-draft-order" />
|
|
|
|
<div className="space-y-2">
|
|
<div className="border rounded-md divide-y">
|
|
{draftOrderTeams.map((teamId, index) => {
|
|
const team = getTeamById(teamId);
|
|
if (!team) return null;
|
|
|
|
return (
|
|
<div key={teamId} className="flex items-center gap-3 p-3">
|
|
<input type="hidden" name="teamOrder" value={teamId} />
|
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary text-primary-foreground font-bold text-sm">
|
|
{index + 1}
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="font-medium">{team.name}</p>
|
|
</div>
|
|
{canEditDraftOrder && (
|
|
<div className="flex gap-1">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => moveDraftSlot(index, Math.max(0, index - 1))}
|
|
disabled={index === 0}
|
|
>
|
|
↑
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => moveDraftSlot(index, Math.min(draftOrderTeams.length - 1, index + 1))}
|
|
disabled={index === draftOrderTeams.length - 1}
|
|
>
|
|
↓
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{canEditDraftOrder && (
|
|
<div className="flex gap-2">
|
|
<Button type="submit" className="flex-1">
|
|
Save Draft Order
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{actionData?.success && actionData?.message && (
|
|
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
|
|
{actionData.message}
|
|
</div>
|
|
)}
|
|
|
|
{actionData?.error && (
|
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
{actionData.error}
|
|
</div>
|
|
)}
|
|
</Form>
|
|
|
|
{canEditDraftOrder && (
|
|
<Form method="post">
|
|
<input type="hidden" name="intent" value="randomize-draft-order" />
|
|
<Button
|
|
type="submit"
|
|
variant="outline"
|
|
className="w-full"
|
|
disabled={navigation.state === "submitting"}
|
|
>
|
|
{navigation.state === "submitting" && navigation.formData?.get("intent") === "randomize-draft-order"
|
|
? "Randomizing..."
|
|
: "🎲 Randomize Draft Order"}
|
|
</Button>
|
|
</Form>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Team Management */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Team Management</CardTitle>
|
|
<CardDescription>
|
|
Manage team ownership for this league
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
{teams.map((team: any) => {
|
|
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
|
|
return (
|
|
<div
|
|
key={team.id}
|
|
className="flex items-center justify-between p-3 border rounded-md"
|
|
>
|
|
<div className="flex-1">
|
|
<p className="font-medium">{team.name}</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{team.ownerId ? (
|
|
<span>Owner: {ownerName || "Unknown"}</span>
|
|
) : (
|
|
<span>No owner</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{team.ownerId && (
|
|
<Form method="post">
|
|
<input type="hidden" name="intent" value="remove-team-owner" />
|
|
<input type="hidden" name="teamId" value={team.id} />
|
|
<Button
|
|
type="submit"
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={navigation.state === "submitting"}
|
|
>
|
|
Remove Owner
|
|
</Button>
|
|
</Form>
|
|
)}
|
|
{isAdmin && (
|
|
<Form method="post" className="flex gap-2">
|
|
<input type="hidden" name="intent" value="assign-team-owner" />
|
|
<input type="hidden" name="teamId" value={team.id} />
|
|
<Select name="userClerkId" required>
|
|
<SelectTrigger className="w-[180px] h-9">
|
|
<SelectValue placeholder="Select user" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{allUsers.map((user: any) => (
|
|
<SelectItem key={user.id} value={user.clerkId}>
|
|
{user.displayName || user.username || user.email}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
type="submit"
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={navigation.state === "submitting"}
|
|
>
|
|
Assign
|
|
</Button>
|
|
</Form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Danger Zone */}
|
|
<Card className="border-destructive">
|
|
<CardHeader>
|
|
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
|
<CardDescription>
|
|
Irreversible and destructive actions
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="destructive" className="w-full">
|
|
Delete League
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will permanently delete <strong>{league.name}</strong> and all
|
|
associated data including seasons, teams, and commissioners. This
|
|
action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<Form method="post" onSubmit={handleDelete}>
|
|
<input type="hidden" name="intent" value="delete" />
|
|
<AlertDialogAction type="submit" className="bg-destructive hover:bg-destructive/90">
|
|
Delete League
|
|
</AlertDialogAction>
|
|
</Form>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|