regionals enrollments
This commit is contained in:
109
pb_migrations/1771294794_created_groups.js
Normal file
109
pb_migrations/1771294794_created_groups.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"createRule": null,
|
||||
"deleteRule": null,
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_340646327",
|
||||
"hidden": false,
|
||||
"id": "relation3177167065",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "tournament",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text1579384326",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number4113142680",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "order",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_1568971955",
|
||||
"hidden": false,
|
||||
"id": "relation2529305176",
|
||||
"maxSelect": 999,
|
||||
"minSelect": 0,
|
||||
"name": "teams",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate3332085495",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"id": "pbc_3346940990",
|
||||
"indexes": [],
|
||||
"listRule": null,
|
||||
"name": "groups",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_3346940990");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
52
pb_migrations/1771294861_updated_tournaments.js
Normal file
52
pb_migrations/1771294861_updated_tournaments.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("select3736761055")
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(13, new Field({
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text3736761055",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "format",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(13, new Field({
|
||||
"hidden": false,
|
||||
"id": "select3736761055",
|
||||
"maxSelect": 1,
|
||||
"name": "format",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"single_elim",
|
||||
"double_elim",
|
||||
"groups",
|
||||
"swiss",
|
||||
"swiss_bracket",
|
||||
"round_robin"
|
||||
]
|
||||
}))
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("text3736761055")
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
25
pb_migrations/1771294883_updated_tournaments.js
Normal file
25
pb_migrations/1771294883_updated_tournaments.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(14, new Field({
|
||||
"hidden": false,
|
||||
"id": "json118290348",
|
||||
"maxSize": 0,
|
||||
"name": "group_config",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("json118290348")
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
29
pb_migrations/1771294898_updated_tournaments.js
Normal file
29
pb_migrations/1771294898_updated_tournaments.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(15, new Field({
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text2982008523",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "phase",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("text2982008523")
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
47
pb_migrations/1771295070_updated_matches.js
Normal file
47
pb_migrations/1771295070_updated_matches.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(22, new Field({
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_3346940990",
|
||||
"hidden": false,
|
||||
"id": "relation1841317061",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "group",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}))
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(23, new Field({
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text3987859035",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "match_type",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("relation1841317061")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("text3987859035")
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -43,6 +43,7 @@ import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_aut
|
||||
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
|
||||
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
||||
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
|
||||
import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
|
||||
|
||||
const RefreshSessionRoute = RefreshSessionRouteImport.update({
|
||||
id: '/refresh-session',
|
||||
@@ -221,6 +222,12 @@ const AuthedAdminTournamentsIdTeamsRoute =
|
||||
path: '/tournaments/$id/teams',
|
||||
getParentRoute: () => AuthedAdminRoute,
|
||||
} as any)
|
||||
const AuthedAdminTournamentsIdAssignPartnersRoute =
|
||||
AuthedAdminTournamentsIdAssignPartnersRouteImport.update({
|
||||
id: '/tournaments/$id/assign-partners',
|
||||
path: '/tournaments/$id/assign-partners',
|
||||
getParentRoute: () => AuthedAdminRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof AuthedIndexRoute
|
||||
@@ -252,6 +259,7 @@ export interface FileRoutesByFullPath {
|
||||
'/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||
@@ -286,6 +294,7 @@ export interface FileRoutesByTo {
|
||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||
@@ -323,6 +332,7 @@ export interface FileRoutesById {
|
||||
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||
@@ -360,6 +370,7 @@ export interface FileRouteTypes {
|
||||
| '/tournaments/'
|
||||
| '/tournaments/$id/bracket'
|
||||
| '/admin/tournaments/'
|
||||
| '/admin/tournaments/$id/assign-partners'
|
||||
| '/admin/tournaments/$id/teams'
|
||||
| '/admin/tournaments/run/$id'
|
||||
| '/api/files/$collection/$recordId/$file'
|
||||
@@ -394,6 +405,7 @@ export interface FileRouteTypes {
|
||||
| '/tournaments'
|
||||
| '/tournaments/$id/bracket'
|
||||
| '/admin/tournaments'
|
||||
| '/admin/tournaments/$id/assign-partners'
|
||||
| '/admin/tournaments/$id/teams'
|
||||
| '/admin/tournaments/run/$id'
|
||||
| '/api/files/$collection/$recordId/$file'
|
||||
@@ -430,6 +442,7 @@ export interface FileRouteTypes {
|
||||
| '/_authed/tournaments/'
|
||||
| '/_authed/tournaments/$id/bracket'
|
||||
| '/_authed/admin/tournaments/'
|
||||
| '/_authed/admin/tournaments/$id/assign-partners'
|
||||
| '/_authed/admin/tournaments/$id/teams'
|
||||
| '/_authed/admin/tournaments/run/$id'
|
||||
| '/api/files/$collection/$recordId/$file'
|
||||
@@ -695,6 +708,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
|
||||
parentRoute: typeof AuthedAdminRoute
|
||||
}
|
||||
'/_authed/admin/tournaments/$id/assign-partners': {
|
||||
id: '/_authed/admin/tournaments/$id/assign-partners'
|
||||
path: '/tournaments/$id/assign-partners'
|
||||
fullPath: '/admin/tournaments/$id/assign-partners'
|
||||
preLoaderRoute: typeof AuthedAdminTournamentsIdAssignPartnersRouteImport
|
||||
parentRoute: typeof AuthedAdminRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -704,6 +724,7 @@ interface AuthedAdminRouteChildren {
|
||||
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
||||
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
||||
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
||||
AuthedAdminTournamentsIdAssignPartnersRoute: typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
|
||||
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
|
||||
@@ -715,6 +736,8 @@ const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
||||
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
||||
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
||||
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
||||
AuthedAdminTournamentsIdAssignPartnersRoute:
|
||||
AuthedAdminTournamentsIdAssignPartnersRoute,
|
||||
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
|
||||
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
|
||||
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
|
||||
|
||||
167
src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx
Normal file
167
src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries";
|
||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import { Stack, Text, Button, Alert, LoadingOverlay, Group } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams";
|
||||
import useConfirmTeamAssignments from "@/features/tournaments/hooks/use-confirm-team-assignments";
|
||||
import TeamAssignmentPreview from "@/features/tournaments/components/team-assignment-preview";
|
||||
import { WarningCircleIcon, ShuffleIcon, CheckCircleIcon } from "@phosphor-icons/react";
|
||||
import { PlayerInfo } from "@/features/players/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/tournaments/$id/assign-partners")({
|
||||
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 ${context.tournament.name}`,
|
||||
},
|
||||
}),
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
interface TeamAssignment {
|
||||
player1: PlayerInfo;
|
||||
player2: PlayerInfo;
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: freeAgents } = useFreeAgents(id);
|
||||
const [assignments, setAssignments] = useState<TeamAssignment[] | null>(null);
|
||||
const [currentSeed, setCurrentSeed] = useState<number | undefined>(undefined);
|
||||
|
||||
const generateMutation = useGenerateRandomTeams();
|
||||
const confirmMutation = useConfirmTeamAssignments();
|
||||
|
||||
const hasOddPlayers = freeAgents.length % 2 !== 0;
|
||||
const hasEnoughPlayers = freeAgents.length >= 2;
|
||||
|
||||
const handleGenerate = () => {
|
||||
generateMutation.mutate(
|
||||
{ data: { tournamentId: id, seed: currentSeed } },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setAssignments(result.assignments);
|
||||
setCurrentSeed(result.seed);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleReroll = () => {
|
||||
if (currentSeed === undefined) return;
|
||||
generateMutation.mutate(
|
||||
{ data: { tournamentId: id, seed: currentSeed + 1 } },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setAssignments(result.assignments);
|
||||
setCurrentSeed(result.seed);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!assignments) return;
|
||||
|
||||
const formattedAssignments = assignments.map((a) => ({
|
||||
player1Id: a.player1.id,
|
||||
player2Id: a.player2.id,
|
||||
teamName: a.teamName,
|
||||
}));
|
||||
|
||||
confirmMutation.mutate(
|
||||
{ data: { tournamentId: id, assignments: formattedAssignments } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: tournamentQueries.details(id).queryKey });
|
||||
queryClient.invalidateQueries({ queryKey: tournamentQueries.free_agents(id).queryKey });
|
||||
navigate({ to: "/admin/tournaments/$id", params: { id } });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg" pos="relative">
|
||||
<LoadingOverlay visible={confirmMutation.isPending} />
|
||||
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" align="baseline">
|
||||
<Text size="xl" fw={700}>
|
||||
{freeAgents.length}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{freeAgents.length === 1 ? "player enrolled" : "players enrolled"}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{!hasEnoughPlayers && (
|
||||
<Alert color="yellow" icon={<WarningCircleIcon size={16} />}>
|
||||
Need at least 2 players to create teams
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasOddPlayers && (
|
||||
<Alert color="red" icon={<WarningCircleIcon size={16} />}>
|
||||
Cannot create teams with an odd number of players. Please have one player unenroll.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!assignments && hasEnoughPlayers && !hasOddPlayers && (
|
||||
<Button
|
||||
leftSection={<ShuffleIcon size={18} />}
|
||||
onClick={handleGenerate}
|
||||
loading={generateMutation.isPending}
|
||||
>
|
||||
Generate Random Pairings
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{assignments && (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="lg" fw={600}>
|
||||
Partner Assignments
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<ShuffleIcon size={16} />}
|
||||
onClick={handleReroll}
|
||||
loading={generateMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
Re-roll
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<CheckCircleIcon size={18} />}
|
||||
onClick={handleConfirm}
|
||||
loading={confirmMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
Confirm & Create Teams
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<TeamAssignmentPreview assignments={assignments} />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { TeamInfo, Team } from "../teams/types";
|
||||
import { TournamentInfo } from "../tournaments/types";
|
||||
|
||||
export type MatchStatus = "tbd" | "ready" | "started" | "ended";
|
||||
export type MatchType = "group_stage" | "knockout" | "winners" | "losers" | "bracket";
|
||||
|
||||
export interface Match {
|
||||
id: string;
|
||||
@@ -29,6 +30,8 @@ export interface Match {
|
||||
updated: string;
|
||||
home_seed?: number;
|
||||
away_seed?: number;
|
||||
match_type?: MatchType;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export const matchInputSchema = z.object({
|
||||
@@ -53,6 +56,8 @@ export const matchInputSchema = z.object({
|
||||
away: z.string().min(1).optional(),
|
||||
home_seed: z.number().int().min(1).optional(),
|
||||
away_seed: z.number().int().min(1).optional(),
|
||||
match_type: z.enum(["group_stage", "knockout", "winners", "losers", "bracket"]).optional(),
|
||||
group: z.string().optional(),
|
||||
});
|
||||
|
||||
export type MatchInput = z.infer<typeof matchInputSchema>;
|
||||
|
||||
@@ -51,6 +51,7 @@ export const teamInputSchema = z
|
||||
song_start: z.number().int().optional(),
|
||||
song_end: z.number().int().optional(),
|
||||
song_image_url: z.url("Invalid song image URL").optional(),
|
||||
private: z.boolean().optional(),
|
||||
players: z.array(z.string()).min(1, "At least one player is required").max(10, "Maximum 10 players allowed"),
|
||||
})
|
||||
.refine(
|
||||
|
||||
162
src/features/tournaments/components/edit-enrolled-players.tsx
Normal file
162
src/features/tournaments/components/edit-enrolled-players.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
Stack,
|
||||
ActionIcon,
|
||||
Text,
|
||||
Group,
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import { TrashIcon } from "@phosphor-icons/react";
|
||||
import { useCallback, memo } from "react";
|
||||
import { useFreeAgents } from "../queries";
|
||||
import PlayerAvatar from "@/components/player-avatar";
|
||||
import { PlayerInfo, Player } from "@/features/players/types";
|
||||
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
||||
import { usePlayers } from "@/features/players/queries";
|
||||
import useAdminEnrollPlayer from "../hooks/use-admin-enroll-player";
|
||||
import useAdminUnenrollPlayer from "../hooks/use-admin-unenroll-player";
|
||||
|
||||
interface EditEnrolledPlayersProps {
|
||||
tournamentId: string;
|
||||
}
|
||||
|
||||
interface PlayerItemProps {
|
||||
player: PlayerInfo;
|
||||
onRemove: (playerId: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const PlayerItem = memo(({ player, onRemove, disabled }: PlayerItemProps) => {
|
||||
return (
|
||||
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
|
||||
<PlayerAvatar
|
||||
name={`${player.first_name} ${player.last_name}`}
|
||||
size={32}
|
||||
/>
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} truncate>
|
||||
{player.first_name} {player.last_name}
|
||||
</Text>
|
||||
</Stack>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => onRemove(player.id)}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
>
|
||||
<TrashIcon size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
|
||||
const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => {
|
||||
const { data: freeAgents = [], isLoading } = useFreeAgents(tournamentId);
|
||||
const { data: allPlayers = [] } = usePlayers();
|
||||
|
||||
const { mutate: removeFreeAgent, isPending: isRemoving } = useAdminUnenrollPlayer();
|
||||
const { mutate: enrollPlayer, isPending: isEnrolling } = useAdminEnrollPlayer();
|
||||
|
||||
const handleRemovePlayer = useCallback(
|
||||
(playerId: string) => {
|
||||
removeFreeAgent({ tournamentId, playerId });
|
||||
},
|
||||
[removeFreeAgent, tournamentId]
|
||||
);
|
||||
|
||||
const handleEnrollPlayer = useCallback(
|
||||
(option: TypeaheadOption<Player>) => {
|
||||
enrollPlayer({ tournamentId, playerId: option.data.id });
|
||||
},
|
||||
[enrollPlayer, tournamentId]
|
||||
);
|
||||
|
||||
const enrolledPlayers = freeAgents.map(agent => agent.player).filter((p): p is PlayerInfo => p !== undefined);
|
||||
const enrolledPlayerIds = new Set(enrolledPlayers.map(p => p.id));
|
||||
const hasEnrolledPlayers = enrolledPlayers.length > 0;
|
||||
|
||||
const searchPlayers = async (query: string): Promise<TypeaheadOption<Player>[]> => {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const filtered = allPlayers.filter((player: Player) => {
|
||||
const fullName = `${player.first_name} ${player.last_name}`.toLowerCase();
|
||||
return fullName.includes(query.toLowerCase()) && !enrolledPlayerIds.has(player.id);
|
||||
});
|
||||
|
||||
return filtered.map((player: Player) => ({
|
||||
id: player.id,
|
||||
data: player
|
||||
}));
|
||||
};
|
||||
|
||||
const renderPlayerOption = (option: TypeaheadOption<Player>) => {
|
||||
const player = option.data;
|
||||
return (
|
||||
<Group py="xs" px="sm" gap="sm" align="center">
|
||||
<PlayerAvatar
|
||||
name={`${player.first_name} ${player.last_name}`}
|
||||
size={32}
|
||||
/>
|
||||
<Text fw={500} truncate>
|
||||
{player.first_name} {player.last_name}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const formatPlayer = (option: TypeaheadOption<Player>) => {
|
||||
return `${option.data.first_name} ${option.data.last_name}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg" w="100%">
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
Add Player
|
||||
</Text>
|
||||
<Typeahead
|
||||
placeholder="Search for players to enroll..."
|
||||
onSelect={handleEnrollPlayer}
|
||||
searchFn={searchPlayers}
|
||||
renderOption={renderPlayerOption}
|
||||
format={formatPlayer}
|
||||
disabled={isEnrolling}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm">
|
||||
Enrolled Players
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{enrolledPlayers.length} players
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : !hasEnrolledPlayers ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
No players enrolled yet
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs" w="100%">
|
||||
{enrolledPlayers.map((player) => (
|
||||
<PlayerItem
|
||||
key={player.id}
|
||||
player={player}
|
||||
onRemove={handleRemovePlayer}
|
||||
disabled={isRemoving}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditEnrolledPlayers;
|
||||
@@ -9,11 +9,12 @@ import {
|
||||
TreeStructureIcon,
|
||||
UsersThreeIcon,
|
||||
UsersIcon,
|
||||
ShuffleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import EditEnrolledTeams from "./edit-enrolled-teams";
|
||||
import EditEnrolledPlayers from "./edit-enrolled-players";
|
||||
import ListLink from "@/components/list-link";
|
||||
import { RichTextEditor } from "@/components/rich-text-editor";
|
||||
import React from "react";
|
||||
import EditRules from "./edit-rules";
|
||||
|
||||
@@ -61,11 +62,20 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
||||
Icon={UsersThreeIcon}
|
||||
onClick={openEditTeams}
|
||||
/>
|
||||
<ListLink
|
||||
label="Manage Team Songs/Logos"
|
||||
Icon={UsersIcon}
|
||||
to={`/admin/tournaments/${tournamentId}/teams`}
|
||||
/>
|
||||
{tournament.regional && (
|
||||
<ListLink
|
||||
label="Assign Partners"
|
||||
Icon={ShuffleIcon}
|
||||
to={`/admin/tournaments/${tournamentId}/assign-partners`}
|
||||
/>
|
||||
)}
|
||||
{!tournament.regional && (
|
||||
<ListLink
|
||||
label="Manage Team Songs/Logos"
|
||||
Icon={UsersIcon}
|
||||
to={`/admin/tournaments/${tournamentId}/teams`}
|
||||
/>
|
||||
)}
|
||||
<ListLink
|
||||
label="Run Tournament"
|
||||
Icon={TreeStructureIcon}
|
||||
@@ -102,11 +112,15 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
||||
</Sheet>
|
||||
|
||||
<Sheet
|
||||
title="Edit Enrolled Teams"
|
||||
title={tournament.regional === true ? "Manage Enrollments" : "Edit Enrolled Teams"}
|
||||
opened={editTeamsOpened}
|
||||
onChange={closeEditTeams}
|
||||
>
|
||||
<EditEnrolledTeams tournamentId={tournamentId} />
|
||||
{tournament.regional === true ? (
|
||||
<EditEnrolledPlayers tournamentId={tournamentId} />
|
||||
) : (
|
||||
<EditEnrolledTeams tournamentId={tournamentId} />
|
||||
)}
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Card, Group, Stack, Text, Avatar } from "@mantine/core";
|
||||
import { PlayerInfo } from "@/features/players/types";
|
||||
import PlayerAvatar from "@/components/player-avatar";
|
||||
|
||||
interface TeamAssignment {
|
||||
player1: PlayerInfo;
|
||||
player2: PlayerInfo;
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
interface TeamAssignmentPreviewProps {
|
||||
assignments: TeamAssignment[];
|
||||
}
|
||||
|
||||
const TeamAssignmentPreview: React.FC<TeamAssignmentPreviewProps> = ({ assignments }) => {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{assignments.map((assignment, index) => (
|
||||
<Card key={index} withBorder radius="md" p="md">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<Text size="sm" fw={600} c="dimmed" w={40}>
|
||||
#{index + 1}
|
||||
</Text>
|
||||
|
||||
<Group gap="sm" style={{ flex: 1 }} align="center">
|
||||
<PlayerAvatar name={`${assignment.player1.first_name} ${assignment.player1.last_name}`} size={32} />
|
||||
<Text size="sm" fw={500}>
|
||||
{assignment.player1.first_name} {assignment.player1.last_name}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text size="lg" c="dimmed">
|
||||
&
|
||||
</Text>
|
||||
|
||||
<Group gap="sm" style={{ flex: 1 }} align="center">
|
||||
<PlayerAvatar name={`${assignment.player2.first_name} ${assignment.player2.last_name}`} size={32} />
|
||||
<Text size="sm" fw={500}>
|
||||
{assignment.player2.first_name} {assignment.player2.last_name}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamAssignmentPreview;
|
||||
@@ -5,13 +5,12 @@ import { useSheet } from "@/hooks/use-sheet";
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import useEnrollFreeAgent from "../../hooks/use-enroll-free-agent";
|
||||
|
||||
const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
|
||||
const EnrollFreeAgent = ({ tournamentId, isRegional }: {tournamentId: string, isRegional?: boolean} ) => {
|
||||
const { open, isOpen, toggle } = useSheet();
|
||||
const { user, phone } = useAuth();
|
||||
|
||||
const { mutate: enrollFreeAgent, isPending: isEnrolling } = useEnrollFreeAgent();
|
||||
const { mutate: enrollFreeAgent, isPending: isEnrolling } = useEnrollFreeAgent(isRegional);
|
||||
const handleEnroll = () => {
|
||||
console.log('enrolling...')
|
||||
enrollFreeAgent({ playerId: user!.id, tournamentId, phone }, {
|
||||
onSuccess: () => {
|
||||
toggle();
|
||||
@@ -22,21 +21,31 @@ const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
|
||||
return (
|
||||
<>
|
||||
<Button variant="subtle" size="sm" onClick={open}>
|
||||
Enroll As Free Agent
|
||||
{isRegional ? "Enroll" : "Enroll As Free Agent"}
|
||||
</Button>
|
||||
|
||||
<Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}>
|
||||
<Sheet title={isRegional ? "Enrollment" : "Free Agent Enrollment"} opened={isOpen} onChange={toggle}>
|
||||
<Stack gap="xs">
|
||||
<Text size="md">
|
||||
Enrolling as a free agent adds you to a pool of players looking for teammates.
|
||||
</Text>
|
||||
<Text size="sm" c='dimmed'>
|
||||
Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs.
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Important: Enrolling as a free agent does not guarantee a tournament spot. To secure a spot, one team member must register through the app and select a walkout song.
|
||||
</Text>
|
||||
<Button onClick={handleEnroll}>Confirm</Button>
|
||||
{isRegional ? (
|
||||
<>
|
||||
<Text size="md">
|
||||
Enroll in this regional tournament to be assigned a random partner.
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size="md">
|
||||
Enrolling as a free agent adds you to a pool of players looking for teammates.
|
||||
</Text>
|
||||
<Text size="sm" c='dimmed'>
|
||||
Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs.
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Important: Enrolling as a free agent does not guarantee a tournament spot. To secure a spot, one team member must register through the app and select a walkout song.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={handleEnroll} loading={isEnrolling}>Confirm</Button>
|
||||
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
|
||||
</Stack>
|
||||
</Sheet>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Group, Stack, Text, Card, Badge, Box, ActionIcon } from "@mantine/core";
|
||||
import { UserIcon, PhoneIcon } from "@phosphor-icons/react";
|
||||
import { PhoneIcon, CheckCircleIcon } from "@phosphor-icons/react";
|
||||
import { useFreeAgents } from "../../queries";
|
||||
import UnenrollFreeAgent from "./unenroll-free-agent";
|
||||
import toast from "@/lib/sonner";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import PlayerAvatar from "@/components/player-avatar";
|
||||
|
||||
const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
|
||||
tournamentId
|
||||
const EnrolledFreeAgent: React.FC<{ tournamentId: string, isRegional?: boolean }> = ({
|
||||
tournamentId,
|
||||
isRegional
|
||||
}) => {
|
||||
const { data: freeAgents } = useFreeAgents(tournamentId);
|
||||
const { user } = useAuth();
|
||||
|
||||
const copyToClipboard = async (phone: string) => {
|
||||
try {
|
||||
@@ -38,33 +42,66 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (isRegional) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="md" align="center">
|
||||
<PlayerAvatar name={`${user?.first_name} ${user?.last_name}`} size={48} />
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{user?.first_name} {user?.last_name}
|
||||
</Text>
|
||||
<Group gap={4} align="center">
|
||||
<CheckCircleIcon size={14} weight="fill" color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" c="green" fw={500}>
|
||||
Enrolled
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
Partners will be randomly assigned when enrollment closes
|
||||
</Text>
|
||||
|
||||
<UnenrollFreeAgent tournamentId={tournamentId} isRegional={isRegional} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="xs" align="center">
|
||||
<UserIcon size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
Enrolled as Free Agent
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600} c="green">
|
||||
✓ Enrolled as Free Agent
|
||||
</Text>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
You're on the free agent list. Other free agents looking for teams:
|
||||
Other players looking for teammates:
|
||||
</Text>
|
||||
|
||||
{freeAgents.length > 1 ? (
|
||||
<Card withBorder radius="md" p="sm">
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
Free Agents
|
||||
</Text>
|
||||
<Badge variant="light" size="xs" color="blue">
|
||||
{freeAgents.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Stack gap="xs">
|
||||
{freeAgents
|
||||
.filter(agent => agent.player)
|
||||
.map((agent) => (
|
||||
<Group key={agent.id} justify="space-between" align="center" wrap="nowrap">
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{agent.player?.first_name} {agent.player?.last_name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Group key={agent.id} justify="space-between" align="center" wrap="nowrap" p="xs" style={{ borderRadius: '8px', backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{agent.player?.first_name} {agent.player?.last_name}
|
||||
</Text>
|
||||
{agent.phone && (
|
||||
<Group gap={4} align="center" style={{ flexShrink: 0 }}>
|
||||
<ActionIcon
|
||||
@@ -87,27 +124,15 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
|
||||
{freeAgents.length > 1 && (
|
||||
<Badge
|
||||
variant="light"
|
||||
size="xs"
|
||||
color="blue"
|
||||
style={{ alignSelf: 'flex-start', marginTop: '4px' }}
|
||||
>
|
||||
{freeAgents.length} free agents total
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
) : (
|
||||
<Card withBorder radius="md" p="sm">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
You're the only free agent so far
|
||||
</Text>
|
||||
</Card>
|
||||
<Text size="xs" c="dimmed" py="sm">
|
||||
You're the first free agent!
|
||||
</Text>
|
||||
)}
|
||||
<UnenrollFreeAgent tournamentId={tournamentId} />
|
||||
|
||||
<UnenrollFreeAgent tournamentId={tournamentId} isRegional={isRegional} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import ListButton from "@/components/list-button";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import { UserListIcon } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
import { useFreeAgents } from "../../queries";
|
||||
import { Text } from "@mantine/core";
|
||||
import PlayerList from "@/features/players/components/player-list";
|
||||
import { Player } from "@/features/players/types";
|
||||
|
||||
interface EnrolledPlayersListButtonProps {
|
||||
tournamentId: string;
|
||||
}
|
||||
|
||||
const EnrolledPlayersListButton: React.FC<EnrolledPlayersListButtonProps> = ({ tournamentId }) => {
|
||||
const { data: freeAgents } = useFreeAgents(tournamentId);
|
||||
const count = useMemo(() => freeAgents.length, [freeAgents]);
|
||||
const { open, isOpen, toggle } = useSheet();
|
||||
|
||||
const players = useMemo(() =>
|
||||
freeAgents.map(agent => agent.player).filter((player): player is Player => player !== undefined),
|
||||
[freeAgents]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListButton
|
||||
label={`View Enrolled Players (${count})`}
|
||||
Icon={UserListIcon}
|
||||
onClick={open}
|
||||
/>
|
||||
|
||||
<Sheet title="Enrolled Players" opened={isOpen} onChange={toggle}>
|
||||
{count === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No players enrolled yet
|
||||
</Text>
|
||||
) : (
|
||||
<PlayerList players={players} />
|
||||
)}
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnrolledPlayersListButton;
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Suspense, useCallback, useMemo } from "react";
|
||||
import { Suspense, useMemo } from "react";
|
||||
import { Tournament } from "../../types";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { Box, Button, Card, Divider, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { Box, Card, Divider, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import Countdown from "@/components/countdown";
|
||||
import ListLink from "@/components/list-link";
|
||||
import ListButton from "@/components/list-button";
|
||||
import { TreeStructureIcon, UsersIcon } from "@phosphor-icons/react";
|
||||
import EnrollTeam from "./enroll-team";
|
||||
import EnrollFreeAgent from "./enroll-free-agent";
|
||||
import TeamListButton from "./team-list-button";
|
||||
import EnrolledPlayersListButton from "./enrolled-players-list-button";
|
||||
import Header from "./header";
|
||||
import TeamCardSkeleton from "@/features/teams/components/team-card-skeleton";
|
||||
import TeamCard from "@/features/teams/components/team-card";
|
||||
@@ -80,12 +80,19 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
|
||||
{!isUserEnrolled && isEnrollmentOpen && !isFreeAgent && (
|
||||
<>
|
||||
<EnrollTeam
|
||||
{!tournament.regional && (
|
||||
<>
|
||||
<EnrollTeam
|
||||
tournamentId={tournament.id}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
<Divider my={0} label="or" />
|
||||
</>
|
||||
)}
|
||||
<EnrollFreeAgent
|
||||
tournamentId={tournament.id}
|
||||
onSubmit={handleSubmit}
|
||||
isRegional={tournament.regional}
|
||||
/>
|
||||
<Divider my={0} label="or" />
|
||||
<EnrollFreeAgent tournamentId={tournament.id} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -107,7 +114,10 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
|
||||
{
|
||||
isFreeAgent && isEnrollmentOpen && (
|
||||
<EnrolledFreeAgent tournamentId={tournament.id} />
|
||||
<EnrolledFreeAgent
|
||||
tournamentId={tournament.id}
|
||||
isRegional={tournament.regional}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -130,7 +140,11 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
Icon={TreeStructureIcon}
|
||||
disabled
|
||||
/>
|
||||
<TeamListButton teams={tournament.teams || []} />
|
||||
{tournament.regional === true ? (
|
||||
<EnrolledPlayersListButton tournamentId={tournament.id} />
|
||||
) : (
|
||||
<TeamListButton teams={tournament.teams || []} />
|
||||
)}
|
||||
<RulesListButton tournamentId={tournament.id} />
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
@@ -5,11 +5,11 @@ import { useSheet } from "@/hooks/use-sheet";
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import useUnenrollFreeAgent from "../../hooks/use-unenroll-free-agent";
|
||||
|
||||
const UnenrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
|
||||
const UnenrollFreeAgent = ({ tournamentId, isRegional }: {tournamentId: string, isRegional?: boolean} ) => {
|
||||
const { open, isOpen, toggle } = useSheet();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { mutate: unenrollFreeAgent, isPending: isEnrolling } = useUnenrollFreeAgent();
|
||||
const { mutate: unenrollFreeAgent, isPending: isEnrolling } = useUnenrollFreeAgent(isRegional);
|
||||
const handleUnenroll = () => {
|
||||
unenrollFreeAgent({ playerId: user!.id, tournamentId }, {
|
||||
onSuccess: () => {
|
||||
@@ -20,17 +20,21 @@ const UnenrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="subtle" size="sm" onClick={open}>
|
||||
<Button variant="subtle" size="sm" color="red" onClick={open}>
|
||||
Unenroll
|
||||
</Button>
|
||||
|
||||
<Sheet title="Are you sure?" opened={isOpen} onChange={toggle}>
|
||||
<Sheet title="Unenroll from tournament?" opened={isOpen} onChange={toggle}>
|
||||
<Stack gap="xs">
|
||||
<Text size="md">
|
||||
This will remove you from the free agent list.
|
||||
{isRegional
|
||||
? "This will remove you from the tournament enrollment."
|
||||
: "This will remove you from the free agent list."}
|
||||
</Text>
|
||||
<Button onClick={handleUnenroll}>Confirm</Button>
|
||||
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
|
||||
<Button color="red" onClick={handleUnenroll} loading={isEnrolling}>
|
||||
Confirm Unenrollment
|
||||
</Button>
|
||||
<Button variant="subtle" onClick={toggle}>Cancel</Button>
|
||||
</Stack>
|
||||
</Sheet>
|
||||
</>
|
||||
|
||||
21
src/features/tournaments/hooks/use-admin-enroll-player.ts
Normal file
21
src/features/tournaments/hooks/use-admin-enroll-player.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||
import { adminEnrollPlayer } from "@/features/tournaments/server";
|
||||
import { tournamentKeys } from "../queries";
|
||||
|
||||
const useAdminEnrollPlayer = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useServerMutation({
|
||||
mutationFn: (data: { tournamentId: string, playerId: string }) => {
|
||||
return adminEnrollPlayer({ data });
|
||||
},
|
||||
onSuccess: (data, { tournamentId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
|
||||
queryClient.invalidateQueries({ queryKey: tournamentKeys.details(tournamentId) });
|
||||
},
|
||||
successMessage: "Player enrolled successfully",
|
||||
});
|
||||
};
|
||||
|
||||
export default useAdminEnrollPlayer;
|
||||
21
src/features/tournaments/hooks/use-admin-unenroll-player.ts
Normal file
21
src/features/tournaments/hooks/use-admin-unenroll-player.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||
import { adminUnenrollPlayer } from "@/features/tournaments/server";
|
||||
import { tournamentKeys } from "../queries";
|
||||
|
||||
const useAdminUnenrollPlayer = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useServerMutation({
|
||||
mutationFn: (data: { tournamentId: string, playerId: string }) => {
|
||||
return adminUnenrollPlayer({ data });
|
||||
},
|
||||
onSuccess: (data, { tournamentId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
|
||||
queryClient.invalidateQueries({ queryKey: tournamentKeys.details(tournamentId) });
|
||||
},
|
||||
successMessage: "Player removed successfully",
|
||||
});
|
||||
};
|
||||
|
||||
export default useAdminUnenrollPlayer;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
|
||||
import { confirmTeamAssignments } from "../server";
|
||||
|
||||
export default function useConfirmTeamAssignments() {
|
||||
return useServerMutation({
|
||||
mutationFn: confirmTeamAssignments,
|
||||
successMessage: "Teams created successfully!",
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||
import { enrollFreeAgent } from "@/features/tournaments/server";
|
||||
import { tournamentKeys } from "../queries";
|
||||
|
||||
const useEnrollFreeAgent = () => {
|
||||
const useEnrollFreeAgent = (isRegional?: boolean) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useServerMutation({
|
||||
@@ -13,7 +13,7 @@ const useEnrollFreeAgent = () => {
|
||||
onSuccess: (data, { tournamentId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
|
||||
},
|
||||
successMessage: 'You\'ve been added as a free agent!',
|
||||
successMessage: isRegional ? "You've enrolled in regionals!" : "You've been added as a free agent!",
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
|
||||
import { generateRandomTeams } from "../server";
|
||||
|
||||
export default function useGenerateRandomTeams() {
|
||||
return useServerMutation({
|
||||
mutationFn: generateRandomTeams,
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||
import { unenrollFreeAgent } from "@/features/tournaments/server";
|
||||
import { tournamentKeys } from "../queries";
|
||||
|
||||
const useUnenrollFreeAgent = () => {
|
||||
const useUnenrollFreeAgent = (isRegional?: boolean) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useServerMutation({
|
||||
@@ -13,7 +13,7 @@ const useUnenrollFreeAgent = () => {
|
||||
onSuccess: (data, { tournamentId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
|
||||
},
|
||||
successMessage: 'You\'ve been removed as a free agent.',
|
||||
successMessage: isRegional ? "You've been removed from regionals!" : "You've been removed as a free agent.",
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -129,3 +129,260 @@ export const unenrollFreeAgent = createServerFn()
|
||||
logger.info('Player unenrolled as free agent', { playerId: player.id });
|
||||
})
|
||||
);
|
||||
|
||||
export const generateRandomTeams = createServerFn()
|
||||
.inputValidator(z.object({
|
||||
tournamentId: z.string(),
|
||||
seed: z.number().optional()
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
const freeAgents = await pbAdmin.getFreeAgents(data.tournamentId);
|
||||
|
||||
if (freeAgents.length < 2) {
|
||||
throw new Error("Need at least 2 players to create teams");
|
||||
}
|
||||
|
||||
if (freeAgents.length % 2 !== 0) {
|
||||
throw new Error("Need an even number of players to create teams");
|
||||
}
|
||||
|
||||
const playerIds = freeAgents.map(fa => fa.player?.id).filter(Boolean) as string[];
|
||||
|
||||
const allTeams = await pbAdmin.getTeamsWithFilter(
|
||||
playerIds.map(id => `players.id ?= "${id}"`).join(" || "),
|
||||
"players,tournaments"
|
||||
);
|
||||
|
||||
const invalidPairings = new Set<string>();
|
||||
const mostRecentRegionalPartners = new Map<string, string>();
|
||||
|
||||
let mostRecentRegionalDate: Date | null = null;
|
||||
|
||||
for (const team of allTeams) {
|
||||
const teamPlayers = (team.expand?.players || []) as any[];
|
||||
if (teamPlayers.length !== 2) continue;
|
||||
|
||||
const [p1, p2] = teamPlayers.map((p: any) => p.id).sort();
|
||||
const pairKey = `${p1}|${p2}`;
|
||||
|
||||
const teamTournaments = (team.expand?.tournaments || []) as any[];
|
||||
const hasMainlineTournament = teamTournaments.some((t: any) => !t.regional);
|
||||
|
||||
if (hasMainlineTournament) {
|
||||
invalidPairings.add(pairKey);
|
||||
} else if (team.private && teamTournaments.length > 0) {
|
||||
const regionalTournaments = teamTournaments.filter((t: any) => t.regional);
|
||||
for (const tournament of regionalTournaments) {
|
||||
const tournamentDate = new Date(tournament.start_time);
|
||||
if (!mostRecentRegionalDate || tournamentDate > mostRecentRegionalDate) {
|
||||
mostRecentRegionalDate = tournamentDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mostRecentRegionalDate) {
|
||||
for (const team of allTeams) {
|
||||
if (!team.private) continue;
|
||||
|
||||
const teamPlayers = (team.expand?.players || []) as any[];
|
||||
if (teamPlayers.length !== 2) continue;
|
||||
|
||||
const teamTournaments = (team.expand?.tournaments || []) as any[];
|
||||
const regionalTournaments = teamTournaments.filter((t: any) => t.regional);
|
||||
|
||||
for (const tournament of regionalTournaments) {
|
||||
const tournamentDate = new Date(tournament.start_time);
|
||||
if (tournamentDate.getTime() === mostRecentRegionalDate.getTime()) {
|
||||
const [p1Id, p2Id] = teamPlayers.map((p: any) => p.id);
|
||||
mostRecentRegionalPartners.set(p1Id, p2Id);
|
||||
mostRecentRegionalPartners.set(p2Id, p1Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canPairPlayers(p1Id: string, p2Id: string): boolean {
|
||||
const pairKey = [p1Id, p2Id].sort().join('|');
|
||||
if (invalidPairings.has(pairKey)) return false;
|
||||
|
||||
const p1LastPartner = mostRecentRegionalPartners.get(p1Id);
|
||||
if (p1LastPartner === p2Id) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const seed = data.seed || Math.floor(Math.random() * 1000000);
|
||||
|
||||
function seededRandom(s: number) {
|
||||
const x = Math.sin(s++) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
let currentSeed = seed;
|
||||
const shuffled = [...freeAgents];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(seededRandom(currentSeed++) * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
|
||||
const assignments = [];
|
||||
const paired = new Set<string>();
|
||||
const MAX_ATTEMPTS = 1000;
|
||||
let attempts = 0;
|
||||
|
||||
while (paired.size < shuffled.length && attempts < MAX_ATTEMPTS) {
|
||||
attempts++;
|
||||
|
||||
for (let i = 0; i < shuffled.length; i++) {
|
||||
if (paired.has(shuffled[i].player!.id)) continue;
|
||||
|
||||
for (let j = i + 1; j < shuffled.length; j++) {
|
||||
if (paired.has(shuffled[j].player!.id)) continue;
|
||||
|
||||
const player1 = shuffled[i].player!;
|
||||
const player2 = shuffled[j].player!;
|
||||
|
||||
if (canPairPlayers(player1.id, player2.id)) {
|
||||
const teamName = `${player1.first_name} And ${player2.first_name}`;
|
||||
|
||||
assignments.push({
|
||||
player1,
|
||||
player2,
|
||||
teamName
|
||||
});
|
||||
|
||||
paired.add(player1.id);
|
||||
paired.add(player2.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (paired.size < shuffled.length) {
|
||||
currentSeed++;
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(seededRandom(currentSeed++) * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
assignments.length = 0;
|
||||
paired.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (paired.size < shuffled.length) {
|
||||
throw new Error("Unable to create valid pairings with current restrictions. Please manually adjust enrollments.");
|
||||
}
|
||||
|
||||
logger.info('Generated random team assignments with restrictions', {
|
||||
tournamentId: data.tournamentId,
|
||||
teamCount: assignments.length,
|
||||
seed,
|
||||
attempts
|
||||
});
|
||||
|
||||
return { assignments, seed };
|
||||
})
|
||||
);
|
||||
|
||||
export const confirmTeamAssignments = createServerFn()
|
||||
.inputValidator(z.object({
|
||||
tournamentId: z.string(),
|
||||
assignments: z.array(z.object({
|
||||
player1Id: z.string(),
|
||||
player2Id: z.string(),
|
||||
teamName: z.string()
|
||||
}))
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
const createdTeams = [];
|
||||
let reusedCount = 0;
|
||||
|
||||
for (const assignment of data.assignments) {
|
||||
const existingTeams = await pbAdmin.getTeamsWithFilter(
|
||||
`private = true && players.id ?= "${assignment.player1Id}" && players.id ?= "${assignment.player2Id}"`,
|
||||
"players,tournaments"
|
||||
);
|
||||
|
||||
let teamToUse = null;
|
||||
|
||||
for (const team of existingTeams) {
|
||||
const teamPlayers = (team.expand?.players || []) as any[];
|
||||
|
||||
if (teamPlayers.length !== 2) continue;
|
||||
|
||||
const playerIds = teamPlayers.map((p: any) => p.id).sort();
|
||||
const assignmentIds = [assignment.player1Id, assignment.player2Id].sort();
|
||||
if (playerIds[0] !== assignmentIds[0] || playerIds[1] !== assignmentIds[1]) continue;
|
||||
|
||||
const teamTournaments = (team.expand?.tournaments || []) as any[];
|
||||
const hasMainlineTournament = teamTournaments.some((t: any) => !t.regional);
|
||||
|
||||
if (!hasMainlineTournament) {
|
||||
teamToUse = team;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (teamToUse) {
|
||||
await pbAdmin.enrollTeam(data.tournamentId, teamToUse.id);
|
||||
createdTeams.push(teamToUse);
|
||||
reusedCount++;
|
||||
logger.info('Reusing existing regional team', { teamId: teamToUse.id, teamName: teamToUse.name });
|
||||
} else {
|
||||
const team = await pbAdmin.createTeam({
|
||||
name: assignment.teamName,
|
||||
players: [assignment.player1Id, assignment.player2Id],
|
||||
private: true
|
||||
});
|
||||
|
||||
await pbAdmin.enrollTeam(data.tournamentId, team.id);
|
||||
createdTeams.push(team);
|
||||
}
|
||||
}
|
||||
|
||||
for (const assignment of data.assignments) {
|
||||
await pbAdmin.unenrollFreeAgent(assignment.player1Id, data.tournamentId);
|
||||
await pbAdmin.unenrollFreeAgent(assignment.player2Id, data.tournamentId);
|
||||
}
|
||||
|
||||
logger.info('Confirmed team assignments', {
|
||||
tournamentId: data.tournamentId,
|
||||
teamCount: createdTeams.length,
|
||||
reusedCount,
|
||||
newCount: createdTeams.length - reusedCount
|
||||
});
|
||||
|
||||
return { teams: createdTeams };
|
||||
})
|
||||
);
|
||||
|
||||
export const adminEnrollPlayer = createServerFn()
|
||||
.inputValidator(z.object({
|
||||
playerId: z.string(),
|
||||
tournamentId: z.string()
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
await pbAdmin.enrollFreeAgent(data.playerId, "", data.tournamentId);
|
||||
logger.info('Admin enrolled player', { playerId: data.playerId, tournamentId: data.tournamentId });
|
||||
})
|
||||
);
|
||||
|
||||
export const adminUnenrollPlayer = createServerFn()
|
||||
.inputValidator(z.object({
|
||||
playerId: z.string(),
|
||||
tournamentId: z.string()
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
await pbAdmin.unenrollFreeAgent(data.playerId, data.tournamentId);
|
||||
logger.info('Admin unenrolled player', { playerId: data.playerId, tournamentId: data.tournamentId });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -2,6 +2,27 @@ import { TeamInfo } from "@/features/teams/types";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import { z } from "zod";
|
||||
|
||||
export type TournamentFormat = "double_elim" | "group_single_elim";
|
||||
export type TournamentPhase = "seeding" | "group_stage" | "knockout" | "completed";
|
||||
|
||||
export interface GroupConfig {
|
||||
num_groups: number;
|
||||
teams_per_group: number;
|
||||
advance_per_group: number;
|
||||
matches_guaranteed: number;
|
||||
seeding_method: "random" | "ranked";
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
tournament: string;
|
||||
name: string;
|
||||
order: number;
|
||||
teams: TeamInfo[];
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface TournamentTeamStats {
|
||||
id: string;
|
||||
team_id: string;
|
||||
@@ -52,6 +73,10 @@ export interface Tournament {
|
||||
third_place?: TeamInfo;
|
||||
team_stats?: TournamentTeamStats[];
|
||||
regional?: boolean;
|
||||
format?: TournamentFormat;
|
||||
group_config?: GroupConfig;
|
||||
phase?: TournamentPhase;
|
||||
groups?: Group[];
|
||||
}
|
||||
|
||||
export const tournamentInputSchema = z.object({
|
||||
|
||||
@@ -6,8 +6,6 @@ import { createMatchesService } from "./services/matches";
|
||||
import { createReactionsService } from "./services/reactions";
|
||||
import { createActivitiesService } from "./services/activities";
|
||||
import { createBadgesService } from "./services/badges";
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
class PocketBaseAdminClient {
|
||||
private pb: PocketBase;
|
||||
|
||||
@@ -110,5 +110,14 @@ export function createTeamsService(pb: PocketBase) {
|
||||
|
||||
return result.map((match) => transformMatch(match));
|
||||
},
|
||||
|
||||
async getTeamsWithFilter(filter: string, expand?: string): Promise<any[]> {
|
||||
logger.info("PocketBase | Getting teams with filter", { filter, expand });
|
||||
const result = await pb.collection("teams").getFullList({
|
||||
filter,
|
||||
expand,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user