several
This commit is contained in:
@@ -19,6 +19,7 @@
|
|||||||
"@mantine/dates": "^8.2.4",
|
"@mantine/dates": "^8.2.4",
|
||||||
"@mantine/form": "^8.2.4",
|
"@mantine/form": "^8.2.4",
|
||||||
"@mantine/hooks": "^8.2.4",
|
"@mantine/hooks": "^8.2.4",
|
||||||
|
"@mantine/tiptap": "^8.2.4",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
"@tanstack/react-query-devtools": "^5.66.0",
|
"@tanstack/react-query-devtools": "^5.66.0",
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { createFileRoute } from '@tanstack/react-router'
|
|||||||
|
|
||||||
export const Route = createFileRoute('/_authed/admin/preview')({
|
export const Route = createFileRoute('/_authed/admin/preview')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
loader: () => ({
|
||||||
|
header: {
|
||||||
|
withBackButton: true,
|
||||||
|
title: "Bracket Preview"
|
||||||
|
},
|
||||||
|
withPadding: false
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { tournamentQueries } from '@/features/tournaments/queries'
|
import { tournamentQueries } from '@/features/tournaments/queries'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import ManageTournament from '@/features/tournaments/components/manage-tournament'
|
||||||
import { List } from '@mantine/core'
|
|
||||||
import ListButton from '@/components/list-button'
|
|
||||||
import { HardDrivesIcon, PencilLineIcon, UsersThreeIcon } from '@phosphor-icons/react'
|
|
||||||
import { useSheet } from '@/hooks/use-sheet'
|
|
||||||
import Sheet from '@/components/sheet/sheet'
|
|
||||||
import TournamentForm from '@/features/tournaments/components/tournament-form'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/admin/tournaments/$id')({
|
export const Route = createFileRoute('/_authed/admin/tournaments/$id')({
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
@@ -29,33 +23,5 @@ export const Route = createFileRoute('/_authed/admin/tournaments/$id')({
|
|||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { id } = Route.useParams()
|
const { id } = Route.useParams()
|
||||||
const { data: tournament } = useQuery(tournamentQueries.details(id))
|
return <ManageTournament tournamentId={id} />
|
||||||
if (!tournament) throw new Error("Tournament not found.")
|
|
||||||
|
|
||||||
const { isOpen: editTournamentOpened, open: openEditTournament, close: closeEditTournament } = useSheet();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<List>
|
|
||||||
<ListButton label="Edit Tournament" Icon={HardDrivesIcon} onClick={openEditTournament} />
|
|
||||||
<ListButton label="Edit Rules" Icon={PencilLineIcon} onClick={console.log} />
|
|
||||||
<ListButton label="Edit Enrolled Teams" Icon={UsersThreeIcon} onClick={console.log} />
|
|
||||||
</List>
|
|
||||||
|
|
||||||
<Sheet opened={editTournamentOpened} onChange={closeEditTournament}>
|
|
||||||
<TournamentForm
|
|
||||||
tournamentId={tournament.id}
|
|
||||||
initialValues={{
|
|
||||||
name: tournament.name,
|
|
||||||
location: tournament.location,
|
|
||||||
desc: tournament.desc,
|
|
||||||
start_time: tournament.start_time,
|
|
||||||
enroll_time: tournament.enroll_time,
|
|
||||||
end_time: tournament.end_time,
|
|
||||||
}}
|
|
||||||
close={closeEditTournament}
|
|
||||||
/>
|
|
||||||
</Sheet>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Box, Container } from "@mantine/core";
|
import { Box, Container, useComputedColorScheme } from "@mantine/core";
|
||||||
import { PropsWithChildren, useEffect } from "react";
|
import { PropsWithChildren, useEffect } from "react";
|
||||||
import { Drawer as VaulDrawer } from 'vaul';
|
import { Drawer as VaulDrawer } from 'vaul';
|
||||||
import { useMantineColorScheme } from '@mantine/core';
|
import { useMantineColorScheme } from '@mantine/core';
|
||||||
@@ -11,7 +11,7 @@ interface DrawerProps extends PropsWithChildren {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Drawer: React.FC<DrawerProps> = ({ title, children, opened, onChange }) => {
|
const Drawer: React.FC<DrawerProps> = ({ title, children, opened, onChange }) => {
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const colorScheme = useComputedColorScheme('light');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const appElement = document.querySelector('.app') as HTMLElement;
|
const appElement = document.querySelector('.app') as HTMLElement;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Title, List, Divider } from "@mantine/core";
|
import { Title, List, Divider } from "@mantine/core";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
import Page from "@/components/page";
|
import Page from "@/components/page";
|
||||||
import { DatabaseIcon, TrophyIcon, UsersFourIcon, UsersThreeIcon } from "@phosphor-icons/react";
|
import { DatabaseIcon, TreeStructureIcon, TrophyIcon, UsersFourIcon, UsersThreeIcon } from "@phosphor-icons/react";
|
||||||
import ListButton from "@/components/list-button";
|
import ListButton from "@/components/list-button";
|
||||||
|
|
||||||
const AdminPage = () => {
|
const AdminPage = () => {
|
||||||
@@ -12,6 +12,7 @@ const AdminPage = () => {
|
|||||||
<List>
|
<List>
|
||||||
<ListLink label="Manage Tournaments" Icon={TrophyIcon} to="/admin/tournaments" />
|
<ListLink label="Manage Tournaments" Icon={TrophyIcon} to="/admin/tournaments" />
|
||||||
<ListButton label="Open Pocketbase" Icon={DatabaseIcon} onClick={() => window.location.replace(import.meta.env.VITE_POCKETBASE_URL! + "/_/")} />
|
<ListButton label="Open Pocketbase" Icon={DatabaseIcon} onClick={() => window.location.replace(import.meta.env.VITE_POCKETBASE_URL! + "/_/")} />
|
||||||
|
<ListLink label="Bracket Preview" Icon={TreeStructureIcon} to="/admin/preview" />
|
||||||
</List>
|
</List>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,17 +10,22 @@ import { useEffect, useState } from "react";
|
|||||||
import { bracketQueries } from "../queries";
|
import { bracketQueries } from "../queries";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { createBracketMaps, BracketMaps } from "../utils/bracket-maps";
|
import { createBracketMaps, BracketMaps } from "../utils/bracket-maps";
|
||||||
import { BracketData, Match, Team } from "../types";
|
import { BracketData, Match } from "../types";
|
||||||
import Bracket from "./bracket";
|
import Bracket from "./bracket";
|
||||||
import "./styles.module.css";
|
import "./styles.module.css";
|
||||||
|
|
||||||
|
interface PreviewTeam {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const PreviewBracket: React.FC = () => {
|
export const PreviewBracket: React.FC = () => {
|
||||||
const [teamCount, setTeamCount] = useState(20);
|
const [teamCount, setTeamCount] = useState(20);
|
||||||
const { data, isLoading, error } = useQuery<BracketData>(
|
const { data, isLoading, error } = useQuery(
|
||||||
bracketQueries.preview(teamCount)
|
bracketQueries.preview(teamCount)
|
||||||
);
|
);
|
||||||
|
|
||||||
const [teams, setTeams] = useState<Team[]>([]);
|
const [teams, setTeams] = useState<PreviewTeam[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTeams(
|
setTeams(
|
||||||
@@ -40,7 +45,7 @@ export const PreviewBracket: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data || teams.length === 0) return;
|
if (!data || teams.length === 0) return;
|
||||||
|
|
||||||
const maps = createBracketMaps(data);
|
const maps = createBracketMaps(data as BracketData);
|
||||||
setBracketMaps(maps);
|
setBracketMaps(maps);
|
||||||
|
|
||||||
const mapBracket = (bracket: Match[][]) => {
|
const mapBracket = (bracket: Match[][]) => {
|
||||||
@@ -73,8 +78,9 @@ export const PreviewBracket: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
setSeededWinnersBracket(mapBracket(data.winners));
|
const bracketData = data as BracketData;
|
||||||
setSeededLosersBracket(mapBracket(data.losers));
|
setSeededWinnersBracket(mapBracket(bracketData.winners));
|
||||||
|
setSeededLosersBracket(mapBracket(bracketData.losers));
|
||||||
}, [teams, data]);
|
}, [teams, data]);
|
||||||
|
|
||||||
if (error) return <p>Error loading bracket</p>;
|
if (error) return <p>Error loading bracket</p>;
|
||||||
@@ -82,10 +88,7 @@ export const PreviewBracket: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Container p={0} w="100%" style={{ userSelect: "none" }}>
|
<Container p={0} w="100%" style={{ userSelect: "none" }}>
|
||||||
<Flex w="100%" justify="space-between" align="center" h="3rem">
|
<Flex w="100%" justify="space-between" align="center" h="3rem">
|
||||||
<Text fw={600} size="lg">
|
<Group gap="sm" mx='auto'>
|
||||||
Preview Bracket (Double Elimination)
|
|
||||||
</Text>
|
|
||||||
<Group gap="sm">
|
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Teams:
|
Teams:
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createServerFn } from "@tanstack/react-start";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Logger } from "@/lib/logger";
|
import { Logger } from "@/lib/logger";
|
||||||
import brackets from './utils';
|
import brackets from './utils';
|
||||||
|
import { BracketData } from "./types";
|
||||||
|
|
||||||
const logger = new Logger("Bracket Generation")
|
const logger = new Logger("Bracket Generation")
|
||||||
|
|
||||||
@@ -13,5 +14,5 @@ export const previewBracket = createServerFn()
|
|||||||
logger.info('Generating bracket', teams);
|
logger.info('Generating bracket', teams);
|
||||||
if (!Object.keys(brackets).includes(teams.toString()))
|
if (!Object.keys(brackets).includes(teams.toString()))
|
||||||
throw Error("Bracket not available")
|
throw Error("Bracket not available")
|
||||||
return brackets[teams];
|
return brackets[teams as keyof typeof brackets] as BracketData;
|
||||||
});
|
});
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Team } from "../teams/types";
|
|
||||||
|
export interface Slot {
|
||||||
|
seed?: number;
|
||||||
|
team?: any;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Match {
|
export interface Match {
|
||||||
lid: number;
|
lid: number;
|
||||||
round: number;
|
round: number;
|
||||||
order: number | null;
|
order: number | null;
|
||||||
type: string;
|
type: string;
|
||||||
home: Team;
|
home: Slot;
|
||||||
away?: Team;
|
away: Slot;
|
||||||
reset?: boolean;
|
reset?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
|
import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
export const useLinks = (userId: number, roles: string[]) =>
|
export const useLinks = (userId: string | undefined, roles: string[]) =>
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
|
if (!userId) throw new Error("userId is undefined")
|
||||||
const links = [
|
const links = [
|
||||||
{
|
{
|
||||||
label: 'Home',
|
label: 'Home',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { updatePlayer } from "@/features/players/server";
|
import { updatePlayer } from "@/features/players/server";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { Stack, TextInput } from "@mantine/core"
|
import { Stack, TextInput } from "@mantine/core";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import toast from "@/lib/sonner";
|
import toast from "@/lib/sonner";
|
||||||
import { useRouter } from "@tanstack/react-router";
|
import { useRouter } from "@tanstack/react-router";
|
||||||
@@ -18,44 +18,61 @@ const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
|
|||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
first_name: player.first_name,
|
first_name: player.first_name,
|
||||||
last_name: player.last_name
|
last_name: player.last_name,
|
||||||
},
|
},
|
||||||
validate: {
|
validate: {
|
||||||
first_name: (value: string) => {
|
first_name: (value: string | undefined) => {
|
||||||
if (value.length === 0) return 'First name is required'
|
if (!value || value.length === 0) return "First name is required";
|
||||||
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return 'First name must be 3-20 characters long and contain only letters'
|
if (!/^[a-zA-Z\s]{3,20}$/.test(value))
|
||||||
|
return "First name must be 3-20 characters long and contain only letters";
|
||||||
|
},
|
||||||
|
last_name: (value: string | undefined) => {
|
||||||
|
if (!value || value.length === 0) return "Last name is required";
|
||||||
|
if (!/^[a-zA-Z\s]{3,20}$/.test(value))
|
||||||
|
return "Last name must be 3-20 characters long and contain only letters";
|
||||||
},
|
},
|
||||||
last_name: (value: string) => {
|
|
||||||
if (value.length === 0) return 'Last name is required'
|
|
||||||
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return 'Last name must be 3-20 characters long and contain only letters'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const { mutate: updateName, isPending } = useMutation({
|
|
||||||
mutationFn: async (data: { first_name: string, last_name: string }) => await updatePlayer({ data }),
|
|
||||||
onSuccess: () => {
|
|
||||||
toggle();
|
|
||||||
toast.success('Name updated successfully!');
|
|
||||||
router.invalidate();
|
|
||||||
|
|
||||||
},
|
},
|
||||||
onError: () => {
|
|
||||||
toast.error('There was an issue updating your name. Please try again later.');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (data: { first_name: string, last_name: string }) => await updateName(data)
|
const { mutate: updateName, isPending } = useMutation({
|
||||||
|
mutationFn: async (data: { first_name: string; last_name: string }) =>
|
||||||
|
await updatePlayer({ data }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toggle();
|
||||||
|
toast.success("Name updated successfully!");
|
||||||
|
router.invalidate();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(
|
||||||
|
"There was an issue updating your name. Please try again later."
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (data: {
|
||||||
|
first_name: string | undefined;
|
||||||
|
last_name: string | undefined;
|
||||||
|
}) => {
|
||||||
|
if (!data.first_name || !data.last_name) return;
|
||||||
|
await updateName({
|
||||||
|
first_name: data.first_name,
|
||||||
|
last_name: data.last_name,
|
||||||
|
});
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack gap='xs'>
|
<Stack gap="xs">
|
||||||
<TextInput label='First Name' {...form.getInputProps('first_name')} />
|
<TextInput label="First Name" {...form.getInputProps("first_name")} />
|
||||||
<TextInput label='Last Name' {...form.getInputProps('last_name')} />
|
<TextInput label="Last Name" {...form.getInputProps("last_name")} />
|
||||||
<Button loading={isPending} type='submit'>Save</Button>
|
<Button loading={isPending} type="submit">
|
||||||
<Button variant='subtle' color='red' onClick={toggle}>Cancel</Button>
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button variant="subtle" color="red" onClick={toggle}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default NameUpdateForm;
|
export default NameUpdateForm;
|
||||||
|
|||||||
123
src/features/tournaments/components/edit-enrolled-teams.tsx
Normal file
123
src/features/tournaments/components/edit-enrolled-teams.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { Autocomplete, Stack, ActionIcon, Text, Group, Loader } from "@mantine/core";
|
||||||
|
import { TrashIcon } from "@phosphor-icons/react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useState, useCallback, useMemo, memo } from "react";
|
||||||
|
import { tournamentQueries } from "../queries";
|
||||||
|
import useEnrollTeam from "../hooks/use-enroll-team";
|
||||||
|
import useUnenrollTeam from "../hooks/use-unenroll-team";
|
||||||
|
import Avatar from "@/components/avatar";
|
||||||
|
import { Team } from "@/features/teams/types";
|
||||||
|
|
||||||
|
interface EditEnrolledTeamsProps {
|
||||||
|
tournamentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamItem = memo(({ team, onUnenroll, disabled }: {
|
||||||
|
team: Team;
|
||||||
|
onUnenroll: (teamId: string) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}) => {
|
||||||
|
const playerNames = useMemo(() =>
|
||||||
|
team.players?.map(p => `${p.first_name} ${p.last_name}`).join(", ") || "",
|
||||||
|
[team.players]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
|
||||||
|
<Avatar size={32} name={team.name} />
|
||||||
|
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text fw={500} truncate>{team.name}</Text>
|
||||||
|
{playerNames && (
|
||||||
|
<Text size="xs" c="dimmed" truncate>{playerNames}</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onClick={() => onUnenroll(team.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<TrashIcon size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const { data: tournament, isLoading: tournamentLoading } =
|
||||||
|
useQuery(tournamentQueries.details(tournamentId));
|
||||||
|
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
|
||||||
|
useQuery(tournamentQueries.unenrolled(tournamentId));
|
||||||
|
|
||||||
|
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
|
||||||
|
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
|
||||||
|
|
||||||
|
const autocompleteData = useMemo(() =>
|
||||||
|
unenrolledTeams.map((team: Team) => ({ value: team.id, label: team.name })),
|
||||||
|
[unenrolledTeams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEnrollTeam = useCallback((teamId: string) => {
|
||||||
|
enrollTeam({ tournamentId, teamId }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [enrollTeam, tournamentId, setSearch]);
|
||||||
|
|
||||||
|
const handleUnenrollTeam = useCallback((teamId: string) => {
|
||||||
|
unenrollTeam({ tournamentId, teamId });
|
||||||
|
}, [unenrollTeam, tournamentId]);
|
||||||
|
|
||||||
|
const isLoading = tournamentLoading || unenrolledLoading;
|
||||||
|
const enrolledTeams = tournament?.teams || [];
|
||||||
|
const hasEnrolledTeams = enrolledTeams.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg" w="100%">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text fw={600} size="sm">Add Team</Text>
|
||||||
|
<Autocomplete
|
||||||
|
placeholder="Search for teams to enroll..."
|
||||||
|
data={autocompleteData}
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
onOptionSubmit={handleEnrollTeam}
|
||||||
|
disabled={isEnrolling || unenrolledLoading}
|
||||||
|
rightSection={isEnrolling ? <Loader size="xs" /> : null}
|
||||||
|
maxDropdownHeight={200}
|
||||||
|
limit={10}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={600} size="sm">Enrolled Teams</Text>
|
||||||
|
<Text size="xs" c="dimmed">{enrolledTeams.length} teams</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="md"><Loader size="sm" /></Group>
|
||||||
|
) : !hasEnrolledTeams ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="lg">No teams enrolled yet</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap="xs" w="100%">
|
||||||
|
{enrolledTeams.map((team: Team) => (
|
||||||
|
<TeamItem
|
||||||
|
key={team.id}
|
||||||
|
team={team}
|
||||||
|
onUnenroll={handleUnenrollTeam}
|
||||||
|
disabled={isUnenrolling}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditEnrolledTeams;
|
||||||
59
src/features/tournaments/components/manage-tournament.tsx
Normal file
59
src/features/tournaments/components/manage-tournament.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import { tournamentQueries } from "../queries";
|
||||||
|
import { List } from "@mantine/core";
|
||||||
|
import ListButton from "@/components/list-button";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import TournamentForm from "./tournament-form";
|
||||||
|
import { HardDrivesIcon, PencilLineIcon, UsersThreeIcon } from "@phosphor-icons/react";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
import EditEnrolledTeams from "./edit-enrolled-teams";
|
||||||
|
|
||||||
|
interface ManageTournamentProps {
|
||||||
|
tournamentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
||||||
|
const { data: tournament } = useSuspenseQuery(
|
||||||
|
tournamentQueries.details(tournamentId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tournament) throw new Error("Tournament not found.");
|
||||||
|
const { isOpen: editTournamentOpened, open: openEditTournament, close: closeEditTournament } = useSheet();
|
||||||
|
const { isOpen: editRulesOpened, open: openEditRules, close: closeEditRules } = useSheet();
|
||||||
|
const { isOpen: editTeamsOpened, open: openEditTeams, close: closeEditTeams } = useSheet();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<List>
|
||||||
|
<ListButton label="Edit Tournament" Icon={HardDrivesIcon} onClick={openEditTournament} />
|
||||||
|
<ListButton label="Edit Rules" Icon={PencilLineIcon} onClick={openEditRules} />
|
||||||
|
<ListButton label="Edit Enrolled Teams" Icon={UsersThreeIcon} onClick={openEditTeams} />
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<Sheet title="Edit Tournament" opened={editTournamentOpened} onChange={closeEditTournament}>
|
||||||
|
<TournamentForm
|
||||||
|
tournamentId={tournament.id}
|
||||||
|
initialValues={{
|
||||||
|
name: tournament.name,
|
||||||
|
location: tournament.location,
|
||||||
|
desc: tournament.desc,
|
||||||
|
start_time: tournament.start_time,
|
||||||
|
enroll_time: tournament.enroll_time,
|
||||||
|
end_time: tournament.end_time,
|
||||||
|
}}
|
||||||
|
close={closeEditTournament}
|
||||||
|
/>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<Sheet title="Edit Rules" opened={editRulesOpened} onChange={closeEditRules}>
|
||||||
|
<p>Test</p>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<Sheet title="Edit Enrolled Teams" opened={editTeamsOpened} onChange={closeEditTeams}>
|
||||||
|
<EditEnrolledTeams tournamentId={tournamentId} />
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ManageTournament;
|
||||||
@@ -13,7 +13,9 @@ const useEnrollTeam = () => {
|
|||||||
if (!data) {
|
if (!data) {
|
||||||
toast.error('There was an issue enrolling. Please try again later.');
|
toast.error('There was an issue enrolling. Please try again later.');
|
||||||
} else {
|
} else {
|
||||||
queryClient.invalidateQueries({ queryKey: ['tournaments', 'detail', tournamentId] });
|
// Invalidate both tournament details and unenrolled teams queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tournaments', 'details', tournamentId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tournaments', 'unenrolled', tournamentId] });
|
||||||
toast.success('Team enrolled successfully!');
|
toast.success('Team enrolled successfully!');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ const useUnenrollTeam = () => {
|
|||||||
if (!data) {
|
if (!data) {
|
||||||
toast.error('There was an issue unenrolling. Please try again later.');
|
toast.error('There was an issue unenrolling. Please try again later.');
|
||||||
} else {
|
} else {
|
||||||
queryClient.invalidateQueries({ queryKey: ['tournaments', 'detail', tournamentId] });
|
// Invalidate both tournament details and unenrolled teams queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tournaments', 'details', tournamentId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tournaments', 'unenrolled', tournamentId] });
|
||||||
toast.success('Team unenrolled successfully.');
|
toast.success('Team unenrolled successfully.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import { queryOptions, useQuery } from "@tanstack/react-query";
|
import { queryOptions, useQuery } from "@tanstack/react-query";
|
||||||
import { getTournament, listTournaments } from "./server";
|
import { getTournament, getUnenrolledTeams, listTournaments } from "./server";
|
||||||
|
|
||||||
const tournamentKeys = {
|
const tournamentKeys = {
|
||||||
list: ['tournaments', 'list'] as const,
|
list: ['tournaments', 'list'] as const,
|
||||||
details: (id: string) => ['tournaments', 'details', id] as const
|
details: (id: string) => ['tournaments', 'details', id] as const,
|
||||||
|
unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tournamentQueries = {
|
export const tournamentQueries = {
|
||||||
list: () => queryOptions({
|
list: () => queryOptions({
|
||||||
queryKey: tournamentKeys.list,
|
queryKey: tournamentKeys.list,
|
||||||
queryFn: listTournaments,
|
queryFn: listTournaments
|
||||||
}),
|
}),
|
||||||
details: (id: string) => queryOptions({
|
details: (id: string) => queryOptions({
|
||||||
queryKey: tournamentKeys.details(id),
|
queryKey: tournamentKeys.details(id),
|
||||||
queryFn: () => getTournament({ data: id }),
|
queryFn: () => getTournament({ data: id })
|
||||||
}),
|
}),
|
||||||
|
unenrolled: (id: string) => queryOptions({
|
||||||
|
queryKey: tournamentKeys.unenrolled(id),
|
||||||
|
queryFn: () => getUnenrolledTeams({ data: id })
|
||||||
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -105,3 +105,12 @@ export const unenrollTeam = createServerFn()
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getUnenrolledTeams = createServerFn()
|
||||||
|
.validator(z.string())
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
|
.handler(async ({ data: tournamentId }) => {
|
||||||
|
logger.info('Getting unenrolled teams', tournamentId);
|
||||||
|
const teams = await pbAdmin.getUnenrolledTeams(tournamentId);
|
||||||
|
return teams;
|
||||||
|
});
|
||||||
@@ -4,8 +4,10 @@ import type {
|
|||||||
TournamentInput,
|
TournamentInput,
|
||||||
TournamentUpdateInput,
|
TournamentUpdateInput,
|
||||||
} from "@/features/tournaments/types";
|
} from "@/features/tournaments/types";
|
||||||
|
import type { Team } from "@/features/teams/types";
|
||||||
import PocketBase from "pocketbase";
|
import PocketBase from "pocketbase";
|
||||||
import { transformTournament } from "@/lib/pocketbase/util/transform-types";
|
import { transformTournament } from "@/lib/pocketbase/util/transform-types";
|
||||||
|
import { transformTeam } from "@/lib/pocketbase/util/transform-types";
|
||||||
|
|
||||||
export function createTournamentsService(pb: PocketBase) {
|
export function createTournamentsService(pb: PocketBase) {
|
||||||
return {
|
return {
|
||||||
@@ -54,6 +56,12 @@ export function createTournamentsService(pb: PocketBase) {
|
|||||||
{ "teams+": teamId },
|
{ "teams+": teamId },
|
||||||
{ expand: "teams, teams.players" }
|
{ expand: "teams, teams.players" }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await pb.collection("teams").update(
|
||||||
|
teamId,
|
||||||
|
{ "tournaments+": tournamentId }
|
||||||
|
);
|
||||||
|
|
||||||
return transformTournament(result);
|
return transformTournament(result);
|
||||||
},
|
},
|
||||||
async unenrollTeam(
|
async unenrollTeam(
|
||||||
@@ -65,7 +73,43 @@ export function createTournamentsService(pb: PocketBase) {
|
|||||||
{ "teams-": teamId },
|
{ "teams-": teamId },
|
||||||
{ expand: "teams, teams.players" }
|
{ expand: "teams, teams.players" }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await pb.collection("teams").update(
|
||||||
|
teamId,
|
||||||
|
{ "tournaments-": tournamentId }
|
||||||
|
);
|
||||||
|
|
||||||
return transformTournament(result);
|
return transformTournament(result);
|
||||||
},
|
},
|
||||||
|
async getUnenrolledTeams(tournamentId: string): Promise<Team[]> {
|
||||||
|
try {
|
||||||
|
logger.info("PocketBase | Getting unenrolled teams for tournament", tournamentId);
|
||||||
|
const tournament = await pb.collection("tournaments").getOne(tournamentId, {
|
||||||
|
fields: "teams"
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrolledTeamIds = tournament.teams || [];
|
||||||
|
if (enrolledTeamIds.length === 0) {
|
||||||
|
const allTeams = await pb.collection("teams").getFullList({
|
||||||
|
expand: "players"
|
||||||
|
});
|
||||||
|
return allTeams.map(transformTeam);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = enrolledTeamIds
|
||||||
|
.map((teamId: string) => `id != "${teamId}"`)
|
||||||
|
.join(" && ");
|
||||||
|
|
||||||
|
const availableTeams = await pb.collection("teams").getFullList({
|
||||||
|
filter,
|
||||||
|
expand: "players"
|
||||||
|
});
|
||||||
|
|
||||||
|
return availableTeams.map(transformTeam);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("PocketBase | Error getting unenrolled teams", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user