manage team data

This commit is contained in:
yohlo
2025-09-22 17:24:45 -05:00
parent cae5fa1c71
commit ae934e77f4
7 changed files with 253 additions and 20 deletions

View File

@@ -27,8 +27,9 @@ import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/prof
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview' import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index' import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket' import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id' import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id' import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo' import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
import { ServerRoute as ApiTeamsUploadLogoServerRouteImport } from './routes/api/teams/upload-logo' import { ServerRoute as ApiTeamsUploadLogoServerRouteImport } from './routes/api/teams/upload-logo'
import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token' import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token'
@@ -125,10 +126,10 @@ const AuthedTournamentsIdBracketRoute =
path: '/tournaments/$id/bracket', path: '/tournaments/$id/bracket',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedAdminTournamentsIdRoute = const AuthedAdminTournamentsIdIndexRoute =
AuthedAdminTournamentsIdRouteImport.update({ AuthedAdminTournamentsIdIndexRouteImport.update({
id: '/tournaments/$id', id: '/tournaments/$id/',
path: '/tournaments/$id', path: '/tournaments/$id/',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const AuthedAdminTournamentsRunIdRoute = const AuthedAdminTournamentsRunIdRoute =
@@ -137,6 +138,12 @@ const AuthedAdminTournamentsRunIdRoute =
path: '/tournaments/run/$id', path: '/tournaments/run/$id',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const AuthedAdminTournamentsIdTeamsRoute =
AuthedAdminTournamentsIdTeamsRouteImport.update({
id: '/tournaments/$id/teams',
path: '/tournaments/$id/teams',
getParentRoute: () => AuthedAdminRoute,
} as any)
const ApiTournamentsUploadLogoServerRoute = const ApiTournamentsUploadLogoServerRoute =
ApiTournamentsUploadLogoServerRouteImport.update({ ApiTournamentsUploadLogoServerRouteImport.update({
id: '/api/tournaments/upload-logo', id: '/api/tournaments/upload-logo',
@@ -212,10 +219,11 @@ export interface FileRoutesByFullPath {
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/admin/': typeof AuthedAdminIndexRoute '/admin/': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
@@ -230,10 +238,11 @@ export interface FileRoutesByTo {
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/admin': typeof AuthedAdminIndexRoute '/admin': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -251,10 +260,11 @@ export interface FileRoutesById {
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/_authed/admin/': typeof AuthedAdminIndexRoute '/_authed/admin/': typeof AuthedAdminIndexRoute
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -272,10 +282,11 @@ export interface FileRouteTypes {
| '/tournaments/$tournamentId' | '/tournaments/$tournamentId'
| '/admin/' | '/admin/'
| '/tournaments' | '/tournaments'
| '/admin/tournaments/$id'
| '/tournaments/$id/bracket' | '/tournaments/$id/bracket'
| '/admin/tournaments' | '/admin/tournaments'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/admin/tournaments/$id'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/login' | '/login'
@@ -290,10 +301,11 @@ export interface FileRouteTypes {
| '/tournaments/$tournamentId' | '/tournaments/$tournamentId'
| '/admin' | '/admin'
| '/tournaments' | '/tournaments'
| '/admin/tournaments/$id'
| '/tournaments/$id/bracket' | '/tournaments/$id/bracket'
| '/admin/tournaments' | '/admin/tournaments'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/admin/tournaments/$id'
id: id:
| '__root__' | '__root__'
| '/_authed' | '/_authed'
@@ -310,10 +322,11 @@ export interface FileRouteTypes {
| '/_authed/tournaments/$tournamentId' | '/_authed/tournaments/$tournamentId'
| '/_authed/admin/' | '/_authed/admin/'
| '/_authed/tournaments/' | '/_authed/tournaments/'
| '/_authed/admin/tournaments/$id'
| '/_authed/tournaments/$id/bracket' | '/_authed/tournaments/$id/bracket'
| '/_authed/admin/tournaments/' | '/_authed/admin/tournaments/'
| '/_authed/admin/tournaments/$id/teams'
| '/_authed/admin/tournaments/run/$id' | '/_authed/admin/tournaments/run/$id'
| '/_authed/admin/tournaments/$id/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -532,11 +545,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/admin/tournaments/$id': { '/_authed/admin/tournaments/$id/': {
id: '/_authed/admin/tournaments/$id' id: '/_authed/admin/tournaments/$id/'
path: '/tournaments/$id' path: '/tournaments/$id'
fullPath: '/admin/tournaments/$id' fullPath: '/admin/tournaments/$id'
preLoaderRoute: typeof AuthedAdminTournamentsIdRouteImport preLoaderRoute: typeof AuthedAdminTournamentsIdIndexRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/_authed/admin/tournaments/run/$id': { '/_authed/admin/tournaments/run/$id': {
@@ -546,6 +559,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsRunIdRouteImport preLoaderRoute: typeof AuthedAdminTournamentsRunIdRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/_authed/admin/tournaments/$id/teams': {
id: '/_authed/admin/tournaments/$id/teams'
path: '/tournaments/$id/teams'
fullPath: '/admin/tournaments/$id/teams'
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
parentRoute: typeof AuthedAdminRoute
}
} }
} }
declare module '@tanstack/react-start/server' { declare module '@tanstack/react-start/server' {
@@ -633,17 +653,19 @@ declare module '@tanstack/react-start/server' {
interface AuthedAdminRouteChildren { interface AuthedAdminRouteChildren {
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
AuthedAdminTournamentsIdRoute: typeof AuthedAdminTournamentsIdRoute
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
} }
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = { const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute, AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
AuthedAdminIndexRoute: AuthedAdminIndexRoute, AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminTournamentsIdRoute: AuthedAdminTournamentsIdRoute,
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute, AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute, AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
} }
const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren( const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(

View File

@@ -3,7 +3,7 @@ import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTournament from "@/features/tournaments/components/manage-tournament"; import ManageTournament from "@/features/tournaments/components/manage-tournament";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
export const Route = createFileRoute("/_authed/admin/tournaments/$id")({ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
const { queryClient } = context; const { queryClient } = context;
const tournament = await ensureServerQueryData( const tournament = await ensureServerQueryData(

View File

@@ -0,0 +1,32 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTeams from "@/features/teams/components/manage-teams";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await ensureServerQueryData(
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/admin/tournaments" });
return {
tournament,
};
},
loader: ({ context }) => ({
header: {
withBackButton: true,
title: `Manage Teams - ${context.tournament.name}`,
},
withPadding: false,
}),
component: RouteComponent,
});
function RouteComponent() {
const { id } = Route.useParams();
const { tournament } = Route.useRouteContext();
return <ManageTeams tournament={tournament} />;
}

View File

@@ -0,0 +1,165 @@
import { useState, useMemo } from "react";
import {
Text,
TextInput,
Stack,
Container,
Box,
ThemeIcon,
Title,
} from "@mantine/core";
import {
MagnifyingGlassIcon,
UsersIcon,
} from "@phosphor-icons/react";
import { Tournament } from "@/features/tournaments/types";
import TeamList from "./team-list";
import Sheet from "@/components/sheet/sheet";
import TeamForm from "./team-form";
import { useSheet } from "@/hooks/use-sheet";
import { useTeam } from "../queries";
interface TeamEditSheetProps {
teamId: string;
isOpen: boolean;
onClose: () => void;
}
const TeamEditSheet = ({ teamId, isOpen, onClose }: TeamEditSheetProps) => {
const { data: team } = useTeam(teamId);
return (
<Sheet
title={team ? `Edit ${team.name}` : "Edit Team"}
opened={isOpen}
onChange={onClose}
>
{team && (
<TeamForm
teamId={team.id}
initialValues={{
...team,
players: team.players ? team.players.map((p) => p.id) : [],
logo: typeof team.logo === "string" ? undefined : team.logo,
}}
close={onClose}
/>
)}
</Sheet>
);
};
interface ManageTeamsProps {
tournament: Tournament;
}
const ManageTeams = ({ tournament }: ManageTeamsProps) => {
const [search, setSearch] = useState("");
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const {
isOpen: editTeamOpened,
open: openEditTeam,
close: closeEditTeam,
} = useSheet();
const teams = tournament.teams || [];
const filteredTeams = useMemo(() => {
if (!search.trim()) return teams;
const searchLower = search.toLowerCase();
return teams.filter((team) => {
if (team.name.toLowerCase().includes(searchLower)) {
return true;
}
if (team.players) {
return team.players.some((player) => {
const firstName = player.first_name?.toLowerCase() || "";
const lastName = player.last_name?.toLowerCase() || "";
const fullName = `${firstName} ${lastName}`.toLowerCase();
return fullName.includes(searchLower) ||
firstName.includes(searchLower) ||
lastName.includes(searchLower);
});
}
return false;
});
}, [teams, search]);
const handleTeamClick = (teamId: string) => {
setSelectedTeamId(teamId);
openEditTeam();
};
const handleCloseEditTeam = () => {
setSelectedTeamId(null);
closeEditTeam();
};
if (!teams.length) {
return (
<Container px={0} size="md">
<Stack align="center" gap="md" py="xl">
<ThemeIcon size="xl" variant="light" radius="md">
<UsersIcon size={32} />
</ThemeIcon>
<Title order={3} c="dimmed">
No Teams Enrolled
</Title>
<Text c="dimmed" ta="center">
This tournament has no enrolled teams yet.
</Text>
</Stack>
</Container>
);
}
return (
<>
<Container size="100%" px={0}>
<Stack gap="xs">
<TextInput
placeholder="Search teams by name or player..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
leftSection={<MagnifyingGlassIcon size={16} />}
size="md"
px="md"
/>
<Box px="md">
<Text size="xs" c="dimmed">
{filteredTeams.length} of {teams.length} teams
</Text>
</Box>
<TeamList
teams={filteredTeams}
onTeamClick={handleTeamClick}
/>
{filteredTeams.length === 0 && search && (
<Text ta="center" c="dimmed" py="xl">
No teams found matching "{search}"
</Text>
)}
</Stack>
</Container>
{selectedTeamId && (
<TeamEditSheet
teamId={selectedTeamId}
isOpen={editTeamOpened}
onClose={handleCloseEditTeam}
/>
)}
</>
);
};
export default ManageTeams;

View File

@@ -39,14 +39,21 @@ const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
interface TeamListProps { interface TeamListProps {
teams: TeamInfo[]; teams: TeamInfo[];
loading?: boolean; loading?: boolean;
onTeamClick?: (teamId: string) => void;
} }
const TeamList = ({ teams, loading = false }: TeamListProps) => { const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const handleClick = useCallback( const handleClick = useCallback(
(teamId: string) => navigate({ to: `/teams/${teamId}` }), (teamId: string) => {
[navigate] if (onTeamClick) {
onTeamClick(teamId);
} else {
navigate({ to: `/teams/${teamId}` });
}
},
[navigate, onTeamClick]
); );
if (loading) if (loading)

View File

@@ -36,6 +36,7 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
<Group py="xs" px="sm" w="100%" gap="sm" align="center"> <Group py="xs" px="sm" w="100%" gap="sm" align="center">
<Avatar <Avatar
size={32} size={32}
radius="sm"
name={team.name} name={team.name}
src={ src={
team.logo team.logo

View File

@@ -8,6 +8,7 @@ import {
PencilLineIcon, PencilLineIcon,
TreeStructureIcon, TreeStructureIcon,
UsersThreeIcon, UsersThreeIcon,
UsersIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import EditEnrolledTeams from "./edit-enrolled-teams"; import EditEnrolledTeams from "./edit-enrolled-teams";
@@ -60,6 +61,11 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
Icon={UsersThreeIcon} Icon={UsersThreeIcon}
onClick={openEditTeams} onClick={openEditTeams}
/> />
<ListLink
label="Manage Teams"
Icon={UsersIcon}
to={`/admin/tournaments/${tournamentId}/teams`}
/>
<ListLink <ListLink
label="Run Tournament" label="Run Tournament"
Icon={TreeStructureIcon} Icon={TreeStructureIcon}