684 lines
23 KiB
TypeScript
684 lines
23 KiB
TypeScript
'use client';
|
||
|
||
import { routes } from '@/lib/routing/RouteConfig';
|
||
import { Badge } from '@/ui/Badge';
|
||
import { CountryFlag } from '@/ui/CountryFlag';
|
||
import { Icon } from '@/ui/Icon';
|
||
import { Image } from '@/ui/Image';
|
||
import { Link } from '@/ui/Link';
|
||
import { PlaceholderImage } from '@/ui/PlaceholderImage';
|
||
import { Stack } from '@/ui/Stack';
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/ui/Table';
|
||
import { Text } from '@/ui/Text';
|
||
import { Edit, User } from 'lucide-react';
|
||
import { useEffect, useRef, useState } from 'react';
|
||
|
||
// League role display data
|
||
const leagueRoleDisplay = {
|
||
owner: {
|
||
text: 'Owner',
|
||
bg: 'bg-yellow-500/10',
|
||
color: 'text-yellow-500',
|
||
borderColor: 'border-yellow-500/30',
|
||
},
|
||
admin: {
|
||
text: 'Admin',
|
||
bg: 'bg-purple-500/10',
|
||
color: 'text-purple-400',
|
||
borderColor: 'border-purple-500/30',
|
||
},
|
||
steward: {
|
||
text: 'Steward',
|
||
bg: 'bg-blue-500/10',
|
||
color: 'text-blue-400',
|
||
borderColor: 'border-blue-500/30',
|
||
},
|
||
member: {
|
||
text: 'Member',
|
||
bg: 'bg-primary-blue/10',
|
||
color: 'text-primary-blue',
|
||
borderColor: 'border-primary-blue/30',
|
||
},
|
||
} as const;
|
||
|
||
// Position background colors
|
||
const getPositionBgColor = (position: number) => {
|
||
switch (position) {
|
||
case 1: return { bg: 'bg-yellow-500/10', borderLeft: true, borderColor: 'border-l-yellow-500' };
|
||
case 2: return { bg: 'bg-gray-300/10', borderLeft: true, borderColor: 'border-l-gray-400' };
|
||
case 3: return { bg: 'bg-amber-600/10', borderLeft: true, borderColor: 'border-l-amber-600' };
|
||
default: return { borderLeft: true, borderColor: 'border-l-transparent' };
|
||
}
|
||
};
|
||
|
||
interface StandingsTableProps {
|
||
standings: Array<{
|
||
driverId: string;
|
||
position: number;
|
||
positionLabel: string;
|
||
totalPointsLabel: string;
|
||
racesLabel: string;
|
||
avgFinishLabel: string;
|
||
penaltyPointsLabel: string;
|
||
bonusPointsLabel: string;
|
||
teamName?: string;
|
||
}>;
|
||
drivers: Array<{
|
||
id: string;
|
||
name: string;
|
||
avatarUrl: string | null;
|
||
iracingId?: string;
|
||
rating?: number;
|
||
country?: string;
|
||
}>;
|
||
memberships?: Array<{
|
||
driverId: string;
|
||
role: 'owner' | 'admin' | 'steward' | 'member';
|
||
joinedAt: string;
|
||
status: 'active' | 'pending' | 'banned';
|
||
}>;
|
||
currentDriverId?: string;
|
||
isAdmin?: boolean;
|
||
onRemoveMember?: (driverId: string) => void;
|
||
onUpdateRole?: (driverId: string, role: string) => void;
|
||
}
|
||
|
||
export function StandingsTable({
|
||
standings,
|
||
drivers,
|
||
memberships = [],
|
||
currentDriverId,
|
||
isAdmin = false,
|
||
onRemoveMember,
|
||
onUpdateRole
|
||
}: StandingsTableProps) {
|
||
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
|
||
const [activeMenu, setActiveMenu] = useState<{ driverId: string; type: 'member' | 'points' } | null>(null);
|
||
const menuRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Close menu when clicking outside
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||
setActiveMenu(null);
|
||
}
|
||
};
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||
}, []);
|
||
|
||
const getDriver = (driverId: string) => {
|
||
return drivers.find((d) => d.id === driverId);
|
||
};
|
||
|
||
const getMembership = (driverId: string) => {
|
||
return memberships.find((m) => m.driverId === driverId);
|
||
};
|
||
|
||
const canModifyMember = (driverId: string): boolean => {
|
||
if (!isAdmin) return false;
|
||
if (driverId === currentDriverId) return false;
|
||
const membership = getMembership(driverId);
|
||
// Allow managing drivers even without formal membership (they have standings = they're participating)
|
||
// But don't allow modifying the owner
|
||
if (membership && membership.role === 'owner') return false;
|
||
return true;
|
||
};
|
||
|
||
const isCurrentUser = (driverId: string): boolean => {
|
||
return driverId === currentDriverId;
|
||
};
|
||
|
||
type MembershipRole = string;
|
||
|
||
const handleRoleChange = (driverId: string, newRole: MembershipRole) => {
|
||
if (!onUpdateRole) return;
|
||
const membership = getMembership(driverId);
|
||
if (!membership) return;
|
||
|
||
const confirmationMessages: Record<string, string> = {
|
||
owner: 'Cannot promote to owner',
|
||
admin: 'Promote this member to Admin? They will have full management permissions.',
|
||
steward: 'Assign Steward role? They will be able to manage protests and penalties.',
|
||
member: 'Demote to regular Member? They will lose elevated permissions.'
|
||
};
|
||
|
||
if (newRole === 'owner') {
|
||
alert(confirmationMessages.owner);
|
||
return;
|
||
}
|
||
|
||
if (newRole !== membership.role && confirm(confirmationMessages[newRole])) {
|
||
onUpdateRole(driverId, newRole);
|
||
setActiveMenu(null);
|
||
}
|
||
};
|
||
|
||
const handleRemove = (driverId: string) => {
|
||
if (!onRemoveMember) return;
|
||
const driver = getDriver(driverId);
|
||
const driverName = driver?.name || 'this member';
|
||
|
||
if (confirm(`Remove ${driverName} from the league? This action cannot be undone.`)) {
|
||
onRemoveMember(driverId);
|
||
setActiveMenu(null);
|
||
}
|
||
};
|
||
|
||
const MemberActionMenu = ({ driverId }: { driverId: string }) => {
|
||
const membership = getMembership(driverId);
|
||
// For drivers without membership, show limited options (add as member, remove from standings)
|
||
const hasMembership = !!membership;
|
||
|
||
return (
|
||
<Stack
|
||
ref={menuRef}
|
||
position="absolute"
|
||
right="0"
|
||
top="full"
|
||
mt={1}
|
||
zIndex={50}
|
||
bg="bg-deep-graphite"
|
||
border
|
||
borderColor="border-charcoal-outline"
|
||
rounded="lg"
|
||
shadow="xl"
|
||
p={2}
|
||
minWidth="200px"
|
||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||
>
|
||
<Text size="xs" color="text-gray-400" px={2} py={1} mb={1} block>
|
||
Member Management
|
||
</Text>
|
||
<Stack gap={1}>
|
||
{hasMembership ? (
|
||
<>
|
||
{/* Role Management for existing members */}
|
||
{membership!.role !== 'admin' && membership!.role !== 'owner' && (
|
||
<Stack
|
||
as="button"
|
||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRoleChange(driverId, 'admin'); }}
|
||
display="flex"
|
||
alignItems="center"
|
||
gap={2}
|
||
w="full"
|
||
textAlign="left"
|
||
px={3}
|
||
py={2}
|
||
rounded="md"
|
||
fontSize="14px"
|
||
color="text-white"
|
||
hoverBg="bg-purple-500/10"
|
||
transition
|
||
>
|
||
<Text>🛡️</Text>
|
||
<Text>Promote to Admin</Text>
|
||
</Stack>
|
||
)}
|
||
{membership!.role === 'admin' && (
|
||
<Stack
|
||
as="button"
|
||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRoleChange(driverId, 'member'); }}
|
||
display="flex"
|
||
alignItems="center"
|
||
gap={2}
|
||
w="full"
|
||
textAlign="left"
|
||
px={3}
|
||
py={2}
|
||
rounded="md"
|
||
fontSize="14px"
|
||
color="text-white"
|
||
hoverBg="bg-iron-gray/20"
|
||
transition
|
||
>
|
||
<Text>⬇️</Text>
|
||
<Text>Demote to Member</Text>
|
||
</Stack>
|
||
)}
|
||
{membership!.role === 'member' && (
|
||
<Stack
|
||
as="button"
|
||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRoleChange(driverId, 'steward'); }}
|
||
display="flex"
|
||
alignItems="center"
|
||
gap={2}
|
||
w="full"
|
||
textAlign="left"
|
||
px={3}
|
||
py={2}
|
||
rounded="md"
|
||
fontSize="14px"
|
||
color="text-white"
|
||
hoverBg="bg-blue-500/10"
|
||
transition
|
||
>
|
||
<Text>🏁</Text>
|
||
<Text>Make Steward</Text>
|
||
</Stack>
|
||
)}
|
||
{membership!.role === 'steward' && (
|
||
<Stack
|
||
as="button"
|
||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRoleChange(driverId, 'member'); }}
|
||
display="flex"
|
||
alignItems="center"
|
||
gap={2}
|
||
w="full"
|
||
textAlign="left"
|
||
px={3}
|
||
py={2}
|
||
rounded="md"
|
||
fontSize="14px"
|
||
color="text-white"
|
||
hoverBg="bg-iron-gray/20"
|
||
transition
|
||
>
|
||
<Text>🏁</Text>
|
||
<Text>Remove Steward</Text>
|
||
</Stack>
|
||
)}
|
||
<Stack borderTop borderColor="border-charcoal-outline" my={1} />
|
||
<Stack
|
||
as="button"
|
||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRemove(driverId); }}
|
||
display="flex"
|
||
alignItems="center"
|
||
gap={2}
|
||
w="full"
|
||
textAlign="left"
|
||
px={3}
|
||
py={2}
|
||
rounded="md"
|
||
fontSize="14px"
|
||
color="text-red-400"
|
||
hoverBg="bg-red-500/10"
|
||
transition
|
||
>
|
||
<Text>🚫</Text>
|
||
<Text>Remove from League</Text>
|
||
</Stack>
|
||
</>
|
||
) : (
|
||
<>
|
||
{/* Options for drivers without membership (participating but not formal members) */}
|
||
<Stack bg="bg-yellow-500/10" rounded px={2} py={1} mb={1}>
|
||
<Text size="xs" color="text-yellow-400/80">Driver not a formal member</Text>
|
||
</Stack>
|
||
<Stack
|
||
as="button"
|
||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); alert('Add as member - feature coming soon'); }}
|
||
display="flex"
|
||
alignItems="center"
|
||
gap={2}
|
||
w="full"
|
||
textAlign="left"
|
||
px={3}
|
||
py={2}
|
||
rounded="md"
|
||
fontSize="14px"
|
||
color="text-white"
|
||
hoverBg="bg-green-500/10"
|
||
transition
|
||
>
|
||
<Text>➕</Text>
|
||
<Text>Add as Member</Text>
|
||
</Stack>
|
||
<Stack
|
||
as="button"
|
||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRemove(driverId); }}
|
||
display="flex"
|
||
alignItems="center"
|
||
gap={2}
|
||
w="full"
|
||
textAlign="left"
|
||
px={3}
|
||
py={2}
|
||
rounded="md"
|
||
fontSize="14px"
|
||
color="text-red-400"
|
||
hoverBg="bg-red-500/10"
|
||
transition
|
||
>
|
||
<Text>🚫</Text>
|
||
<Text>Remove from Standings</Text>
|
||
</Stack>
|
||
</>
|
||
)}
|
||
</Stack>
|
||
</Stack>
|
||
);
|
||
};
|
||
|
||
const PointsActionMenu = () => {
|
||
return (
|
||
<Stack
|
||
ref={menuRef}
|
||
position="absolute"
|
||
right="0"
|
||
top="full"
|
||
mt={1}
|
||
zIndex={50}
|
||
bg="bg-deep-graphite"
|
||
border
|
||
borderColor="border-charcoal-outline"
|
||
rounded="lg"
|
||
shadow="xl"
|
||
p={2}
|
||
minWidth="180px"
|
||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||
>
|
||
<Text size="xs" color="text-gray-400" px={2} py={1} mb={1} block>
|
||
Score Actions
|
||
</Text>
|
||
<Stack gap={1}>
|
||
<Stack
|
||
as="button"
|
||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); alert('View detailed stats - feature coming soon'); }}
|
||
display="flex"
|
||
alignItems="center"
|
||
gap={2}
|
||
w="full"
|
||
textAlign="left"
|
||
px={3}
|
||
py={2}
|
||
rounded="md"
|
||
color="text-white"
|
||
hoverBg="bg-iron-gray/20"
|
||
transition
|
||
fontSize="14px"
|
||
>
|
||
<Text>📊</Text>
|
||
<Text>View Details</Text>
|
||
</Stack>
|
||
<Stack
|
||
as="button"
|
||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); alert('Manual adjustment - feature coming soon'); }}
|
||
display="flex"
|
||
alignItems="center"
|
||
gap={2}
|
||
w="full"
|
||
textAlign="left"
|
||
px={3}
|
||
py={2}
|
||
rounded="md"
|
||
color="text-white"
|
||
hoverBg="bg-iron-gray/20"
|
||
transition
|
||
fontSize="14px"
|
||
>
|
||
<Text>⚠️</Text>
|
||
<Text>Adjust Points</Text>
|
||
</Stack>
|
||
<Stack
|
||
as="button"
|
||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); alert('Race history - feature coming soon'); }}
|
||
display="flex"
|
||
alignItems="center"
|
||
gap={2}
|
||
w="full"
|
||
textAlign="left"
|
||
px={3}
|
||
py={2}
|
||
rounded="md"
|
||
color="text-white"
|
||
hoverBg="bg-iron-gray/20"
|
||
transition
|
||
fontSize="14px"
|
||
>
|
||
<Text>📝</Text>
|
||
<Text>Race History</Text>
|
||
</Stack>
|
||
</Stack>
|
||
</Stack>
|
||
);
|
||
};
|
||
|
||
if (standings.length === 0) {
|
||
return (
|
||
<Stack textAlign="center" py={8}>
|
||
<Text color="text-gray-400">No standings available</Text>
|
||
</Stack>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Stack overflow="auto">
|
||
<Table>
|
||
<TableHead>
|
||
<TableRow>
|
||
<TableHeader textAlign="center" w="14">Pos</TableHeader>
|
||
<TableHeader>Driver</TableHeader>
|
||
<TableHeader>Team</TableHeader>
|
||
<TableHeader textAlign="right">Points</TableHeader>
|
||
<TableHeader textAlign="center">Races</TableHeader>
|
||
<TableHeader textAlign="right">Avg Finish</TableHeader>
|
||
<TableHeader textAlign="right">Penalty</TableHeader>
|
||
<TableHeader textAlign="right">Bonus</TableHeader>
|
||
</TableRow>
|
||
</TableHead>
|
||
<TableBody>
|
||
{standings.map((row) => {
|
||
const driver = getDriver(row.driverId);
|
||
const membership = getMembership(row.driverId);
|
||
const roleDisplay = membership ? leagueRoleDisplay[membership.role] : null;
|
||
const canModify = canModifyMember(row.driverId);
|
||
const isRowHovered = hoveredRow === row.driverId;
|
||
const isMemberMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'member';
|
||
const isPointsMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'points';
|
||
|
||
const isMe = isCurrentUser(row.driverId);
|
||
const posConfig = getPositionBgColor(row.position);
|
||
|
||
return (
|
||
<TableRow
|
||
key={row.driverId}
|
||
bg={isMe ? 'bg-primary-blue/5' : posConfig.bg}
|
||
borderLeft={posConfig.borderLeft}
|
||
borderColor={posConfig.borderColor}
|
||
hoverBg="bg-iron-gray/10"
|
||
ring={isMe ? 'ring-2 ring-primary-blue/50 ring-inset' : ''}
|
||
onMouseEnter={() => setHoveredRow(row.driverId)}
|
||
onMouseLeave={() => {
|
||
setHoveredRow(null);
|
||
if (!isMemberMenuOpen && !isPointsMenuOpen) {
|
||
setActiveMenu(null);
|
||
}
|
||
}}
|
||
>
|
||
{/* Position */}
|
||
<TableCell textAlign="center" w="14">
|
||
<Stack
|
||
display="inline-flex"
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
w="8"
|
||
h="8"
|
||
rounded="full"
|
||
weight="bold"
|
||
bg={
|
||
row.position === 1 ? 'bg-yellow-500' :
|
||
row.position === 2 ? 'bg-gray-400' :
|
||
row.position === 3 ? 'bg-amber-600' :
|
||
'bg-charcoal-outline'
|
||
}
|
||
color={
|
||
row.position === 1 ? 'text-black' :
|
||
row.position === 2 ? 'text-black' :
|
||
'text-white'
|
||
}
|
||
>
|
||
{row.positionLabel}
|
||
</Stack>
|
||
</TableCell>
|
||
|
||
{/* Driver with Rating and Nationality */}
|
||
<TableCell position="relative">
|
||
<Stack display="flex" alignItems="center" gap={3}>
|
||
{/* Avatar */}
|
||
<Stack position="relative">
|
||
<Stack
|
||
w="10"
|
||
h="10"
|
||
rounded="full"
|
||
bg="bg-primary-blue/20"
|
||
overflow="hidden"
|
||
display="flex"
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
flexShrink={0}
|
||
>
|
||
{driver && (
|
||
driver.avatarUrl ? (
|
||
<Image
|
||
src={driver.avatarUrl}
|
||
alt={driver.name}
|
||
width={40}
|
||
height={40}
|
||
fullWidth
|
||
fullHeight
|
||
objectFit="cover"
|
||
/>
|
||
) : (
|
||
<PlaceholderImage size={40} />
|
||
)
|
||
)}
|
||
</Stack>
|
||
{/* Nationality flag */}
|
||
{driver && driver.country && (
|
||
<Stack position="absolute" bottom="-1" right="-1">
|
||
<CountryFlag countryCode={driver.country} size="sm" />
|
||
</Stack>
|
||
)}
|
||
</Stack>
|
||
|
||
{/* Name and Rating */}
|
||
<Stack flexGrow={1} minWidth="0">
|
||
<Stack display="flex" alignItems="center" gap={2}>
|
||
<Link
|
||
href={routes.driver.detail(row.driverId)}
|
||
weight="medium"
|
||
color="text-white"
|
||
truncate
|
||
hoverTextColor="text-primary-blue"
|
||
transition
|
||
>
|
||
{driver?.name || 'Unknown Driver'}
|
||
</Link>
|
||
{isMe && (
|
||
<Badge variant="primary">You</Badge>
|
||
)}
|
||
{roleDisplay && roleDisplay.text !== 'Member' && (
|
||
<Stack
|
||
as="span"
|
||
px={2}
|
||
py={0.5}
|
||
fontSize="12px"
|
||
weight="medium"
|
||
rounded="md"
|
||
border
|
||
bg={roleDisplay.bg}
|
||
color={roleDisplay.color}
|
||
borderColor={roleDisplay.borderColor}
|
||
>
|
||
{roleDisplay.text}
|
||
</Stack>
|
||
)}
|
||
</Stack>
|
||
</Stack>
|
||
|
||
{/* Hover Actions for Member Management */}
|
||
{isAdmin && canModify && (
|
||
<Stack
|
||
display="flex"
|
||
alignItems="center"
|
||
gap={1}
|
||
opacity={isRowHovered || isMemberMenuOpen ? 1 : 0}
|
||
transition
|
||
visibility={isRowHovered || isMemberMenuOpen ? 'visible' : 'hidden'}
|
||
>
|
||
<Stack
|
||
as="button"
|
||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setActiveMenu(isMemberMenuOpen ? null : { driverId: row.driverId, type: 'member' }); }}
|
||
p={1.5}
|
||
rounded="md"
|
||
bg={isMemberMenuOpen ? 'bg-primary-blue/20' : ''}
|
||
color={isMemberMenuOpen ? 'text-primary-blue' : 'text-gray-400'}
|
||
hoverColor={!isMemberMenuOpen ? 'text-white' : ''}
|
||
hoverBg={!isMemberMenuOpen ? 'bg-iron-gray/30' : ''}
|
||
transition
|
||
title="Manage member"
|
||
>
|
||
<Icon icon={User} size={4} />
|
||
</Stack>
|
||
</Stack>
|
||
)}
|
||
</Stack>
|
||
{isMemberMenuOpen && <MemberActionMenu driverId={row.driverId} />}
|
||
</TableCell>
|
||
|
||
{/* Team */}
|
||
<TableCell>
|
||
<Text color="text-gray-300">{row.teamName ?? '—'}</Text>
|
||
</TableCell>
|
||
|
||
{/* Total Points with Hover Action */}
|
||
<TableCell textAlign="right" position="relative">
|
||
<Stack display="flex" alignItems="center" justifyContent="end" gap={2}>
|
||
<Text color="text-white" weight="bold" size="lg">{row.totalPointsLabel}</Text>
|
||
{isAdmin && canModify && (
|
||
<Stack
|
||
as="button"
|
||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setActiveMenu(isPointsMenuOpen ? null : { driverId: row.driverId, type: 'points' }); }}
|
||
p={1}
|
||
rounded="md"
|
||
bg={isPointsMenuOpen ? 'bg-primary-blue/20' : ''}
|
||
color={isPointsMenuOpen ? 'text-primary-blue' : 'text-gray-400'}
|
||
hoverColor={!isPointsMenuOpen ? 'text-white' : ''}
|
||
hoverBg={!isPointsMenuOpen ? 'bg-iron-gray/30' : ''}
|
||
transition
|
||
opacity={isRowHovered || isPointsMenuOpen ? 1 : 0}
|
||
visibility={isRowHovered || isPointsMenuOpen ? 'visible' : 'hidden'}
|
||
title="Score actions"
|
||
>
|
||
<Icon icon={Edit} size={3} />
|
||
</Stack>
|
||
)}
|
||
</Stack>
|
||
{isPointsMenuOpen && <PointsActionMenu />}
|
||
</TableCell>
|
||
|
||
{/* Races (Finished/Started) */}
|
||
<TableCell textAlign="center">
|
||
<Text color="text-white">{row.racesLabel}</Text>
|
||
</TableCell>
|
||
|
||
{/* Avg Finish */}
|
||
<TableCell textAlign="right">
|
||
<Text color="text-gray-300">
|
||
{row.avgFinishLabel}
|
||
</Text>
|
||
</TableCell>
|
||
|
||
{/* Penalty */}
|
||
<TableCell textAlign="right">
|
||
<Text color="text-gray-500">
|
||
{row.penaltyPointsLabel}
|
||
</Text>
|
||
</TableCell>
|
||
|
||
{/* Bonus */}
|
||
<TableCell textAlign="right">
|
||
<Text color="text-gray-500">
|
||
{row.bonusPointsLabel}
|
||
</Text>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</Stack>
|
||
);
|
||
}
|