273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
|
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
|
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
|
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
|
|
import {
|
|
getLeagueMembers,
|
|
type LeagueMembership,
|
|
type MembershipRole,
|
|
} from '@/lib/leagueMembership';
|
|
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
|
|
|
interface LeagueMembersProps {
|
|
leagueId: string;
|
|
onRemoveMember?: (driverId: string) => void;
|
|
onUpdateRole?: (driverId: string, role: MembershipRole) => void;
|
|
showActions?: boolean;
|
|
}
|
|
|
|
export default function LeagueMembers({
|
|
leagueId,
|
|
onRemoveMember,
|
|
onUpdateRole,
|
|
showActions = false
|
|
}: LeagueMembersProps) {
|
|
const [members, setMembers] = useState<LeagueMembership[]>([]);
|
|
const [driversById, setDriversById] = useState<Record<string, DriverDTO>>({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [sortBy, setSortBy] = useState<'role' | 'name' | 'date' | 'rating' | 'points' | 'wins'>('rating');
|
|
const currentDriverId = useEffectiveDriverId();
|
|
|
|
const loadMembers = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const membershipData = getLeagueMembers(leagueId);
|
|
setMembers(membershipData);
|
|
|
|
const driverRepo = getDriverRepository();
|
|
const driverEntities = await Promise.all(
|
|
membershipData.map((m) => driverRepo.findById(m.driverId))
|
|
);
|
|
const driverDtos = driverEntities
|
|
.map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null))
|
|
.filter((dto): dto is DriverDTO => dto !== null);
|
|
|
|
const byId: Record<string, DriverDTO> = {};
|
|
for (const dto of driverDtos) {
|
|
byId[dto.id] = dto;
|
|
}
|
|
setDriversById(byId);
|
|
} catch (error) {
|
|
console.error('Failed to load members:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [leagueId]);
|
|
|
|
useEffect(() => {
|
|
loadMembers();
|
|
}, [loadMembers]);
|
|
|
|
const getDriverName = (driverId: string): string => {
|
|
const driver = driversById[driverId];
|
|
return driver?.name || 'Unknown Driver';
|
|
};
|
|
|
|
const getRoleOrder = (role: MembershipRole): number => {
|
|
const order = { owner: 0, admin: 1, steward: 2, member: 3 };
|
|
return order[role];
|
|
};
|
|
|
|
const sortedMembers = [...members].sort((a, b) => {
|
|
switch (sortBy) {
|
|
case 'role':
|
|
return getRoleOrder(a.role) - getRoleOrder(b.role);
|
|
case 'name':
|
|
return getDriverName(a.driverId).localeCompare(getDriverName(b.driverId));
|
|
case 'date':
|
|
return new Date(b.joinedAt).getTime() - new Date(a.joinedAt).getTime();
|
|
case 'rating': {
|
|
const statsA = getDriverStats(a.driverId);
|
|
const statsB = getDriverStats(b.driverId);
|
|
return (statsB?.rating || 0) - (statsA?.rating || 0);
|
|
}
|
|
case 'points':
|
|
return 0;
|
|
case 'wins': {
|
|
const statsA = getDriverStats(a.driverId);
|
|
const statsB = getDriverStats(b.driverId);
|
|
return (statsB?.wins || 0) - (statsA?.wins || 0);
|
|
}
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
|
|
const getRoleBadgeColor = (role: MembershipRole): string => {
|
|
switch (role) {
|
|
case 'owner':
|
|
return 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30';
|
|
case 'admin':
|
|
return 'bg-purple-500/10 text-purple-400 border-purple-500/30';
|
|
case 'steward':
|
|
return 'bg-blue-500/10 text-blue-400 border-blue-500/30';
|
|
case 'member':
|
|
return 'bg-primary-blue/10 text-primary-blue border-primary-blue/30';
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="text-center py-8 text-gray-400">
|
|
Loading members...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (members.length === 0) {
|
|
return (
|
|
<div className="text-center py-8 text-gray-400">
|
|
No members found
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* Sort Controls */}
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<p className="text-sm text-gray-400">
|
|
{members.length} {members.length === 1 ? 'member' : 'members'}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm text-gray-400">Sort by:</label>
|
|
<select
|
|
value={sortBy}
|
|
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
|
|
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
|
>
|
|
<option value="rating">Rating</option>
|
|
<option value="points">Points</option>
|
|
<option value="wins">Wins</option>
|
|
<option value="role">Role</option>
|
|
<option value="name">Name</option>
|
|
<option value="date">Join Date</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Members Table */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-charcoal-outline">
|
|
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Driver</th>
|
|
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Rating</th>
|
|
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Rank</th>
|
|
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Wins</th>
|
|
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Role</th>
|
|
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Joined</th>
|
|
{showActions && <th className="text-right py-3 px-4 text-sm font-semibold text-gray-400">Actions</th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedMembers.map((member, index) => {
|
|
const isCurrentUser = member.driverId === currentDriverId;
|
|
const cannotModify = member.role === 'owner';
|
|
const driverStats = getDriverStats(member.driverId);
|
|
const isTopPerformer = index < 3 && sortBy === 'rating';
|
|
const driver = driversById[member.driverId];
|
|
const roleLabel =
|
|
member.role.charAt(0).toUpperCase() + member.role.slice(1);
|
|
const ratingAndWinsMeta =
|
|
driverStats && typeof driverStats.rating === 'number'
|
|
? `Rating ${driverStats.rating} • ${driverStats.wins ?? 0} wins`
|
|
: null;
|
|
|
|
return (
|
|
<tr
|
|
key={member.driverId}
|
|
className={`border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors ${isTopPerformer ? 'bg-primary-blue/5' : ''}`}
|
|
>
|
|
<td className="py-3 px-4">
|
|
<div className="flex items-center gap-2">
|
|
{driver ? (
|
|
<DriverIdentity
|
|
driver={driver}
|
|
href={`/drivers/${member.driverId}?from=league-members&leagueId=${leagueId}`}
|
|
contextLabel={roleLabel}
|
|
meta={ratingAndWinsMeta}
|
|
size="md"
|
|
/>
|
|
) : (
|
|
<span className="text-white">Unknown Driver</span>
|
|
)}
|
|
{isCurrentUser && (
|
|
<span className="text-xs text-gray-500">(You)</span>
|
|
)}
|
|
{isTopPerformer && (
|
|
<span className="text-xs">⭐</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<span className="text-primary-blue font-medium">
|
|
{driverStats?.rating || '—'}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<span className="text-gray-300">
|
|
#{driverStats?.overallRank || '—'}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<span className="text-green-400 font-medium">
|
|
{driverStats?.wins || 0}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<span className={`px-2 py-1 text-xs font-medium rounded border ${getRoleBadgeColor(member.role)}`}>
|
|
{member.role.charAt(0).toUpperCase() + member.role.slice(1)}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<span className="text-white text-sm">
|
|
{new Date(member.joinedAt).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})}
|
|
</span>
|
|
</td>
|
|
{showActions && (
|
|
<td className="py-3 px-4 text-right">
|
|
{!cannotModify && !isCurrentUser && (
|
|
<div className="flex items-center justify-end gap-2">
|
|
{onUpdateRole && (
|
|
<select
|
|
value={member.role}
|
|
onChange={(e) => onUpdateRole(member.driverId, e.target.value as MembershipRole)}
|
|
className="px-2 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-xs focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
|
>
|
|
<option value="member">Member</option>
|
|
<option value="steward">Steward</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
)}
|
|
{onRemoveMember && (
|
|
<button
|
|
onClick={() => onRemoveMember(member.driverId)}
|
|
className="px-2 py-1 text-xs font-medium text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded transition-colors"
|
|
>
|
|
Remove
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{cannotModify && (
|
|
<span className="text-xs text-gray-500">—</span>
|
|
)}
|
|
</td>
|
|
)}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |