450 lines
20 KiB
TypeScript
450 lines
20 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useRef, useEffect } from 'react';
|
||
import Link from 'next/link';
|
||
import Image from 'next/image';
|
||
import { Star } from 'lucide-react';
|
||
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
|
||
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
||
import type { MembershipRoleDTO } from '@/lib/types/generated/MembershipRoleDTO';
|
||
import { LeagueRoleDisplay } from '@/lib/display-objects/LeagueRoleDisplay';
|
||
import CountryFlag from '@/components/ui/CountryFlag';
|
||
import { useServices } from '@/lib/services/ServiceProvider';
|
||
|
||
// Position background colors
|
||
const getPositionBgColor = (position: number): string => {
|
||
switch (position) {
|
||
case 1: return 'bg-yellow-500/10 border-l-4 border-l-yellow-500';
|
||
case 2: return 'bg-gray-300/10 border-l-4 border-l-gray-400';
|
||
case 3: return 'bg-amber-600/10 border-l-4 border-l-amber-600';
|
||
default: return 'border-l-4 border-l-transparent';
|
||
}
|
||
};
|
||
|
||
interface StandingsTableProps {
|
||
standings: Array<{
|
||
leagueId: string;
|
||
driverId: string;
|
||
position: number;
|
||
totalPoints: number;
|
||
racesFinished: number;
|
||
racesStarted: number;
|
||
avgFinish: number | null;
|
||
penaltyPoints: number;
|
||
bonusPoints: number;
|
||
teamName?: string;
|
||
}>;
|
||
drivers: DriverDTO[];
|
||
leagueId: string;
|
||
memberships?: LeagueMembership[];
|
||
currentDriverId?: string;
|
||
isAdmin?: boolean;
|
||
onRemoveMember?: (driverId: string) => void;
|
||
onUpdateRole?: (driverId: string, role: MembershipRoleDTO['value']) => void;
|
||
}
|
||
|
||
export default function StandingsTable({
|
||
standings,
|
||
drivers,
|
||
leagueId,
|
||
memberships = [],
|
||
currentDriverId,
|
||
isAdmin = false,
|
||
onRemoveMember,
|
||
onUpdateRole
|
||
}: StandingsTableProps) {
|
||
const { mediaService } = useServices();
|
||
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): DriverDTO | undefined => {
|
||
return drivers.find((d) => d.id === driverId);
|
||
};
|
||
|
||
const getMembership = (driverId: string): LeagueMembership | undefined => {
|
||
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 = MembershipRoleDTO['value'];
|
||
|
||
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 as MembershipRoleDTO['value']);
|
||
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 (
|
||
<div
|
||
ref={menuRef}
|
||
className="absolute right-0 top-full mt-1 z-50 bg-deep-graphite border border-charcoal-outline rounded-lg shadow-xl p-2 min-w-[200px]"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="text-xs text-gray-400 px-2 py-1 mb-1">
|
||
Member Management
|
||
</div>
|
||
<div className="space-y-1">
|
||
{hasMembership ? (
|
||
<>
|
||
{/* Role Management for existing members */}
|
||
{membership!.role !== 'admin' && membership!.role !== 'owner' && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); handleRoleChange(driverId, 'admin'); }}
|
||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-purple-500/10 rounded flex items-center gap-2 transition-colors"
|
||
>
|
||
<span>🛡️</span>
|
||
<span>Promote to Admin</span>
|
||
</button>
|
||
)}
|
||
{membership!.role === 'admin' && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); handleRoleChange(driverId, 'member'); }}
|
||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
|
||
>
|
||
<span>⬇️</span>
|
||
<span>Demote to Member</span>
|
||
</button>
|
||
)}
|
||
{membership!.role === 'member' && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); handleRoleChange(driverId, 'steward'); }}
|
||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-blue-500/10 rounded flex items-center gap-2 transition-colors"
|
||
>
|
||
<span>🏁</span>
|
||
<span>Make Steward</span>
|
||
</button>
|
||
)}
|
||
{membership!.role === 'steward' && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); handleRoleChange(driverId, 'member'); }}
|
||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
|
||
>
|
||
<span>🏁</span>
|
||
<span>Remove Steward</span>
|
||
</button>
|
||
)}
|
||
<div className="border-t border-charcoal-outline my-1"></div>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); handleRemove(driverId); }}
|
||
className="w-full text-left px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 rounded flex items-center gap-2 transition-colors"
|
||
>
|
||
<span>🚫</span>
|
||
<span>Remove from League</span>
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
{/* Options for drivers without membership (participating but not formal members) */}
|
||
<div className="text-xs text-yellow-400/80 px-2 py-1 mb-1 bg-yellow-500/10 rounded">
|
||
Driver not a formal member
|
||
</div>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); alert('Add as member - feature coming soon'); }}
|
||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-green-500/10 rounded flex items-center gap-2 transition-colors"
|
||
>
|
||
<span>➕</span>
|
||
<span>Add as Member</span>
|
||
</button>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); handleRemove(driverId); }}
|
||
className="w-full text-left px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 rounded flex items-center gap-2 transition-colors"
|
||
>
|
||
<span>🚫</span>
|
||
<span>Remove from Standings</span>
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const PointsActionMenu = ({ driverId }: { driverId: string }) => {
|
||
return (
|
||
<div
|
||
ref={menuRef}
|
||
className="absolute right-0 top-full mt-1 z-50 bg-deep-graphite border border-charcoal-outline rounded-lg shadow-xl p-2 min-w-[180px]"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="text-xs text-gray-400 px-2 py-1 mb-1">
|
||
Score Actions
|
||
</div>
|
||
<div className="space-y-1">
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); alert('View detailed stats - feature coming soon'); }}
|
||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
|
||
>
|
||
<span>📊</span>
|
||
<span>View Details</span>
|
||
</button>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); alert('Manual adjustment - feature coming soon'); }}
|
||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
|
||
>
|
||
<span>⚠️</span>
|
||
<span>Adjust Points</span>
|
||
</button>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); alert('Race history - feature coming soon'); }}
|
||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
|
||
>
|
||
<span>📝</span>
|
||
<span>Race History</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
if (standings.length === 0) {
|
||
return (
|
||
<div className="text-center py-8 text-gray-400">
|
||
No standings available
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="overflow-x-auto overflow-y-visible">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-charcoal-outline">
|
||
<th className="text-center py-3 px-3 font-semibold text-gray-400 w-14">Pos</th>
|
||
<th className="text-left py-3 px-4 font-semibold text-gray-400">Driver</th>
|
||
<th className="text-left py-3 px-4 font-semibold text-gray-400">Team</th>
|
||
<th className="text-right py-3 px-4 font-semibold text-gray-400">Points</th>
|
||
<th className="text-center py-3 px-4 font-semibold text-gray-400">Races</th>
|
||
<th className="text-right py-3 px-4 font-semibold text-gray-400">Avg Finish</th>
|
||
<th className="text-right py-3 px-4 font-semibold text-gray-400">Penalty</th>
|
||
<th className="text-right py-3 px-4 font-semibold text-gray-400">Bonus</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{standings.map((row) => {
|
||
const driver = getDriver(row.driverId);
|
||
const membership = getMembership(row.driverId);
|
||
const roleDisplay = membership ? LeagueRoleDisplay.getLeagueRoleDisplay(membership.role) : null;
|
||
const canModify = canModifyMember(row.driverId);
|
||
// TODO: Hook up real driver stats once API provides it
|
||
const driverStatsData: null = null;
|
||
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);
|
||
|
||
return (
|
||
<tr
|
||
key={`${row.leagueId}-${row.driverId}`}
|
||
className={`border-b border-charcoal-outline/50 transition-all duration-200 ${getPositionBgColor(row.position)} ${isRowHovered ? 'bg-iron-gray/10' : ''} ${isMe ? 'ring-2 ring-primary-blue/50 ring-inset bg-primary-blue/5' : ''}`}
|
||
onMouseEnter={() => setHoveredRow(row.driverId)}
|
||
onMouseLeave={() => {
|
||
setHoveredRow(null);
|
||
if (!isMemberMenuOpen && !isPointsMenuOpen) {
|
||
setActiveMenu(null);
|
||
}
|
||
}}
|
||
>
|
||
{/* Position */}
|
||
<td className="py-3 px-3 text-center">
|
||
<div className={`inline-flex items-center justify-center w-8 h-8 rounded-full font-bold ${
|
||
row.position === 1 ? 'bg-yellow-500 text-black' :
|
||
row.position === 2 ? 'bg-gray-400 text-black' :
|
||
row.position === 3 ? 'bg-amber-600 text-white' :
|
||
'bg-charcoal-outline text-white'
|
||
}`}>
|
||
{row.position}
|
||
</div>
|
||
</td>
|
||
|
||
{/* Driver with Rating and Nationality */}
|
||
<td className="py-3 px-4 relative">
|
||
<div className="flex items-center gap-3">
|
||
{/* Avatar */}
|
||
<div className="relative">
|
||
<div className="w-10 h-10 rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center shrink-0">
|
||
{driver && (
|
||
<Image
|
||
src={mediaService.getDriverAvatar(driver.id)}
|
||
alt={driver.name}
|
||
width={40}
|
||
height={40}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
)}
|
||
</div>
|
||
{/* Nationality flag */}
|
||
{driver && driver.country && (
|
||
<div className="absolute -bottom-1 -right-1">
|
||
<CountryFlag countryCode={driver.country} size="sm" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Name and Rating */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<Link
|
||
href={`/drivers/${row.driverId}`}
|
||
className="font-medium text-white truncate hover:text-primary-blue transition-colors"
|
||
>
|
||
{driver?.name || 'Unknown Driver'}
|
||
</Link>
|
||
{isMe && (
|
||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-primary-blue/20 text-primary-blue border border-primary-blue/30">
|
||
You
|
||
</span>
|
||
)}
|
||
{roleDisplay && roleDisplay.text !== 'Member' && (
|
||
<span className={`px-2 py-0.5 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
|
||
{roleDisplay.text}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="text-xs flex items-center gap-1">
|
||
{/* Rating intentionally omitted until API provides driver stats */}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Hover Actions for Member Management */}
|
||
{isAdmin && canModify && (
|
||
<div className="flex items-center gap-1" style={{ opacity: isRowHovered || isMemberMenuOpen ? 1 : 0, transition: 'opacity 0.2s', visibility: isRowHovered || isMemberMenuOpen ? 'visible' : 'hidden' }}>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setActiveMenu(isMemberMenuOpen ? null : { driverId: row.driverId, type: 'member' }); }}
|
||
className={`p-1.5 rounded transition-colors ${isMemberMenuOpen ? 'bg-primary-blue/20 text-primary-blue' : 'text-gray-400 hover:text-white hover:bg-iron-gray/30'}`}
|
||
title="Manage member"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{isMemberMenuOpen && <MemberActionMenu driverId={row.driverId} />}
|
||
</td>
|
||
|
||
{/* Team */}
|
||
<td className="py-3 px-4">
|
||
<span className="text-gray-300">{row.teamName ?? '—'}</span>
|
||
</td>
|
||
|
||
{/* Total Points with Hover Action */}
|
||
<td className="py-3 px-4 text-right relative">
|
||
<div className="flex items-center justify-end gap-2">
|
||
<span className="text-white font-bold text-lg">{row.totalPoints}</span>
|
||
{isAdmin && canModify && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setActiveMenu(isPointsMenuOpen ? null : { driverId: row.driverId, type: 'points' }); }}
|
||
className={`p-1 rounded transition-colors ${isPointsMenuOpen ? 'bg-primary-blue/20 text-primary-blue' : 'text-gray-400 hover:text-white hover:bg-iron-gray/30'}`}
|
||
style={{ opacity: isRowHovered || isPointsMenuOpen ? 1 : 0, transition: 'opacity 0.2s', visibility: isRowHovered || isPointsMenuOpen ? 'visible' : 'hidden' }}
|
||
title="Score actions"
|
||
>
|
||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
{isPointsMenuOpen && <PointsActionMenu driverId={row.driverId} />}
|
||
</td>
|
||
|
||
{/* Races (Finished/Started) */}
|
||
<td className="py-3 px-4 text-center">
|
||
<span className="text-white">{row.racesFinished}</span>
|
||
<span className="text-gray-500">/{row.racesStarted}</span>
|
||
</td>
|
||
|
||
{/* Avg Finish */}
|
||
<td className="py-3 px-4 text-right">
|
||
<span className="text-gray-300">
|
||
{row.avgFinish !== null ? row.avgFinish.toFixed(1) : '—'}
|
||
</span>
|
||
</td>
|
||
|
||
{/* Penalty */}
|
||
<td className="py-3 px-4 text-right">
|
||
<span className={row.penaltyPoints > 0 ? 'text-red-400 font-medium' : 'text-gray-500'}>
|
||
{row.penaltyPoints > 0 ? `-${row.penaltyPoints}` : '—'}
|
||
</span>
|
||
</td>
|
||
|
||
{/* Bonus */}
|
||
<td className="py-3 px-4 text-right">
|
||
<span className={row.bonusPoints !== 0 ? 'text-green-400 font-medium' : 'text-gray-500'}>
|
||
{row.bonusPoints !== 0 ? `+${row.bonusPoints}` : '—'}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
<style jsx>{`
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|