regionals
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
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 { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon, Button, Alert } from "@mantine/core";
|
||||
import { CaretCircleDownIcon, CaretCircleUpIcon } 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";
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
|
||||
import { populateKnockoutBracket } from "@/features/matches/server";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { tournamentKeys } from "../queries";
|
||||
|
||||
interface GroupStageViewProps {
|
||||
groups: Group[];
|
||||
matches: Match[];
|
||||
showControls?: boolean;
|
||||
tournamentId?: string;
|
||||
hasKnockoutBracket?: boolean;
|
||||
}
|
||||
|
||||
interface TeamStanding {
|
||||
@@ -18,6 +24,8 @@ interface TeamStanding {
|
||||
team: any;
|
||||
wins: number;
|
||||
losses: number;
|
||||
cupsFor: number;
|
||||
cupsAgainst: number;
|
||||
cupDifference: number;
|
||||
}
|
||||
|
||||
@@ -25,9 +33,33 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
groups,
|
||||
matches,
|
||||
showControls,
|
||||
tournamentId,
|
||||
hasKnockoutBracket,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
|
||||
|
||||
const populateKnockoutMutation = useServerMutation({
|
||||
mutationFn: populateKnockoutBracket,
|
||||
successMessage: "Knockout bracket populated successfully!",
|
||||
onSuccess: () => {
|
||||
if (tournamentId) {
|
||||
queryClient.invalidateQueries({ queryKey: tournamentKeys.details(tournamentId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const allGroupMatchesCompleted = useMemo(() => {
|
||||
const groupMatches = matches.filter((match) => match.round === -1);
|
||||
if (groupMatches.length === 0) return false;
|
||||
return groupMatches.every((match) => match.status === "ended");
|
||||
}, [matches]);
|
||||
|
||||
const handlePopulateKnockout = () => {
|
||||
if (!tournamentId) return;
|
||||
populateKnockoutMutation.mutate({ data: { tournamentId } });
|
||||
};
|
||||
|
||||
const orderMatchesWithSpacing = (matches: Match[]): Match[] => {
|
||||
if (matches.length <= 1) return matches;
|
||||
|
||||
@@ -99,6 +131,8 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
team: team,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
cupsFor: 0,
|
||||
cupsAgainst: 0,
|
||||
cupDifference: 0,
|
||||
});
|
||||
});
|
||||
@@ -119,6 +153,11 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
const homeCups = match.home_cups || 0;
|
||||
const awayCups = match.away_cups || 0;
|
||||
|
||||
homeStanding.cupsFor += homeCups;
|
||||
homeStanding.cupsAgainst += awayCups;
|
||||
awayStanding.cupsFor += awayCups;
|
||||
awayStanding.cupsAgainst += homeCups;
|
||||
|
||||
homeStanding.cupDifference += homeCups - awayCups;
|
||||
awayStanding.cupDifference += awayCups - homeCups;
|
||||
|
||||
@@ -133,7 +172,8 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
|
||||
return Array.from(standings.values()).sort((a, b) => {
|
||||
if (b.wins !== a.wins) return b.wins - a.wins;
|
||||
return b.cupDifference - a.cupDifference;
|
||||
if (b.cupDifference !== a.cupDifference) return b.cupDifference - a.cupDifference;
|
||||
return b.cupsFor - a.cupsFor;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -147,104 +187,26 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
const showGenerateKnockoutButton = showControls && tournamentId && !hasKnockoutBracket && allGroupMatchesCompleted;
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
<Tabs defaultValue={sortedGroups[0]?.id}>
|
||||
<Stack gap="md">
|
||||
{showGenerateKnockoutButton && (
|
||||
<Alert color="blue" title="Group Stage Complete" icon={<CaretCircleUpIcon size={20} />}>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">All group matches are finished! Populate the knockout bracket to advance qualified teams.</Text>
|
||||
<Button
|
||||
onClick={handlePopulateKnockout}
|
||||
loading={populateKnockoutMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
Populate Knockout Bracket
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
<Tabs defaultValue={sortedGroups[0]?.id}>
|
||||
<Tabs.List mb="md" grow>
|
||||
{sortedGroups.map((group) => {
|
||||
const groupMatches = matchesByGroup.get(group.id) || [];
|
||||
@@ -291,7 +253,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
Standings ({standings.length})
|
||||
</Text>
|
||||
<ActionIcon variant="subtle" size="sm">
|
||||
{expandedTeams[group.id] ? <CaretCircleUp size={16} /> : <CaretCircleDown size={16} />}
|
||||
{expandedTeams[group.id] ? <CaretCircleUpIcon size={16} /> : <CaretCircleDownIcon size={16} />}
|
||||
</ActionIcon>
|
||||
</MantineGroup>
|
||||
<Collapse in={expandedTeams[group.id]}>
|
||||
@@ -317,16 +279,21 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
{standing.teamName}
|
||||
</Text>
|
||||
<MantineGroup gap="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
<Text size="xs" c="dimmed" fw={500} miw={35} ta="center">
|
||||
{standing.wins}-{standing.losses}
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
c={standing.cupDifference > 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"}
|
||||
fw={600}
|
||||
miw={30}
|
||||
ta="center"
|
||||
>
|
||||
{standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" fw={400} miw={40} ta="center">
|
||||
{standing.cupsFor}/{standing.cupsAgainst}
|
||||
</Text>
|
||||
</MantineGroup>
|
||||
</MantineGroup>
|
||||
))
|
||||
@@ -339,7 +306,6 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
</Collapse>
|
||||
</Card>
|
||||
|
||||
{/* Matches Grid */}
|
||||
{groupMatches.length === 0 ? (
|
||||
<Card withBorder radius="md" p="xl">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
@@ -365,6 +331,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
Select,
|
||||
LoadingOverlay,
|
||||
Alert,
|
||||
Title,
|
||||
Divider,
|
||||
Box,
|
||||
} from "@mantine/core";
|
||||
import { InfoIcon } from "@phosphor-icons/react";
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
|
||||
@@ -21,6 +24,10 @@ import {
|
||||
import GroupPreview from "./group-preview";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { tournamentKeys } from "../queries";
|
||||
import brackets from "@/features/bracket/utils";
|
||||
import { Bracket } from "@/features/bracket/components/bracket";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import { generateSingleEliminationBracket } from "../utils/bracket-generator";
|
||||
|
||||
interface SetupGroupStageProps {
|
||||
tournamentId: string;
|
||||
@@ -63,6 +70,74 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
|
||||
}));
|
||||
}, [selectedConfig, teams, seed]);
|
||||
|
||||
const knockoutTeamCount = useMemo(() => {
|
||||
if (!selectedConfig) return 0;
|
||||
return selectedConfig.num_groups * selectedConfig.advance_per_group;
|
||||
}, [selectedConfig]);
|
||||
|
||||
const bracketPreview = useMemo(() => {
|
||||
if (!knockoutTeamCount || !selectedConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bracketTemplate: any;
|
||||
if (Object.keys(brackets).includes(knockoutTeamCount.toString())) {
|
||||
bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets];
|
||||
} else {
|
||||
try {
|
||||
bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const groupNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
|
||||
const seedLabels: Record<number, string> = {};
|
||||
|
||||
let seedIndex = 1;
|
||||
for (let rank = 1; rank <= selectedConfig.advance_per_group; rank++) {
|
||||
for (let groupIdx = 0; groupIdx < selectedConfig.num_groups; groupIdx++) {
|
||||
const groupName = groupNames[groupIdx] || `Group ${groupIdx + 1}`;
|
||||
const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`;
|
||||
seedLabels[seedIndex++] = `${groupName} ${rankSuffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
const ordersMap: Record<number, number> = {};
|
||||
bracketTemplate.winners.forEach((round: any[]) => {
|
||||
round.forEach((match: any) => {
|
||||
ordersMap[match.lid] = match.order;
|
||||
});
|
||||
});
|
||||
|
||||
const placeholderMatches: Match[][] = bracketTemplate.winners.map((round: any[], roundIndex: number) =>
|
||||
round.map((match: any) => {
|
||||
const matchData: any = {
|
||||
...match,
|
||||
id: `preview-${match.lid}`,
|
||||
home_from_lid: match.home_from_lid !== null && match.home_from_lid !== undefined ? match.home_from_lid : -1,
|
||||
away_from_lid: match.away_from_lid !== null && match.away_from_lid !== undefined ? match.away_from_lid : -1,
|
||||
home_cups: 0,
|
||||
away_cups: 0,
|
||||
status: "tbd" as const,
|
||||
tournament: { id: "", name: "" },
|
||||
};
|
||||
|
||||
if (roundIndex === 0) {
|
||||
matchData.home = match.home_seed && !match.bye ? { id: `seed-${match.home_seed}`, name: seedLabels[match.home_seed] } : null;
|
||||
matchData.away = match.away_seed ? { id: `seed-${match.away_seed}`, name: seedLabels[match.away_seed] } : null;
|
||||
} else {
|
||||
matchData.home = null;
|
||||
matchData.away = null;
|
||||
}
|
||||
|
||||
return matchData;
|
||||
})
|
||||
);
|
||||
|
||||
return { matches: placeholderMatches, orders: ordersMap };
|
||||
}, [knockoutTeamCount, selectedConfig]);
|
||||
|
||||
const generateGroups = useServerMutation({
|
||||
mutationFn: generateGroupStage,
|
||||
successMessage: "Group stage generated successfully!",
|
||||
@@ -171,9 +246,38 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
|
||||
</Stack>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", maxHeight: "80vh" }}>
|
||||
{groupAssignments.length > 0 && (
|
||||
<GroupPreview groups={groupAssignments} />
|
||||
)}
|
||||
<Stack gap="xl">
|
||||
{groupAssignments.length > 0 && (
|
||||
<GroupPreview groups={groupAssignments} />
|
||||
)}
|
||||
|
||||
{bracketPreview && knockoutTeamCount > 0 && (
|
||||
<Box>
|
||||
<Divider mb="lg" />
|
||||
<Title order={3} ta="center" mb="md">
|
||||
Knockout Bracket Preview ({knockoutTeamCount} Teams)
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" ta="center" mb="lg">
|
||||
Top {selectedConfig?.advance_per_group} team{selectedConfig?.advance_per_group !== 1 ? 's' : ''} from each group will advance
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
|
||||
backgroundSize: "16px 16px",
|
||||
backgroundPosition: "0 0, 8px 8px",
|
||||
padding: "1rem",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
<Bracket
|
||||
rounds={bracketPreview.matches}
|
||||
orders={bracketPreview.orders}
|
||||
showControls={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user