From f83a7d69c8a8999226331a384da8aae4af4e5af4 Mon Sep 17 00:00:00 2001 From: yohlo Date: Wed, 25 Feb 2026 19:54:51 -0600 Subject: [PATCH] groups init --- src/app/routeTree.gen.ts | 22 ++ .../_authed/admin/tournaments/run.$id.tsx | 36 +- .../_authed/tournaments/$id.bracket.tsx | 7 +- .../routes/_authed/tournaments/$id.groups.tsx | 45 +++ .../components/group-match-card.tsx | 201 ++++++++++ .../tournaments/components/group-preview.tsx | 50 +++ .../components/group-stage-view.tsx | 372 ++++++++++++++++++ .../components/setup-group-stage.tsx | 182 +++++++++ .../components/started-tournament/index.tsx | 13 +- .../components/tournament-stats.tsx | 13 +- src/features/tournaments/server.ts | 131 +++++- src/features/tournaments/types.ts | 9 + .../tournaments/utils/group-config.ts | 167 ++++++++ src/lib/pocketbase/client.ts | 5 +- src/lib/pocketbase/services/groups.ts | 46 +++ src/lib/pocketbase/services/tournaments.ts | 8 +- src/lib/pocketbase/util/transform-types.ts | 16 + 17 files changed, 1306 insertions(+), 17 deletions(-) create mode 100644 src/app/routes/_authed/tournaments/$id.groups.tsx create mode 100644 src/features/tournaments/components/group-match-card.tsx create mode 100644 src/features/tournaments/components/group-preview.tsx create mode 100644 src/features/tournaments/components/group-stage-view.tsx create mode 100644 src/features/tournaments/components/setup-group-stage.tsx create mode 100644 src/features/tournaments/utils/group-config.ts create mode 100644 src/lib/pocketbase/services/groups.ts diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index 2732ee2..6ea2f8c 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -38,6 +38,7 @@ import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/p import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges' import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities' import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index' +import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups' import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket' import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index' import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file' @@ -192,6 +193,12 @@ const AuthedAdminTournamentsIndexRoute = path: '/tournaments/', getParentRoute: () => AuthedAdminRoute, } as any) +const AuthedTournamentsIdGroupsRoute = + AuthedTournamentsIdGroupsRouteImport.update({ + id: '/tournaments/$id/groups', + path: '/tournaments/$id/groups', + getParentRoute: () => AuthedRoute, + } as any) const AuthedTournamentsIdBracketRoute = AuthedTournamentsIdBracketRouteImport.update({ id: '/tournaments/$id/bracket', @@ -258,6 +265,7 @@ export interface FileRoutesByFullPath { '/admin/': typeof AuthedAdminIndexRoute '/tournaments/': typeof AuthedTournamentsIndexRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute + '/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute @@ -293,6 +301,7 @@ export interface FileRoutesByTo { '/admin': typeof AuthedAdminIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute + '/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute @@ -331,6 +340,7 @@ export interface FileRoutesById { '/_authed/admin/': typeof AuthedAdminIndexRoute '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute '/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute + '/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute @@ -369,6 +379,7 @@ export interface FileRouteTypes { | '/admin/' | '/tournaments/' | '/tournaments/$id/bracket' + | '/tournaments/$id/groups' | '/admin/tournaments/' | '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/teams' @@ -404,6 +415,7 @@ export interface FileRouteTypes { | '/admin' | '/tournaments' | '/tournaments/$id/bracket' + | '/tournaments/$id/groups' | '/admin/tournaments' | '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/teams' @@ -441,6 +453,7 @@ export interface FileRouteTypes { | '/_authed/admin/' | '/_authed/tournaments/' | '/_authed/tournaments/$id/bracket' + | '/_authed/tournaments/$id/groups' | '/_authed/admin/tournaments/' | '/_authed/admin/tournaments/$id/assign-partners' | '/_authed/admin/tournaments/$id/teams' @@ -673,6 +686,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport parentRoute: typeof AuthedAdminRoute } + '/_authed/tournaments/$id/groups': { + id: '/_authed/tournaments/$id/groups' + path: '/tournaments/$id/groups' + fullPath: '/tournaments/$id/groups' + preLoaderRoute: typeof AuthedTournamentsIdGroupsRouteImport + parentRoute: typeof AuthedRoute + } '/_authed/tournaments/$id/bracket': { id: '/_authed/tournaments/$id/bracket' path: '/tournaments/$id/bracket' @@ -758,6 +778,7 @@ interface AuthedRouteChildren { AuthedTournamentsTournamentIdRoute: typeof AuthedTournamentsTournamentIdRoute AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute + AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute } const AuthedRouteChildren: AuthedRouteChildren = { @@ -771,6 +792,7 @@ const AuthedRouteChildren: AuthedRouteChildren = { AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute, AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute, AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute, + AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute, } const AuthedRouteWithChildren = diff --git a/src/app/routes/_authed/admin/tournaments/run.$id.tsx b/src/app/routes/_authed/admin/tournaments/run.$id.tsx index 3de4ccf..d7e937b 100644 --- a/src/app/routes/_authed/admin/tournaments/run.$id.tsx +++ b/src/app/routes/_authed/admin/tournaments/run.$id.tsx @@ -5,6 +5,8 @@ import { } from "@/features/tournaments/queries"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import SeedTournament from "@/features/tournaments/components/seed-tournament"; +import SetupGroupStage from "@/features/tournaments/components/setup-group-stage"; +import GroupStageView from "@/features/tournaments/components/group-stage-view"; import { Container } from "@mantine/core"; import { useMemo } from "react"; import { BracketData } from "@/features/bracket/types"; @@ -43,6 +45,10 @@ function RouteComponent() { const { roles } = useAuth(); const isAdmin = roles?.includes('Admin') || false; + const isGroupStage = useMemo(() => { + return tournament.matches?.some((match) => match.round === -1) || false; + }, [tournament.matches]); + const bracket: BracketData = useMemo(() => { if (!tournament.matches || tournament.matches.length === 0) { return { winners: [], losers: [] }; @@ -52,6 +58,7 @@ function RouteComponent() { const losersMap = new Map(); tournament.matches + .filter((match) => match.round !== -1) .sort((a, b) => a.lid - b.lid) .forEach((match) => { if (!match.is_losers_bracket) { @@ -79,15 +86,30 @@ function RouteComponent() { return ( - { isAdmin && } + { isAdmin && !tournament.regional && } {tournament.matches?.length ? ( - + isGroupStage ? ( + + ) : ( + + ) ) : ( - + tournament.regional === true ? ( + + ) : ( + + ) )} ); diff --git a/src/app/routes/_authed/tournaments/$id.bracket.tsx b/src/app/routes/_authed/tournaments/$id.bracket.tsx index 094c39d..a562102 100644 --- a/src/app/routes/_authed/tournaments/$id.bracket.tsx +++ b/src/app/routes/_authed/tournaments/$id.bracket.tsx @@ -4,7 +4,6 @@ import { useTournament, } from "@/features/tournaments/queries"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; -import SeedTournament from "@/features/tournaments/components/seed-tournament"; import { Container } from "@mantine/core"; import { useMemo } from "react"; import { BracketData } from "@/features/bracket/types"; @@ -18,7 +17,7 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({ queryClient, tournamentQueries.details(params.id) ); - if (!tournament) throw redirect({ to: "/admin/tournaments" }); + if (!tournament) throw redirect({ to: "/tournaments" }); return { tournament, }; @@ -26,10 +25,9 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({ loader: ({ context }) => ({ fullWidth: true, withPadding: false, - showSpotifyPanel: true, header: { withBackButton: true, - title: `${context.tournament.name}`, + title: `${context.tournament.name} - Bracket`, }, }), component: RouteComponent, @@ -48,6 +46,7 @@ function RouteComponent() { const losersMap = new Map(); tournament.matches + .filter((match) => match.round !== -1) .sort((a, b) => a.lid - b.lid) .forEach((match) => { if (!match.is_losers_bracket) { diff --git a/src/app/routes/_authed/tournaments/$id.groups.tsx b/src/app/routes/_authed/tournaments/$id.groups.tsx new file mode 100644 index 0000000..194406e --- /dev/null +++ b/src/app/routes/_authed/tournaments/$id.groups.tsx @@ -0,0 +1,45 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { + tournamentQueries, + useTournament, +} from "@/features/tournaments/queries"; +import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; +import GroupStageView from "@/features/tournaments/components/group-stage-view"; +import { Container } from "@mantine/core"; + +export const Route = createFileRoute("/_authed/tournaments/$id/groups")({ + beforeLoad: async ({ context, params }) => { + const { queryClient } = context; + const tournament = await ensureServerQueryData( + queryClient, + tournamentQueries.details(params.id) + ); + if (!tournament) throw redirect({ to: "/tournaments" }); + return { + tournament, + }; + }, + loader: ({ context }) => ({ + fullWidth: true, + withPadding: false, + header: { + withBackButton: true, + title: `${context.tournament.name} - Groups`, + }, + }), + component: RouteComponent, +}); + +function RouteComponent() { + const { id } = Route.useParams(); + const { data: tournament } = useTournament(id); + + return ( + + + + ); +} diff --git a/src/features/tournaments/components/group-match-card.tsx b/src/features/tournaments/components/group-match-card.tsx new file mode 100644 index 0000000..37f2eb5 --- /dev/null +++ b/src/features/tournaments/components/group-match-card.tsx @@ -0,0 +1,201 @@ +import React, { useCallback } from "react"; +import { Card, Group, Stack, Text, ActionIcon, Indicator, Flex, Box } from "@mantine/core"; +import { PlayIcon, PencilIcon } from "@phosphor-icons/react"; +import { Match } from "@/features/matches/types"; +import { useSheet } from "@/hooks/use-sheet"; +import Sheet from "@/components/sheet/sheet"; +import { useServerMutation } from "@/lib/tanstack-query/hooks"; +import { endMatch, startMatch } from "@/features/matches/server"; +import { tournamentKeys } from "@/features/tournaments/queries"; +import { useQueryClient } from "@tanstack/react-query"; +import { MatchForm } from "@/features/bracket/components/match-form"; +import TeamAvatar from "@/components/team-avatar"; + +interface GroupMatchCardProps { + match: Match; + showControls?: boolean; +} + +const GroupMatchCard: React.FC = ({ match, showControls }) => { + const queryClient = useQueryClient(); + const editSheet = useSheet(); + + const isReady = match.status === "ready"; + const isStarted = match.status === "started"; + const isEnded = match.status === "ended"; + + const homeWon = isEnded && match.home_cups !== undefined && match.away_cups !== undefined && match.home_cups > match.away_cups; + const awayWon = isEnded && match.away_cups !== undefined && match.home_cups !== undefined && match.away_cups > match.home_cups; + + const start = useServerMutation({ + mutationFn: startMatch, + successMessage: "Match started!", + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: tournamentKeys.details(match.tournament.id), + }); + }, + }); + + const end = useServerMutation({ + mutationFn: endMatch, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: tournamentKeys.details(match.tournament.id), + }); + editSheet.close(); + }, + }); + + const handleFormSubmit = useCallback( + async (data: { + home_cups: number; + away_cups: number; + ot_count: number; + }) => { + end.mutate({ + data: { + ...data, + matchId: match.id, + }, + }); + }, + [end, match.id] + ); + + const handleStartMatch = () => { + start.mutate({ data: match.id }); + }; + + const showStartButton = isReady && showControls; + const showEditButton = isStarted && showControls; + + return ( + <> + + + + + + + + + {match.home?.name || "TBD"} + + + {isEnded && match.home_cups !== undefined && ( + + {match.home_cups} + + )} + + + + + + + + + {match.away?.name || "TBD"} + + + {isEnded && match.away_cups !== undefined && ( + + {match.away_cups} + + )} + + + + + + {showStartButton && ( + + + + )} + + {showEditButton && ( + + + + )} + + + {showControls && ( + + + + )} + + ); +}; + +export default GroupMatchCard; diff --git a/src/features/tournaments/components/group-preview.tsx b/src/features/tournaments/components/group-preview.tsx new file mode 100644 index 0000000..afb944a --- /dev/null +++ b/src/features/tournaments/components/group-preview.tsx @@ -0,0 +1,50 @@ +import { Stack, Text, Card, Group, Box } from "@mantine/core"; +import { TeamInfo } from "@/features/teams/types"; +import TeamAvatar from "@/components/team-avatar"; + +interface GroupAssignment { + groupIndex: number; + groupName: string; + teams: TeamInfo[]; +} + +interface GroupPreviewProps { + groups: GroupAssignment[]; +} + +const GroupPreview: React.FC = ({ groups }) => { + return ( + + {groups.map((group) => ( + + + + + Group {group.groupName} + + + ({group.teams.length} teams) + + + + + {group.teams.map((team, index) => ( + + + {index + 1} + + + + {team.name} + + + ))} + + + + ))} + + ); +}; + +export default GroupPreview; diff --git a/src/features/tournaments/components/group-stage-view.tsx b/src/features/tournaments/components/group-stage-view.tsx new file mode 100644 index 0000000..9e13876 --- /dev/null +++ b/src/features/tournaments/components/group-stage-view.tsx @@ -0,0 +1,372 @@ +import React, { useMemo, useState } from "react"; +import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon } from "@mantine/core"; +import { CaretCircleDown, CaretCircleUp } from "@phosphor-icons/react"; +import { Match } from "@/features/matches/types"; +import { Group } from "../types"; +import GroupMatchCard from "./group-match-card"; +import TeamAvatar from "@/components/team-avatar"; + +interface GroupStageViewProps { + groups: Group[]; + matches: Match[]; + showControls?: boolean; +} + +interface TeamStanding { + teamId: string; + teamName: string; + team: any; + wins: number; + losses: number; + cupDifference: number; +} + +const GroupStageView: React.FC = ({ + groups, + matches, + showControls, +}) => { + const [expandedTeams, setExpandedTeams] = useState>({}); + + const orderMatchesWithSpacing = (matches: Match[]): Match[] => { + if (matches.length <= 1) return matches; + + const ordered: Match[] = []; + const remaining = [...matches]; + + ordered.push(remaining.shift()!); + + while (remaining.length > 0) { + const lastMatch = ordered[ordered.length - 1]; + const lastTeams = new Set([lastMatch.home?.id, lastMatch.away?.id].filter(Boolean)); + + let bestMatchIndex = remaining.findIndex((match) => { + const currentTeams = new Set([match.home?.id, match.away?.id].filter(Boolean)); + for (const teamId of currentTeams) { + if (lastTeams.has(teamId)) return false; + } + return true; + }); + + if (bestMatchIndex === -1) { + bestMatchIndex = 0; + } + + ordered.push(remaining.splice(bestMatchIndex, 1)[0]); + } + + return ordered; + }; + + const matchesByGroup = useMemo(() => { + const map = new Map(); + + matches + .filter((match) => match.round === -1 && match.group) + .forEach((match) => { + if (!map.has(match.group!)) { + map.set(match.group!, []); + } + map.get(match.group!)!.push(match); + }); + + map.forEach((groupMatches, groupId) => { + map.set(groupId, orderMatchesWithSpacing(groupMatches)); + }); + + return map; + }, [matches]); + + const sortedGroups = useMemo(() => { + return [...groups].sort((a, b) => a.order - b.order); + }, [groups]); + + const toggleTeams = (groupId: string) => { + setExpandedTeams((prev) => ({ + ...prev, + [groupId]: !prev[groupId], + })); + }; + + const getTeamStandings = (groupId: string, teams: any[]): TeamStanding[] => { + const groupMatches = matchesByGroup.get(groupId) || []; + const standings: Map = new Map(); + + teams.forEach((team) => { + standings.set(team.id, { + teamId: team.id, + teamName: team.name, + team: team, + wins: 0, + losses: 0, + cupDifference: 0, + }); + }); + + groupMatches + .filter((match) => match.status === "ended") + .forEach((match) => { + const homeId = match.home?.id; + const awayId = match.away?.id; + + if (!homeId || !awayId) return; + + const homeStanding = standings.get(homeId); + const awayStanding = standings.get(awayId); + + if (!homeStanding || !awayStanding) return; + + const homeCups = match.home_cups || 0; + const awayCups = match.away_cups || 0; + + homeStanding.cupDifference += homeCups - awayCups; + awayStanding.cupDifference += awayCups - homeCups; + + if (homeCups > awayCups) { + homeStanding.wins++; + awayStanding.losses++; + } else if (awayCups > homeCups) { + awayStanding.wins++; + homeStanding.losses++; + } + }); + + return Array.from(standings.values()).sort((a, b) => { + if (b.wins !== a.wins) return b.wins - a.wins; + return b.cupDifference - a.cupDifference; + }); + }; + + if (sortedGroups.length === 0) { + return ( + + + No groups have been created yet + + + ); + } + + if (sortedGroups.length === 1) { + const group = sortedGroups[0]; + const groupMatches = matchesByGroup.get(group.id) || []; + const standings = getTeamStandings(group.id, group.teams || []); + + return ( + + + + toggleTeams(group.id)} + > + + Standings ({standings.length}) + + + {expandedTeams[group.id] ? : } + + + + + {standings.length > 0 ? ( + standings.map((standing, index) => ( + 0 ? '1px solid var(--mantine-color-default-border)' : 'none', + }} + > + + {index + 1} + + + + {standing.teamName} + + + + {standing.wins}-{standing.losses} + + 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"} + fw={600} + > + {standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference} + + + + )) + ) : ( + + No teams assigned + + )} + + + + + {groupMatches.length === 0 ? ( + + + No matches scheduled + + + ) : ( + + {groupMatches.map((match) => ( + + ))} + + )} + + + ); + } + + return ( + + + + {sortedGroups.map((group) => { + const groupMatches = matchesByGroup.get(group.id) || []; + const completedMatches = groupMatches.filter((m) => m.status === "ended").length; + const totalMatches = groupMatches.length; + + return ( + + + Group {group.name} + + ({completedMatches}/{totalMatches}) + + + + ); + })} + + + {sortedGroups.map((group) => { + const groupMatches = matchesByGroup.get(group.id) || []; + const standings = getTeamStandings(group.id, group.teams || []); + + return ( + + + + toggleTeams(group.id)} + > + + Standings ({standings.length}) + + + {expandedTeams[group.id] ? : } + + + + + {standings.length > 0 ? ( + standings.map((standing, index) => ( + 0 ? '1px solid var(--mantine-color-default-border)' : 'none', + }} + > + + {index + 1} + + + + {standing.teamName} + + + + {standing.wins}-{standing.losses} + + 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"} + fw={600} + > + {standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference} + + + + )) + ) : ( + + No teams assigned + + )} + + + + + {/* Matches Grid */} + {groupMatches.length === 0 ? ( + + + No matches scheduled + + + ) : ( + + {groupMatches.map((match) => ( + + ))} + + )} + + + ); + })} + + + ); +}; + +export default GroupStageView; diff --git a/src/features/tournaments/components/setup-group-stage.tsx b/src/features/tournaments/components/setup-group-stage.tsx new file mode 100644 index 0000000..42b2295 --- /dev/null +++ b/src/features/tournaments/components/setup-group-stage.tsx @@ -0,0 +1,182 @@ +import React, { useState, useMemo } from "react"; +import { + Stack, + Text, + Group, + Button, + Select, + LoadingOverlay, + Alert, +} from "@mantine/core"; +import { InfoIcon } from "@phosphor-icons/react"; +import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation"; +import { generateGroupStage } from "../server"; +import { TeamInfo } from "@/features/teams/types"; +import { + calculateGroupConfigurations, + assignTeamsToGroups, + getGroupName, + GroupConfigOption, +} from "../utils/group-config"; +import GroupPreview from "./group-preview"; +import { useQueryClient } from "@tanstack/react-query"; +import { tournamentKeys } from "../queries"; + +interface SetupGroupStageProps { + tournamentId: string; + teams: TeamInfo[]; +} + +const SetupGroupStage: React.FC = ({ + tournamentId, + teams, +}) => { + const queryClient = useQueryClient(); + + const [selectedConfigIndex, setSelectedConfigIndex] = useState("0"); + const [seed, setSeed] = useState(Date.now()); + + const configurations = useMemo(() => { + try { + return calculateGroupConfigurations(teams.length); + } catch (error) { + return []; + } + }, [teams.length]); + + const selectedConfig: GroupConfigOption | null = useMemo(() => { + const index = parseInt(selectedConfigIndex); + return configurations[index] || null; + }, [selectedConfigIndex, configurations]); + + const groupAssignments = useMemo(() => { + if (!selectedConfig) return []; + + const teamIds = teams.map((t) => t.id); + const assignments = assignTeamsToGroups(teamIds, selectedConfig, seed); + + return assignments.map((teamIds, index) => ({ + groupIndex: index, + groupName: getGroupName(index), + teams: teamIds.map((id) => teams.find((t) => t.id === id)!).filter(Boolean), + teamIds, + })); + }, [selectedConfig, teams, seed]); + + const generateGroups = useServerMutation({ + mutationFn: generateGroupStage, + successMessage: "Group stage generated successfully!", + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: tournamentKeys.details(tournamentId), + }); + }, + }); + + const handleGenerateGroups = () => { + if (!selectedConfig) return; + + generateGroups.mutate({ + data: { + tournamentId, + groupConfig: { + num_groups: selectedConfig.num_groups, + teams_per_group: selectedConfig.teams_per_group, + advance_per_group: selectedConfig.advance_per_group, + matches_guaranteed: selectedConfig.matches_guaranteed, + seeding_method: selectedConfig.seeding_method, + }, + teamAssignments: groupAssignments.map((g) => ({ + groupIndex: g.groupIndex, + groupName: g.groupName, + teamIds: g.teamIds, + })), + seed, + }, + }); + }; + + const handleShuffle = () => { + setSeed(Date.now()); + }; + + if (configurations.length === 0) { + return ( + }> + Need at least 4 teams to create a group stage format. Current team count: {teams.length} + + ); + } + + return ( +
+ + + + + + + Group Stage Setup + + + {teams.length} teams + + + + + + + Group Configuration + +