212 lines
6.6 KiB
TypeScript
212 lines
6.6 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import Card from '@/components/ui/Card';
|
|
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
|
import {
|
|
getTeamRosterViewModel,
|
|
type TeamRosterViewModel,
|
|
} from '@/lib/presenters/TeamRosterPresenter';
|
|
import type { TeamRole } from '@gridpilot/racing/domain/types/TeamMembership';
|
|
|
|
interface TeamMembershipSummary {
|
|
driverId: string;
|
|
role: TeamRole;
|
|
joinedAt: Date;
|
|
}
|
|
|
|
interface TeamRosterProps {
|
|
teamId: string;
|
|
memberships: TeamMembershipSummary[];
|
|
isAdmin: boolean;
|
|
onRemoveMember?: (driverId: string) => void;
|
|
onChangeRole?: (driverId: string, newRole: TeamRole) => void;
|
|
}
|
|
|
|
export default function TeamRoster({
|
|
teamId,
|
|
memberships,
|
|
isAdmin,
|
|
onRemoveMember,
|
|
onChangeRole,
|
|
}: TeamRosterProps) {
|
|
const [viewModel, setViewModel] = useState<TeamRosterViewModel | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [sortBy, setSortBy] = useState<'role' | 'rating' | 'name'>('rating');
|
|
|
|
useEffect(() => {
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const fullMemberships = memberships.map((m) => ({
|
|
teamId,
|
|
driverId: m.driverId,
|
|
role: m.role,
|
|
joinedAt: m.joinedAt,
|
|
status: 'active' as const,
|
|
}));
|
|
const vm = await getTeamRosterViewModel(fullMemberships);
|
|
setViewModel(vm);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
void load();
|
|
}, [memberships]);
|
|
|
|
const getRoleBadgeColor = (role: TeamRole) => {
|
|
switch (role) {
|
|
case 'owner':
|
|
return 'bg-warning-amber/20 text-warning-amber';
|
|
case 'manager':
|
|
return 'bg-primary-blue/20 text-primary-blue';
|
|
default:
|
|
return 'bg-charcoal-outline text-gray-300';
|
|
}
|
|
};
|
|
|
|
const getRoleLabel = (role: TeamRole) => {
|
|
return role.charAt(0).toUpperCase() + role.slice(1);
|
|
};
|
|
|
|
function getRoleOrder(role: TeamRole): number {
|
|
switch (role) {
|
|
case 'owner':
|
|
return 0;
|
|
case 'manager':
|
|
return 1;
|
|
case 'driver':
|
|
return 2;
|
|
default:
|
|
return 3;
|
|
}
|
|
}
|
|
|
|
const sortedMembers = viewModel
|
|
? [...viewModel.members].sort((a, b) => {
|
|
switch (sortBy) {
|
|
case 'rating': {
|
|
const ratingA = a.rating ?? 0;
|
|
const ratingB = b.rating ?? 0;
|
|
return ratingB - ratingA;
|
|
}
|
|
case 'role': {
|
|
return getRoleOrder(a.role) - getRoleOrder(b.role);
|
|
}
|
|
case 'name': {
|
|
return a.driver.name.localeCompare(b.driver.name);
|
|
}
|
|
default:
|
|
return 0;
|
|
}
|
|
})
|
|
: [];
|
|
|
|
const teamAverageRating = viewModel?.averageRating ?? 0;
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card>
|
|
<div className="text-center py-8 text-gray-400">Loading roster...</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h3 className="text-xl font-semibold text-white">Team Roster</h3>
|
|
<p className="text-sm text-gray-400 mt-1">
|
|
{memberships.length} {memberships.length === 1 ? 'member' : 'members'} • Avg Rating:{' '}
|
|
<span className="text-primary-blue font-medium">{teamAverageRating}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<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="role">Role</option>
|
|
<option value="name">Name</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{sortedMembers.map((member) => {
|
|
const { driver, role, joinedAt, rating, overallRank } = member;
|
|
|
|
const canManageMembership = isAdmin && role !== 'owner';
|
|
|
|
return (
|
|
<div
|
|
key={driver.id}
|
|
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-charcoal-outline/60 transition-colors"
|
|
>
|
|
<DriverIdentity
|
|
driver={driver}
|
|
href={`/drivers/${driver.id}?from=team&teamId=${teamId}`}
|
|
contextLabel={getRoleLabel(role)}
|
|
meta={
|
|
<span>
|
|
{driver.country} • Joined {new Date(joinedAt).toLocaleDateString()}
|
|
</span>
|
|
}
|
|
size="md"
|
|
/>
|
|
|
|
{rating !== null && (
|
|
<div className="flex items-center gap-6 text-center">
|
|
<div>
|
|
<div className="text-lg font-bold text-primary-blue">
|
|
{rating}
|
|
</div>
|
|
<div className="text-xs text-gray-400">Rating</div>
|
|
</div>
|
|
{overallRank !== null && (
|
|
<div>
|
|
<div className="text-sm text-gray-300">#{overallRank}</div>
|
|
<div className="text-xs text-gray-500">Rank</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{canManageMembership && (
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
className="px-3 py-2 bg-iron-gray border-0 rounded text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
|
|
value={role}
|
|
onChange={(e) =>
|
|
onChangeRole?.(driver.id, e.target.value as TeamRole)
|
|
}
|
|
>
|
|
<option value="driver">Driver</option>
|
|
<option value="manager">Manager</option>
|
|
</select>
|
|
|
|
<button
|
|
onClick={() => onRemoveMember?.(driver.id)}
|
|
className="px-3 py-2 bg-danger-red/20 hover:bg-danger-red/30 text-danger-red rounded text-sm font-medium transition-colors"
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{memberships.length === 0 && (
|
|
<div className="text-center py-8 text-gray-400">No team members yet.</div>
|
|
)}
|
|
</Card>
|
|
);
|
|
} |