work on team enrollment

This commit is contained in:
yohlo
2025-09-16 09:24:21 -05:00
parent 9a105b30c6
commit cde74a04d5
45 changed files with 1244 additions and 457 deletions

View File

@@ -0,0 +1,80 @@
import { ColorPicker, TextInput, Stack, Group, ColorSwatch, Text } from '@mantine/core';
import React, { useState, useCallback, useMemo } from 'react';
const presetColors = [
'#FF0000', '#00FF00', '#0000FF', '#FFFF00',
'#FF00FF', '#00FFFF', '#FFA500', '#800080',
'#008000', '#000080', '#800000', '#808000'
];
interface TeamColorPickerProps {
value: string;
onChange: (value: string) => void;
label?: string;
}
const TeamColorPicker: React.FC<TeamColorPickerProps> = ({
value,
onChange,
label = "Select Color"
}) => {
const [customHex, setCustomHex] = useState(value || '');
const isValidHex = useMemo(() => {
const hexRegex = /^#[0-9A-F]{6}$/i;
return hexRegex.test(customHex);
}, [customHex]);
const handleColorChange = useCallback((color: string) => {
setCustomHex(color);
onChange(color);
}, [onChange]);
const handleHexInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const hex = event.currentTarget.value;
setCustomHex(hex);
if (/^#[0-9A-F]{6}$/i.test(hex)) {
onChange(hex);
}
}, [onChange]);
return (
<Stack gap="md" p="md" w="100%">
<Text fw={500}>{label}</Text>
<ColorPicker
value={value || '#000000'}
onChange={handleColorChange}
format="hex"
swatches={presetColors}
withPicker={true}
fullWidth
size="md"
swatchesPerRow={12}
/>
<Group gap="xs" align="flex-end">
<TextInput
style={{ flex: 1 }}
label="Custom Hex Code"
placeholder="#FF0000"
value={customHex}
onChange={handleHexInputChange}
error={customHex && !isValidHex ? 'Invalid hex color format' : undefined}
w="100%"
/>
{isValidHex && (
<ColorSwatch
color={customHex}
size={36}
style={{ marginBottom: customHex && !isValidHex ? 24 : 0 }}
/>
)}
</Group>
</Stack>
);
};
export default TeamColorPicker;

View File

@@ -1,81 +1,88 @@
import { FileInput, Stack, TextInput, Textarea } from "@mantine/core";
import { Badge, FileInput, Group, Stack, Text, TextInput } from "@mantine/core";
import { useForm, UseFormInput } from "@mantine/form";
import { LinkIcon } from "@phosphor-icons/react";
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
import { TournamentInput } from "@/features/tournaments/types";
import { isNotEmpty } from "@mantine/form";
import useCreateTournament from "../hooks/use-create-team";
import useUpdateTournament from "../hooks/use-update-tournament";
import useCreateTeam from "../../hooks/use-create-team";
import useUpdateTeam from "../../hooks/use-update-team";
import toast from "@/lib/sonner";
import { logger } from "..";
import { logger } from "../..";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentKeys } from "@/features/tournaments/queries";
import { useCallback } from "react";
import { DateTimePicker } from "@/components/date-time-picker";
import { TeamInput } from "../../types";
import { teamKeys } from "../../queries";
import SongPicker from "./song-picker";
import TeamColorPicker from "./color-picker";
import PlayersPicker from "./players-picker";
interface TournamentFormProps {
interface TeamFormProps {
close: () => void;
initialValues?: Partial<TournamentInput>;
initialValues?: Partial<TeamInput>;
teamId?: string;
tournamentId?: string;
}
const TournamentForm = ({
const TeamForm = ({
close,
initialValues,
teamId,
tournamentId,
}: TournamentFormProps) => {
const isEditMode = !!tournamentId;
}: TeamFormProps) => {
const isEditMode = !!teamId;
const config: UseFormInput<TournamentInput> = {
const config: UseFormInput<TeamInput> = {
initialValues: {
name: initialValues?.name || "",
location: initialValues?.location || "",
desc: initialValues?.desc || "",
start_time: initialValues?.start_time || "",
enroll_time: initialValues?.enroll_time || "",
end_time: initialValues?.end_time || "",
primary_color: initialValues?.primary_color,
accent_color: initialValues?.accent_color,
song_id: initialValues?.song_id,
song_name: initialValues?.song_name,
song_artist: initialValues?.song_artist,
song_album: initialValues?.song_album,
song_year: initialValues?.song_year,
song_start: initialValues?.song_start,
song_end: initialValues?.song_end,
song_image_url: initialValues?.song_image_url,
logo: undefined,
players: initialValues?.players || []
},
onSubmitPreventDefault: "always",
validate: {
name: isNotEmpty("Name is required"),
location: isNotEmpty("Location is required"),
start_time: isNotEmpty("Start time is required"),
enroll_time: isNotEmpty("Enrollment time is required"),
players: (value: string[]) => value.length > 1 && value[1] !== '' ? undefined : "Players are required"
},
};
const form = useForm(config);
const queryClient = useQueryClient();
const { mutate: createTournament, isPending: createPending } =
useCreateTournament();
const { mutate: updateTournament, isPending: updatePending } =
useUpdateTournament(tournamentId || "");
const { mutate: createTournament, isPending: createPending } = useCreateTeam();
const { mutate: updateTournament, isPending: updatePending } = useUpdateTeam(teamId!);
const isPending = createPending || updatePending;
const handleSubmit = useCallback(
async (values: TournamentInput) => {
const { logo, ...tournamentData } = values;
async (values: TeamInput) => {
const { logo, ...teamData } = values;
const mutation = isEditMode ? updateTournament : createTournament;
const successMessage = isEditMode
? "Tournament updated successfully!"
: "Tournament created successfully!";
? "Team updated successfully!"
: "Team created successfully!";
const errorMessage = isEditMode
? "Failed to update tournament"
: "Failed to create tournament";
? "Failed to update team"
: "Failed to create team";
mutation(tournamentData, {
onSuccess: async (tournament) => {
if (logo && tournament) {
mutation(teamData, {
onSuccess: async (team) => {
if (logo && team) {
try {
const formData = new FormData();
formData.append("tournamentId", tournament.id);
formData.append("teamId", team.id);
formData.append("logo", logo);
const response = await fetch("/api/tournaments/upload-logo", {
const response = await fetch("/api/teams/upload-logo", {
method: "POST",
body: formData,
});
@@ -87,22 +94,22 @@ const TournamentForm = ({
const result = await response.json();
queryClient.invalidateQueries({ queryKey: tournamentKeys.list });
queryClient.invalidateQueries({ queryKey: teamKeys.list });
queryClient.invalidateQueries({
queryKey: tournamentKeys.details(result.tournament!.id),
queryKey: teamKeys.details(result.team!.id),
});
queryClient.setQueryData(
tournamentKeys.details(result.tournament!.id),
result.tournament
tournamentKeys.details(result.team!.id),
result.team
);
toast.success(successMessage);
} catch (error: any) {
const logoErrorMessage = isEditMode
? `Tournament updated but logo upload failed: ${error.message}`
: `Tournament created but logo upload failed: ${error.message}`;
? `Team updated but logo upload failed: ${error.message}`
: `Team created but logo upload failed: ${error.message}`;
toast.error(logoErrorMessage);
logger.error("Tournament logo upload error", error);
logger.error("Team logo upload error", error);
}
} else {
toast.success(successMessage);
@@ -112,7 +119,7 @@ const TournamentForm = ({
onError: (error: any) => {
toast.error(`${errorMessage}: ${error.message}`);
logger.error(
`Tournament ${isEditMode ? "update" : "create"} error`,
`Team ${isEditMode ? "update" : "create"} error`,
error
);
},
@@ -123,9 +130,9 @@ const TournamentForm = ({
return (
<SlidePanel
onSubmit={form.onSubmit(handleSubmit)}
onSubmit={form.onSubmit((values) => console.log(values))}
onCancel={close}
submitText={isEditMode ? "Update Tournament" : "Create Tournament"}
submitText={isEditMode ? "Update Team" : "Create Team"}
cancelText="Cancel"
loading={isPending}
>
@@ -136,19 +143,6 @@ const TournamentForm = ({
key={form.key("name")}
{...form.getInputProps("name")}
/>
<TextInput
label="Location"
withAsterisk
key={form.key("location")}
{...form.getInputProps("location")}
/>
<Textarea
label="Description"
key={form.key("desc")}
{...form.getInputProps("desc")}
minRows={3}
/>
<FileInput
key={form.key("logo")}
@@ -158,6 +152,50 @@ const TournamentForm = ({
{...form.getInputProps("logo")}
/>
{
tournamentId && (
<PlayersPicker
tournamentId={tournamentId}
key={form.key("players")}
{...form.getInputProps("players")}
/>
)
}
<SlidePanelField
key={form.key("primary_color")}
{...form.getInputProps("primary_color")}
Component={TeamColorPicker}
title="Select Primary Color"
placeholder="Select Primary Color"
label="Primary Color"
formatValue={(value) => (
<Group>
<Badge variant="filled" radius="sm" color={value} />
{value}
</Group>
)}
/>
<SlidePanelField
key={form.key("accent_color")}
{...form.getInputProps("accent_color")}
Component={TeamColorPicker}
title="Select Accent Color"
placeholder="Select Accent Color"
label="Accent Color"
formatValue={(value) => (
<Group>
<Badge variant="filled" radius="sm" color={value} />
{value}
</Group>
)}
/>
<SongPicker form={form} />
{/*
<SlidePanelField
key={form.key("start_time")}
{...form.getInputProps("start_time")}
@@ -221,9 +259,10 @@ const TournamentForm = ({
}
/>
)}
*/}
</Stack>
</SlidePanel>
);
};
export default TournamentForm;
export default TeamForm;

View File

@@ -0,0 +1,62 @@
import { SlidePanelField } from "@/components/sheet/slide-panel";
import { useAuth } from "@/contexts/auth-context";
import { useUnenrolledPlayers } from "@/features/players/queries";
import { Autocomplete, Stack, Text, TextInput } from "@mantine/core";
import { useCallback, useEffect, useMemo, useState } from "react";
interface PlayersPickerProps {
tournamentId: string;
value?: string[];
onChange: (value: string[]) => void;
disabled?: boolean;
error?: string
}
const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }: PlayersPickerProps) => {
const { roles, user } = useAuth();
const isAdmin = roles.includes("Admin");
const { data } = useUnenrolledPlayers(tournamentId);
const options = useMemo(() => data.filter(p => p.id != user?.id).map(p => (p.first_name + " " + p.last_name)), [data])
const findPlayerId = useCallback((name?: string) => {
const player = data.find(player => player?.first_name + " " + player?.last_name === name)
return player?.id || ""
}, [data])
const findPlayerName = useCallback((id?: string) => {
const player = data.find(player => player.id === id)
return player?.first_name + " " + player?.last_name
}, [data])
const [playerOne, setPlayerOne] = useState(value.length ? findPlayerName(value[0]) : findPlayerName(user!.id));
const [playerTwo, setPlayerTwo] = useState(value.length > 1 ? findPlayerName(value[1]) : undefined);
useEffect(() => {
onChange([findPlayerId(playerOne), findPlayerId(playerTwo)])
}, [playerOne, playerTwo])
return (
<Stack>
<Autocomplete
label="Player 1"
data={options}
disabled={!isAdmin || disabled}
value={playerOne}
onChange={setPlayerOne}
/>
<Autocomplete
label="Player 2"
data={options}
disabled={disabled && !isAdmin}
value={playerTwo}
onChange={setPlayerTwo}
withAsterisk
error={error}
/>
</Stack>
);
};
export default PlayersPicker;

View File

@@ -0,0 +1,56 @@
import { SlidePanelField } from "@/components/sheet/slide-panel";
import { Stack, Text } from "@mantine/core";
import { UseFormReturnType } from "@mantine/form";
import { TeamInput } from "../../types";
import { useEffect, useState } from "react";
interface Song {
song_id: string;
song_name: string;
song_artist: string;
song_album: string;
song_year?: number;
song_start?: number;
song_end?: number;
song_image_url: string;
}
interface SongPickerProps {
form: UseFormReturnType<TeamInput>
}
const SongPicker = ({ form }: SongPickerProps) => {
const [song, setSong] = useState<Song>();
useEffect(() => {
const values = form.getValues();
setSong({
song_id: values.song_id || "",
song_name: values.song_name || "",
song_artist: values.song_artist || "",
song_album: values.song_album || "",
song_year: values.song_year,
song_start: values.song_start,
song_end: values.song_end,
song_image_url: values.song_image_url || "",
})
}, []);
return (
<SlidePanelField
key={"song-picker"}
value={""}
onChange={console.log}
Component={() => (
<Stack>
<Text>Song picker</Text>
</Stack>
)}
title={"Select Song"}
label={"Song"}
/>
);
};
export default SongPicker;

View File

@@ -1,77 +0,0 @@
import { ColorPicker, TextInput, Stack, Group, ColorSwatch, Text } from '@mantine/core';
import React, { useState, useCallback, useMemo } from 'react';
interface TeamColorPickerProps {
value: string;
onChange: (value: string) => void;
label?: string;
}
const TeamColorPicker: React.FC<TeamColorPickerProps> = ({
value,
onChange,
label = "Select Color"
}) => {
const [customHex, setCustomHex] = useState(value || '');
const isValidHex = useMemo(() => {
const hexRegex = /^#[0-9A-F]{6}$/i;
return hexRegex.test(customHex);
}, [customHex]);
const handleColorChange = useCallback((color: string) => {
setCustomHex(color);
onChange(color);
}, [onChange]);
const handleHexInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const hex = event.currentTarget.value;
setCustomHex(hex);
if (/^#[0-9A-F]{6}$/i.test(hex)) {
onChange(hex);
}
}, [onChange]);
const presetColors = [
'#FF0000', '#00FF00', '#0000FF', '#FFFF00',
'#FF00FF', '#00FFFF', '#FFA500', '#800080',
'#008000', '#000080', '#800000', '#808000'
];
return (
<Stack gap="md" p="md">
<Text fw={500}>{label}</Text>
<ColorPicker
value={value || '#000000'}
onChange={handleColorChange}
format="hex"
swatches={presetColors}
withPicker={true}
fullWidth
/>
<Group gap="xs" align="flex-end">
<TextInput
style={{ flex: 1 }}
label="Custom Hex Code"
placeholder="#FF0000"
value={customHex}
onChange={handleHexInputChange}
error={customHex && !isValidHex ? 'Invalid hex color format' : undefined}
/>
{isValidHex && (
<ColorSwatch
color={customHex}
size={36}
style={{ marginBottom: customHex && !isValidHex ? 24 : 0 }}
/>
)}
</Group>
</Stack>
);
};
export default TeamColorPicker;

View File

@@ -3,7 +3,8 @@ import Header from "./header";
import SwipeableTabs from "@/components/swipeable-tabs";
import TournamentList from "@/features/tournaments/components/tournament-list";
import StatsOverview from "@/shared/components/stats-overview";
import { useTeam, useTeamStats } from "../../queries";
import { useTeam, useTeamMatches, useTeamStats } from "../../queries";
import MatchList from "@/features/matches/components/match-list";
interface ProfileProps {
id: string;
@@ -11,6 +12,7 @@ interface ProfileProps {
const TeamProfile = ({ id }: ProfileProps) => {
const { data: team } = useTeam(id);
const { data: matches } = useTeamMatches(id);
const { data: stats, isLoading: statsLoading, error: statsError } = useTeamStats(id);
if (!team) return <Text p="md">Team not found</Text>;
@@ -21,7 +23,7 @@ const TeamProfile = ({ id }: ProfileProps) => {
},
{
label: "Matches",
content: <Text p="md">Matches feed will go here</Text>,
content: <MatchList matches={matches || []} />,
},
{
label: "Tournaments",
@@ -35,7 +37,7 @@ const TeamProfile = ({ id }: ProfileProps) => {
return (
<>
<Header name={team.name} logo={team.logo} />
<Box m="sm" mt="lg">
<Box m="xs" mt="lg">
<SwipeableTabs tabs={tabs} />
</Box>
</>