import { Form, Link } from "react-router"; import { useState, useEffect, useMemo } from "react"; import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket"; import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.bracket.server"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "~/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "~/components/ui/select"; import { ArrowLeft, Plus, Trophy, X, Check } from "lucide-react"; import { getAllBracketTemplates, getBracketTemplate } from "~/lib/bracket-templates"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "~/components/ui/table"; import { Badge } from "~/components/ui/badge"; import { ParticipantSelector } from "~/components/ParticipantSelector"; export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { return [{ title: `Bracket — ${data?.event?.name ?? "Event"} - Brackt Admin` }]; } export { loader, action }; export default function EventBracket({ loaderData, actionData, }: Route.ComponentProps) { const { sportsSeason, event, participants, matches, tournamentGroups } = loaderData; const [selectedTemplate, setSelectedTemplate] = useState("simple_8"); const [selectedParticipants, setSelectedParticipants] = useState>({}); const [selectedWinners, setSelectedWinners] = useState>({}); const [knockoutAssignments, setKnockoutAssignments] = useState>({}); const templates = getAllBracketTemplates(); const template = templates.find((t) => t.id === selectedTemplate); // Determine if this is a group-stage event const hasGroupStage = template?.groupStage != null; const isGroupStageEvent = tournamentGroups.length > 0; // Determine the phase for group-stage events const knockoutPopulated = useMemo(() => { if (!isGroupStageEvent) return false; // Check if any Round of 32 match has participants assigned const r32Matches = matches.filter((m: any) => m.round === "Round of 32"); return r32Matches.some((m: any) => m.participant1Id || m.participant2Id); }, [isGroupStageEvent, matches]); // Group stage stats const groupStageStats = useMemo(() => { if (!isGroupStageEvent) return null; let eliminated = 0; let total = 0; for (const group of tournamentGroups) { for (const member of (group as any).members || []) { total++; if (member.eliminated) eliminated++; } } return { eliminated, advancing: total - eliminated, total }; }, [isGroupStageEvent, tournamentGroups]); // Advancing participants (non-eliminated) for knockout assignment const advancingParticipants = useMemo(() => { if (!isGroupStageEvent) return []; const advancing: Array<{ id: string; name: string }> = []; for (const group of tournamentGroups) { for (const member of (group as any).members || []) { if (!member.eliminated && member.participant) { advancing.push({ id: member.participant.id, name: member.participant.name, }); } } } return advancing; }, [isGroupStageEvent, tournamentGroups]); // Available advancing participants for knockout assignment (exclude already assigned) const availableAdvancingMap = useMemo(() => { const assignedIds = new Set(Object.values(knockoutAssignments).filter(Boolean)); const map: Record = {}; // Create 32 slots (16 matches × 2 slots) for (let m = 1; m <= 16; m++) { for (const slot of ["participant1Id", "participant2Id"]) { const key = `match-${m}-${slot}`; const currentSelection = knockoutAssignments[key]; map[key] = advancingParticipants.filter( (p) => !assignedIds.has(p.id) || p.id === currentSelection ); } } return map; }, [knockoutAssignments, advancingParticipants]); // Clear selected winners after successful batch submission useEffect(() => { if (actionData?.success) { setSelectedWinners({}); } }, [actionData?.success]); // Reset selected participants when template changes const handleTemplateChange = (newTemplateId: string) => { setSelectedTemplate(newTemplateId); setSelectedParticipants({}); }; // Update selected participant for a seed const handleParticipantChange = (seedIndex: number, participantId: string) => { setSelectedParticipants(prev => ({ ...prev, [seedIndex]: participantId })); }; // Memoized available participants calculation const availableParticipantsMap = useMemo(() => { const selectedIdsSet = new Set(Object.values(selectedParticipants)); const map: Record = {}; const totalSeeds = template?.totalTeams || 8; for (let i = 0; i < totalSeeds; i++) { const currentSelection = selectedParticipants[i]; map[i] = participants.filter((p: { id: string; name: string }) => { return !selectedIdsSet.has(p.id) || p.id === currentSelection; }); } return map; }, [selectedParticipants, participants, template?.totalTeams]); const getAvailableParticipants = (seedIndex: number) => { return availableParticipantsMap[seedIndex] || participants; }; // Update selected winner for a match const handleWinnerChange = (matchId: string, winnerId: string) => { setSelectedWinners(prev => ({ ...prev, [matchId]: winnerId })); }; // Get matches with pending winner selections in a round const getPendingWinnersInRound = (round: string) => { return Object.entries(selectedWinners) .filter(([matchId]) => { const match = matches.find((m: any) => m.id === matchId); return match && match.round === round && !match.isComplete; }); }; // Memoized: Group matches by round const matchesByRound = useMemo(() => { const grouped: Record = {}; for (const match of matches) { if (!grouped[match.round]) { grouped[match.round] = []; } grouped[match.round].push(match); } return grouped; }, [matches]); // Memoized: Get available rounds in chronological order const availableRounds = useMemo(() => { const roundsInMatches = Array.from(new Set(matches.map(m => m.round))); if (event.bracketTemplateId) { const eventTemplate = getBracketTemplate(event.bracketTemplateId); if (eventTemplate) { return eventTemplate.rounds .map((r) => r.name) .filter((roundName: string) => roundsInMatches.includes(roundName)); } } return roundsInMatches; }, [matches, event.bracketTemplateId]); // Check if all matches are complete const allMatchesComplete = useMemo(() => { return matches.length > 0 && matches.every((m: any) => m.isComplete); }, [matches]); // No bracket and no groups yet = setup phase const showSetup = matches.length === 0 && !isGroupStageEvent; return (

Playoff Bracket

{event.name} - {sportsSeason.sport.name} {sportsSeason.name}

{actionData?.error && (
{actionData.error}
)} {actionData?.success && (
{actionData.success}
)} {/* Reprocess Eliminations - For existing brackets (non-group-stage) */} {matches.length > 0 && !isGroupStageEvent && ( Reprocess Eliminations Mark participants not in this bracket as eliminated (for brackets created before automatic elimination tracking)
This will set finalPosition = 0 for all participants who are not in any playoff match, allowing probability recalculation to set their odds to 0%.
)} {/* ====== SETUP PHASE ====== */} {showSetup && ( Generate Bracket Create the bracket structure for this playoff event
{/* Intent depends on whether this is a group-stage template */}
{template && (

{template.groupStage ? ( <> Groups: {template.groupStage.groupCount} groups of {template.groupStage.teamsPerGroup} {" | "}Knockout: {template.rounds.map(r => r.name).join(" \u2192 ")} ) : ( <>Rounds: {template.rounds.map(r => r.name).join(" \u2192 ")} )}

)}
{/* Group-stage template: show group assignments */} {hasGroupStage && template?.groupStage && (

Assign {template.totalTeams} participants across {template.groupStage.groupCount} groups ({template.groupStage.teamsPerGroup} per group).

{template.groupStage.groupLabels.map((label, groupIndex) => ( Group {label} {[...Array(template.groupStage!.teamsPerGroup)].map((_, teamIndex) => { const seedIndex = groupIndex * template.groupStage!.teamsPerGroup + teamIndex; const availableParticipants = getAvailableParticipants(seedIndex); return (
handleParticipantChange(seedIndex, value)} placeholder={`Team ${teamIndex + 1}...`} />
); })}
))}
)} {/* Non-group-stage template: show seeded list */} {!hasGroupStage && (

Select {template?.totalTeams || 8} participants for the bracket. Use search to quickly find participants.

{[...Array(template?.totalTeams || 8)].map((_, i) => { const availableParticipants = getAvailableParticipants(i); return (
handleParticipantChange(i, value)} placeholder={`Select seed ${i + 1}...`} />
); })}
)}
)} {/* ====== GROUP STAGE MANAGEMENT (Phase 2) ====== */} {isGroupStageEvent && !knockoutPopulated && ( <> {/* Group Stage Summary */} {groupStageStats && ( Group Stage Manage group stage eliminations. Mark teams as eliminated, then assign advancing teams to the knockout bracket.
{groupStageStats.total} total teams {groupStageStats.eliminated} eliminated {groupStageStats.advancing} advancing
)} {/* Group Cards */}
{tournamentGroups.map((group: any) => ( Group {group.groupName} {(group.members || []).map((member: any) => (
{member.participant?.name || "Unknown"}
))}
))}
{/* Knockout Assignment Section */} {groupStageStats && groupStageStats.advancing === 32 && ( Assign Knockout Bracket 32 teams are advancing. Assign them to the Round of 32 knockout bracket slots.
{[...Array(16)].map((_, matchIndex) => { const matchNum = matchIndex + 1; const key1 = `match-${matchNum}-participant1Id`; const key2 = `match-${matchNum}-participant2Id`; return (
Match {matchNum}
setKnockoutAssignments((prev) => ({ ...prev, [key1]: value })) } placeholder="Team 1..." />
vs
setKnockoutAssignments((prev) => ({ ...prev, [key2]: value })) } placeholder="Team 2..." />
); })}
)} {groupStageStats && groupStageStats.advancing !== 32 && groupStageStats.eliminated > 0 && (

{groupStageStats.advancing} teams advancing (need exactly 32 to populate knockout bracket). {groupStageStats.advancing > 32 && ` Eliminate ${groupStageStats.advancing - 32} more.`} {groupStageStats.advancing < 32 && ` Reinstate ${32 - groupStageStats.advancing} teams or adjust eliminations.`}

)} )} {/* ====== KNOCKOUT BRACKET DISPLAY (Phase 3) ====== */} {/* Show bracket rounds when: non-group event with matches, OR group event with knockout populated */} {((matches.length > 0 && !isGroupStageEvent) || knockoutPopulated) && ( <> {availableRounds.map((round: string) => ( {round} {matchesByRound[round].length} {matchesByRound[round].length === 1 ? "match" : "matches"} Match Participant 1 Score vs Score Participant 2 Winner Actions {matchesByRound[round].map((match) => ( {match.matchNumber} {match.participant1?.name || "TBD"} {match.participant1Score ? parseFloat(match.participant1Score) : "-"} vs {match.participant2Score ? parseFloat(match.participant2Score) : "-"} {match.participant2?.name || "TBD"} {match.winner?.name ? (
{match.winner.name}
) : ( "-" )}
{!match.isComplete && match.participant1Id && match.participant2Id ? ( ) : match.isComplete ? ( Complete ) : ( Waiting )}
))}
{/* Batch Submit Winners */} {getPendingWinnersInRound(round).length > 0 && (
{getPendingWinnersInRound(round).map(([matchId, winnerId]) => ( ))}
)}
))} {/* Complete Round Button */} {availableRounds.length > 0 && !event.isComplete && ( Complete Round Finalize the current round and calculate placements
)} {/* Finalize Bracket Button */} {allMatchesComplete && !event.isComplete && ( Finalize Bracket All matches are complete! Process all rounds and assign final placements.

This will:

  • Process all playoff rounds in order
  • Assign fantasy placements (1st-8th) based on bracket results
  • Assign 0 points to participants not in the bracket
  • Mark the event as complete
  • Recalculate all team standings
)} )} {/* Event Complete Badge */} {event.isComplete && (
Event Complete {event.completedAt && new Date(event.completedAt).toLocaleDateString()}
)}
); }