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
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user