Files
flxn-app/src/features/tournaments/components/enrollment/free-agent-section.tsx
T
kyle a52872bea8 feat(admin): dashboard home, telemetry section, enrollment ops screen
- mission-control dashboard: now strip, tournament quick actions, usage
  sparklines, error feed, live activity, tools list
- /admin/telemetry with functions/events/errors/players/usage child routes,
  hand-rolled svg chart primitives, decomposed activities table
- error browser with grouping and mark-resolved
- consolidated per-tournament enrollment screen; hub simplified with
  run tournament promoted
- /admin/activities redirects to /admin/telemetry/functions
2026-08-25 21:54:21 -07:00

188 lines
5.5 KiB
TypeScript

import {
Stack,
ActionIcon,
Text,
Group,
Loader,
Box,
} from "@mantine/core";
import { TrashIcon, ShuffleIcon } from "@phosphor-icons/react";
import { useCallback, memo } from "react";
import { Trans, Plural, useLingui } from "@lingui/react/macro";
import { useNavigate } from "@tanstack/react-router";
import Button from "@/components/button";
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";
import EnrollmentStats from "./enrollment-stats";
interface FreeAgentSectionProps {
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 FreeAgentSection = ({ tournamentId }: FreeAgentSectionProps) => {
const { t } = useLingui();
const navigate = useNavigate();
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%">
<EnrollmentStats count={enrolledPlayers.length} regional />
<Box
pos="sticky"
top={0}
py="xs"
style={{ zIndex: 5, backgroundColor: "var(--mantine-color-body)" }}
>
<Stack gap="xs">
<Text fw={600} size="sm">
<Trans>Add Player</Trans>
</Text>
<Typeahead
placeholder={t`Search for players to enroll...`}
onSelect={handleEnrollPlayer}
searchFn={searchPlayers}
renderOption={renderPlayerOption}
format={formatPlayer}
disabled={isEnrolling}
/>
</Stack>
</Box>
<Button
leftSection={<ShuffleIcon size={18} />}
onClick={() =>
navigate({ to: `/admin/tournaments/${tournamentId}/assign-partners` })
}
>
<Trans>Assign Partners</Trans>
</Button>
<Stack gap="xs">
<Group justify="space-between">
<Text fw={600} size="sm">
<Trans>Enrolled Players</Trans>
</Text>
<Text size="xs" c="dimmed">
<Plural value={enrolledPlayers.length} one="# player" other="# players" />
</Text>
</Group>
{isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : !hasEnrolledPlayers ? (
<Text size="sm" c="dimmed" ta="center" py="lg">
<Trans>No players enrolled yet</Trans>
</Text>
) : (
<Stack gap="xs" w="100%">
{enrolledPlayers.map((player) => (
<PlayerItem
key={player.id}
player={player}
onRemove={handleRemovePlayer}
disabled={isRemoving}
/>
))}
</Stack>
)}
</Stack>
</Stack>
);
};
export default FreeAgentSection;