groups init
This commit is contained in:
201
src/features/tournaments/components/group-match-card.tsx
Normal file
201
src/features/tournaments/components/group-match-card.tsx
Normal file
@@ -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<GroupMatchCardProps> = ({ 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 (
|
||||
<>
|
||||
<Flex direction="row" align="stretch">
|
||||
<Indicator
|
||||
inline
|
||||
processing={isStarted}
|
||||
color="red"
|
||||
size={16}
|
||||
disabled={!isStarted || showEditButton}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<TeamAvatar team={match.home} size={32} radius="sm" />
|
||||
<Text
|
||||
size="sm"
|
||||
fw={homeWon ? 700 : 500}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
lineClamp={1}
|
||||
>
|
||||
{match.home?.name || "TBD"}
|
||||
</Text>
|
||||
</Group>
|
||||
{isEnded && match.home_cups !== undefined && (
|
||||
<Text
|
||||
size="xl"
|
||||
fw={700}
|
||||
c={homeWon ? "green" : "dimmed"}
|
||||
style={{ minWidth: 32, textAlign: 'center' }}
|
||||
>
|
||||
{match.home_cups}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
height: 1,
|
||||
backgroundColor: 'var(--mantine-color-default-border)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<TeamAvatar team={match.away} size={32} radius="sm" />
|
||||
<Text
|
||||
size="sm"
|
||||
fw={awayWon ? 700 : 500}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
lineClamp={1}
|
||||
>
|
||||
{match.away?.name || "TBD"}
|
||||
</Text>
|
||||
</Group>
|
||||
{isEnded && match.away_cups !== undefined && (
|
||||
<Text
|
||||
size="xl"
|
||||
fw={700}
|
||||
c={awayWon ? "green" : "dimmed"}
|
||||
style={{ minWidth: 32, textAlign: 'center' }}
|
||||
>
|
||||
{match.away_cups}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Indicator>
|
||||
|
||||
{showStartButton && (
|
||||
<ActionIcon
|
||||
color="green"
|
||||
onClick={handleStartMatch}
|
||||
loading={start.isPending}
|
||||
size="md"
|
||||
h="100%"
|
||||
radius="sm"
|
||||
ml={-4}
|
||||
style={{
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
}}
|
||||
>
|
||||
<PlayIcon size={16} weight="fill" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
{showEditButton && (
|
||||
<ActionIcon
|
||||
color="blue"
|
||||
onClick={editSheet.open}
|
||||
size="md"
|
||||
h="100%"
|
||||
radius="sm"
|
||||
ml={-4}
|
||||
style={{
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
}}
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{showControls && (
|
||||
<Sheet title="End Match" opened={editSheet.isOpen} onChange={editSheet.toggle}>
|
||||
<MatchForm
|
||||
match={match}
|
||||
onSubmit={handleFormSubmit}
|
||||
isPending={end.isPending}
|
||||
/>
|
||||
</Sheet>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupMatchCard;
|
||||
50
src/features/tournaments/components/group-preview.tsx
Normal file
50
src/features/tournaments/components/group-preview.tsx
Normal file
@@ -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<GroupPreviewProps> = ({ groups }) => {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{groups.map((group) => (
|
||||
<Card key={group.groupIndex} withBorder radius="md" p="md" w="fit-content">
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="sm">
|
||||
Group {group.groupName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
({group.teams.length} teams)
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Stack gap="xs">
|
||||
{group.teams.map((team, index) => (
|
||||
<Group key={team.id} gap="sm" align="center" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" w={20} ta="right">
|
||||
{index + 1}
|
||||
</Text>
|
||||
<TeamAvatar team={team} size={24} radius="sm" isRegional />
|
||||
<Text size="sm" truncate style={{ flex: 1 }}>
|
||||
{team.name}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupPreview;
|
||||
372
src/features/tournaments/components/group-stage-view.tsx
Normal file
372
src/features/tournaments/components/group-stage-view.tsx
Normal file
@@ -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<GroupStageViewProps> = ({
|
||||
groups,
|
||||
matches,
|
||||
showControls,
|
||||
}) => {
|
||||
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
|
||||
|
||||
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<string, Match[]>();
|
||||
|
||||
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<string, TeamStanding> = 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 (
|
||||
<Box p="md">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No groups have been created yet
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (sortedGroups.length === 1) {
|
||||
const group = sortedGroups[0];
|
||||
const groupMatches = matchesByGroup.get(group.id) || [];
|
||||
const standings = getTeamStandings(group.id, group.teams || []);
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" p={0}>
|
||||
<MantineGroup
|
||||
justify="space-between"
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
}}
|
||||
onClick={() => toggleTeams(group.id)}
|
||||
>
|
||||
<Text fw={600} size="sm">
|
||||
Standings ({standings.length})
|
||||
</Text>
|
||||
<ActionIcon variant="subtle" size="sm">
|
||||
{expandedTeams[group.id] ? <CaretCircleUp size={16} /> : <CaretCircleDown size={16} />}
|
||||
</ActionIcon>
|
||||
</MantineGroup>
|
||||
<Collapse in={expandedTeams[group.id]}>
|
||||
<Stack gap={0}>
|
||||
{standings.length > 0 ? (
|
||||
standings.map((standing, index) => (
|
||||
<MantineGroup
|
||||
key={standing.teamId}
|
||||
gap="sm"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px="md"
|
||||
py="xs"
|
||||
style={{
|
||||
borderTop: index > 0 ? '1px solid var(--mantine-color-default-border)' : 'none',
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={700} c="dimmed" w={24} ta="center">
|
||||
{index + 1}
|
||||
</Text>
|
||||
<TeamAvatar team={standing.team} size={28} radius="sm" />
|
||||
<Text size="sm" fw={500} style={{ flex: 1 }} lineClamp={1}>
|
||||
{standing.teamName}
|
||||
</Text>
|
||||
<MantineGroup gap="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
{standing.wins}-{standing.losses}
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
c={standing.cupDifference > 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"}
|
||||
fw={600}
|
||||
>
|
||||
{standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference}
|
||||
</Text>
|
||||
</MantineGroup>
|
||||
</MantineGroup>
|
||||
))
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
No teams assigned
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Card>
|
||||
|
||||
{groupMatches.length === 0 ? (
|
||||
<Card withBorder radius="md" p="xl">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No matches scheduled
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, sm: 2, lg: 3 }}
|
||||
spacing="md"
|
||||
>
|
||||
{groupMatches.map((match) => (
|
||||
<GroupMatchCard
|
||||
key={match.id}
|
||||
match={match}
|
||||
showControls={showControls}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
<Tabs defaultValue={sortedGroups[0]?.id}>
|
||||
<Tabs.List mb="md" grow>
|
||||
{sortedGroups.map((group) => {
|
||||
const groupMatches = matchesByGroup.get(group.id) || [];
|
||||
const completedMatches = groupMatches.filter((m) => m.status === "ended").length;
|
||||
const totalMatches = groupMatches.length;
|
||||
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={group.id}
|
||||
value={group.id}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
}}
|
||||
>
|
||||
<MantineGroup gap="xs" justify="center" wrap="nowrap">
|
||||
<Text fw={600} size="sm">Group {group.name}</Text>
|
||||
<Text size="xs" c="dimmed" style={{ opacity: 0.7 }}>
|
||||
({completedMatches}/{totalMatches})
|
||||
</Text>
|
||||
</MantineGroup>
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
|
||||
{sortedGroups.map((group) => {
|
||||
const groupMatches = matchesByGroup.get(group.id) || [];
|
||||
const standings = getTeamStandings(group.id, group.teams || []);
|
||||
|
||||
return (
|
||||
<Tabs.Panel key={group.id} value={group.id}>
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" p={0}>
|
||||
<MantineGroup
|
||||
justify="space-between"
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
}}
|
||||
onClick={() => toggleTeams(group.id)}
|
||||
>
|
||||
<Text fw={600} size="sm">
|
||||
Standings ({standings.length})
|
||||
</Text>
|
||||
<ActionIcon variant="subtle" size="sm">
|
||||
{expandedTeams[group.id] ? <CaretCircleUp size={16} /> : <CaretCircleDown size={16} />}
|
||||
</ActionIcon>
|
||||
</MantineGroup>
|
||||
<Collapse in={expandedTeams[group.id]}>
|
||||
<Stack gap={0}>
|
||||
{standings.length > 0 ? (
|
||||
standings.map((standing, index) => (
|
||||
<MantineGroup
|
||||
key={standing.teamId}
|
||||
gap="sm"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px="md"
|
||||
py="xs"
|
||||
style={{
|
||||
borderTop: index > 0 ? '1px solid var(--mantine-color-default-border)' : 'none',
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={700} c="dimmed" w={24} ta="center">
|
||||
{index + 1}
|
||||
</Text>
|
||||
<TeamAvatar team={standing.team} size={28} radius="sm" />
|
||||
<Text size="sm" fw={500} style={{ flex: 1 }} lineClamp={1}>
|
||||
{standing.teamName}
|
||||
</Text>
|
||||
<MantineGroup gap="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
{standing.wins}-{standing.losses}
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
c={standing.cupDifference > 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"}
|
||||
fw={600}
|
||||
>
|
||||
{standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference}
|
||||
</Text>
|
||||
</MantineGroup>
|
||||
</MantineGroup>
|
||||
))
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
No teams assigned
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Card>
|
||||
|
||||
{/* Matches Grid */}
|
||||
{groupMatches.length === 0 ? (
|
||||
<Card withBorder radius="md" p="xl">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No matches scheduled
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, sm: 2, lg: 3 }}
|
||||
spacing="md"
|
||||
>
|
||||
{groupMatches.map((match) => (
|
||||
<GroupMatchCard
|
||||
key={match.id}
|
||||
match={match}
|
||||
showControls={showControls}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupStageView;
|
||||
182
src/features/tournaments/components/setup-group-stage.tsx
Normal file
182
src/features/tournaments/components/setup-group-stage.tsx
Normal file
@@ -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<SetupGroupStageProps> = ({
|
||||
tournamentId,
|
||||
teams,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selectedConfigIndex, setSelectedConfigIndex] = useState<string>("0");
|
||||
const [seed, setSeed] = useState<number>(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 (
|
||||
<Alert color="red" title="Cannot create groups" icon={<InfoIcon />}>
|
||||
Need at least 4 teams to create a group stage format. Current team count: {teams.length}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", gap: "2rem", alignItems: "flex-start" }}>
|
||||
<Stack gap="lg" style={{ flexShrink: 0, width: 400 }}>
|
||||
<Stack gap={0} pos="relative">
|
||||
<LoadingOverlay visible={generateGroups.isPending} />
|
||||
|
||||
<Group gap="xs" p="md" pb="sm" align="center">
|
||||
<Text fw={600} size="lg">
|
||||
Group Stage Setup
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ml="auto">
|
||||
{teams.length} teams
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Stack gap="md" p="md" pt={0}>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
Group Configuration
|
||||
</Text>
|
||||
<Select
|
||||
value={selectedConfigIndex}
|
||||
onChange={(value) => setSelectedConfigIndex(value || "0")}
|
||||
data={configurations.map((config, index) => ({
|
||||
value: index.toString(),
|
||||
label: config.description,
|
||||
}))}
|
||||
styles={{
|
||||
dropdown: { maxHeight: 300, overflowY: "auto" },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{selectedConfig && (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{selectedConfig.total_group_matches} total group stage matches
|
||||
</Text>
|
||||
{selectedConfig.wildcards_needed > 0 && (
|
||||
<Text size="xs" c="yellow">
|
||||
⚠ {selectedConfig.wildcards_needed} wildcard spot{selectedConfig.wildcards_needed > 1 ? 's' : ''} needed for knockout bracket
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Button size="sm" variant="light" onClick={handleShuffle}>
|
||||
Shuffle Groups
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleGenerateGroups}
|
||||
loading={generateGroups.isPending}
|
||||
disabled={!selectedConfig}
|
||||
>
|
||||
Confirm Setup
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", maxHeight: "80vh" }}>
|
||||
{groupAssignments.length > 0 && (
|
||||
<GroupPreview groups={groupAssignments} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupGroupStage;
|
||||
@@ -4,7 +4,7 @@ import { useAuth } from "@/contexts/auth-context";
|
||||
import { Box, Divider, Stack, Text, Card, Center } from "@mantine/core";
|
||||
import { Carousel } from "@mantine/carousel";
|
||||
import ListLink from "@/components/list-link";
|
||||
import { TreeStructureIcon, UsersIcon, ClockIcon, TrophyIcon } from "@phosphor-icons/react";
|
||||
import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react";
|
||||
import TeamListButton from "../upcoming-tournament/team-list-button";
|
||||
import RulesListButton from "../upcoming-tournament/rules-list-button";
|
||||
import MatchCard from "@/features/matches/components/match-card";
|
||||
@@ -37,6 +37,10 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
return finalsMatch?.status === 'ended';
|
||||
}, [tournament.matches]);
|
||||
|
||||
const hasGroupStage = useMemo(() => {
|
||||
return tournament.matches?.some((match) => match.round === -1) || false;
|
||||
}, [tournament.matches]);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Header tournament={tournament} />
|
||||
@@ -83,6 +87,13 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
Icon={UsersIcon}
|
||||
/>
|
||||
)}
|
||||
{hasGroupStage && (
|
||||
<ListLink
|
||||
label={`View Groups`}
|
||||
to={`/tournaments/${tournament.id}/groups`}
|
||||
Icon={ListDashes}
|
||||
/>
|
||||
)}
|
||||
<ListLink
|
||||
label={`View Bracket`}
|
||||
to={`/tournaments/${tournament.id}/bracket`}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { Tournament } from "@/features/tournaments/types";
|
||||
import { CrownIcon, TreeStructureIcon, InfoIcon } from "@phosphor-icons/react";
|
||||
import { CrownIcon, TreeStructureIcon, InfoIcon, ListDashes } from "@phosphor-icons/react";
|
||||
import Avatar from "@/components/avatar";
|
||||
import ListLink from "@/components/list-link";
|
||||
import { Podium } from "./podium";
|
||||
@@ -33,6 +33,10 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
||||
[nonByeMatches]
|
||||
);
|
||||
|
||||
const hasGroupStage = useMemo(() => {
|
||||
return tournament.matches?.some((match) => match.round === -1) || false;
|
||||
}, [tournament.matches]);
|
||||
|
||||
const sortedTeamStats = useMemo(() => {
|
||||
return [...(tournament.team_stats || [])].sort((a, b) => {
|
||||
if (b.wins !== a.wins) {
|
||||
@@ -163,6 +167,13 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
||||
</Alert>
|
||||
)}
|
||||
{!tournament.regional && <Podium tournament={tournament} />}
|
||||
{hasGroupStage && (
|
||||
<ListLink
|
||||
label={`View Groups`}
|
||||
to={`/tournaments/${tournament.id}/groups`}
|
||||
Icon={ListDashes}
|
||||
/>
|
||||
)}
|
||||
<ListLink
|
||||
label={`View Bracket`}
|
||||
to={`/tournaments/${tournament.id}/bracket`}
|
||||
|
||||
Reference in New Issue
Block a user