stats table
This commit is contained in:
256
src/features/players/components/player-stats-table.tsx
Normal file
256
src/features/players/components/player-stats-table.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Stack,
|
||||
Group,
|
||||
Box,
|
||||
ThemeIcon,
|
||||
Container,
|
||||
Title,
|
||||
ScrollArea,
|
||||
Paper,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
CaretUpIcon,
|
||||
CaretDownIcon,
|
||||
ChartBarIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { PlayerStats } from "../types";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface PlayerStatsTableProps {
|
||||
playerStats: PlayerStats[];
|
||||
}
|
||||
|
||||
type SortKey = keyof PlayerStats;
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
interface SortConfig {
|
||||
key: SortKey;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
||||
key: 'win_percentage',
|
||||
direction: 'desc'
|
||||
});
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
setSortConfig(prev => ({
|
||||
key,
|
||||
direction: prev.key === key && prev.direction === 'desc' ? 'asc' : 'desc'
|
||||
}));
|
||||
};
|
||||
|
||||
const getSortIcon = (key: SortKey) => {
|
||||
if (sortConfig.key !== key) return null;
|
||||
return sortConfig.direction === 'desc' ? <CaretDownIcon size={14} /> : <CaretUpIcon size={14} />;
|
||||
};
|
||||
|
||||
const filteredAndSortedStats = useMemo(() => {
|
||||
let filtered = playerStats.filter(stat =>
|
||||
stat.player_name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
const aValue = a[sortConfig.key];
|
||||
const bValue = b[sortConfig.key];
|
||||
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
return sortConfig.direction === 'desc' ? bValue - aValue : aValue - bValue;
|
||||
}
|
||||
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
return sortConfig.direction === 'desc'
|
||||
? bValue.localeCompare(aValue)
|
||||
: aValue.localeCompare(bValue);
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}, [playerStats, search, sortConfig]);
|
||||
|
||||
const formatPercentage = (value: number) => `${value.toFixed(1)}%`;
|
||||
const formatDecimal = (value: number) => value.toFixed(2);
|
||||
|
||||
const columns = [
|
||||
{ key: 'player_name' as SortKey, label: 'Player', width: 200 },
|
||||
{ key: 'win_percentage' as SortKey, label: 'Win %', width: 110 },
|
||||
{ key: 'matches' as SortKey, label: 'Matches', width: 90 },
|
||||
{ key: 'wins' as SortKey, label: 'Wins', width: 80 },
|
||||
{ key: 'losses' as SortKey, label: 'Losses', width: 80 },
|
||||
{ key: 'total_cups_made' as SortKey, label: 'Cups Made', width: 110 },
|
||||
{ key: 'total_cups_against' as SortKey, label: 'Cups Against', width: 120 },
|
||||
{ key: 'avg_cups_per_match' as SortKey, label: 'Avg/Match', width: 100 },
|
||||
{ key: 'margin_of_victory' as SortKey, label: 'Win Margin', width: 110 },
|
||||
{ key: 'margin_of_loss' as SortKey, label: 'Loss Margin', width: 110 },
|
||||
];
|
||||
|
||||
const renderCellContent = (stat: PlayerStats, column: typeof columns[0], index: number) => {
|
||||
switch (column.key) {
|
||||
case 'player_name':
|
||||
return (
|
||||
<Group gap="sm">
|
||||
<Text size="xs" c="dimmed" fw={500}>#{index + 1}</Text>
|
||||
<Text fw={600}>{stat.player_name}</Text>
|
||||
</Group>
|
||||
);
|
||||
case 'win_percentage':
|
||||
return (
|
||||
<Text size='sm' c={stat.win_percentage >= 70 ? "green" : stat.win_percentage >= 50 ? "yellow" : "red"}>
|
||||
{formatPercentage(stat.win_percentage)}
|
||||
</Text>
|
||||
);
|
||||
case 'wins':
|
||||
return <Text c="green" fw={500}>{stat.wins}</Text>;
|
||||
case 'losses':
|
||||
return <Text c="red" fw={500}>{stat.losses}</Text>;
|
||||
case 'total_cups_made':
|
||||
return <Text fw={500}>{stat.total_cups_made}</Text>;
|
||||
case 'matches':
|
||||
return <Text fw={500}>{stat.matches}</Text>;
|
||||
case 'avg_cups_per_match':
|
||||
return <Text>{formatDecimal(stat.avg_cups_per_match)}</Text>;
|
||||
case 'margin_of_victory':
|
||||
return <Text>{stat.margin_of_victory ? formatDecimal(stat.margin_of_victory) : 'N/A'}</Text>;
|
||||
case 'margin_of_loss':
|
||||
return <Text>{stat.margin_of_loss ? formatDecimal(stat.margin_of_loss) : 'N/A'}</Text>;
|
||||
default:
|
||||
return <Text>{(stat as any)[column.key]}</Text>;
|
||||
}
|
||||
};
|
||||
|
||||
if (playerStats.length === 0) {
|
||||
return (
|
||||
<Container size="md">
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<ThemeIcon size="xl" variant="light" radius="md">
|
||||
<ChartBarIcon size={32} />
|
||||
</ThemeIcon>
|
||||
<Title order={3} c="dimmed">No Stats Available</Title>
|
||||
<Text c="dimmed" ta="center">
|
||||
Player statistics will appear here once matches have been played.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="100%" px={0}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap">
|
||||
<Stack gap="xs">
|
||||
<Title order={2}>Player Statistics</Title>
|
||||
<Text c="dimmed">
|
||||
{filteredAndSortedStats.length} of {playerStats.length} players
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
placeholder="Search players..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<Paper withBorder radius="md" p={0} style={{ overflow: 'hidden' }}>
|
||||
<ScrollArea>
|
||||
<Table
|
||||
highlightOnHover
|
||||
striped
|
||||
withTableBorder={false}
|
||||
style={{
|
||||
minWidth: 1000,
|
||||
borderRadius: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Table.Thead
|
||||
style={{
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
<Table.Tr>
|
||||
{columns.map((column, index) => (
|
||||
<Table.Th
|
||||
key={column.key}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
width: column.width,
|
||||
minWidth: column.width,
|
||||
padding: '12px 16px',
|
||||
fontWeight: 600,
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
borderBottom: '2px solid var(--mantine-color-default-border)',
|
||||
...(index === 0 && {
|
||||
borderTopLeftRadius: 'var(--mantine-radius-md)',
|
||||
}),
|
||||
...(index === columns.length - 1 && {
|
||||
borderTopRightRadius: 'var(--mantine-radius-md)',
|
||||
}),
|
||||
}}
|
||||
onClick={() => handleSort(column.key)}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{column.label}
|
||||
</Text>
|
||||
<Box style={{ minWidth: 16, display: 'flex', justifyContent: 'center' }}>
|
||||
{getSortIcon(column.key)}
|
||||
</Box>
|
||||
</Group>
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filteredAndSortedStats.map((stat, index) => (
|
||||
<motion.tr
|
||||
key={stat.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.01 }}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--mantine-color-default-border)',
|
||||
}}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<Table.Td
|
||||
key={`${stat.id}-${column.key}`}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
>
|
||||
{renderCellContent(stat, column, index)}
|
||||
</Table.Td>
|
||||
))}
|
||||
</motion.tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
|
||||
{filteredAndSortedStats.length === 0 && search && (
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
No players found matching "{search}"
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlayerStatsTable;
|
||||
Reference in New Issue
Block a user