wip
This commit is contained in:
@@ -8,7 +8,7 @@ import CareerHighlights from './CareerHighlights';
|
||||
import DriverRankings from './DriverRankings';
|
||||
import PerformanceMetrics from './PerformanceMetrics';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getDriverStats, getLeagueRankings, getGetDriverTeamUseCase, getAllDriverRankings } from '@/lib/di-container';
|
||||
import { getLeagueRankings, getGetDriverTeamUseCase, getGetProfileOverviewUseCase } from '@/lib/di-container';
|
||||
import { getPrimaryLeagueIdForDriver } from '@/lib/leagueMembership';
|
||||
import type { GetDriverTeamQueryResultDTO } from '@gridpilot/racing/application/dto/TeamCommandAndQueryDTO';
|
||||
|
||||
@@ -19,24 +19,34 @@ interface DriverProfileProps {
|
||||
}
|
||||
|
||||
export default function DriverProfile({ driver, isOwnProfile = false, onEditClick }: DriverProfileProps) {
|
||||
const driverStats = getDriverStats(driver.id);
|
||||
const primaryLeagueId = getPrimaryLeagueIdForDriver(driver.id);
|
||||
const leagueRank = primaryLeagueId
|
||||
? getLeagueRankings(driver.id, primaryLeagueId)
|
||||
: { rank: 0, totalDrivers: 0, percentile: 0 };
|
||||
const allRankings = getAllDriverRankings();
|
||||
const [profileData, setProfileData] = useState<any>(null);
|
||||
const [teamData, setTeamData] = useState<GetDriverTeamQueryResultDTO | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const useCase = getGetDriverTeamUseCase();
|
||||
await useCase.execute({ driverId: driver.id });
|
||||
const viewModel = useCase.presenter.getViewModel();
|
||||
setTeamData(viewModel.result);
|
||||
// Load profile data using GetProfileOverviewUseCase
|
||||
const profileUseCase = getGetProfileOverviewUseCase();
|
||||
await profileUseCase.execute({ driverId: driver.id });
|
||||
const profileViewModel = profileUseCase.presenter.getViewModel();
|
||||
setProfileData(profileViewModel);
|
||||
|
||||
// Load team data
|
||||
const teamUseCase = getGetDriverTeamUseCase();
|
||||
await teamUseCase.execute({ driverId: driver.id });
|
||||
const teamViewModel = teamUseCase.presenter.getViewModel();
|
||||
setTeamData(teamViewModel.result);
|
||||
};
|
||||
void load();
|
||||
}, [driver.id]);
|
||||
|
||||
const driverStats = profileData?.stats || null;
|
||||
const primaryLeagueId = getPrimaryLeagueIdForDriver(driver.id);
|
||||
const leagueRank = primaryLeagueId
|
||||
? getLeagueRankings(driver.id, primaryLeagueId)
|
||||
: { rank: 0, totalDrivers: 0, percentile: 0 };
|
||||
const globalRank = profileData?.currentDriver?.globalRank || null;
|
||||
const totalDrivers = profileData?.currentDriver?.totalDrivers || 0;
|
||||
|
||||
const performanceStats = driverStats ? {
|
||||
winRate: (driverStats.wins / driverStats.totalRaces) * 100,
|
||||
podiumRate: (driverStats.podiums / driverStats.totalRaces) * 100,
|
||||
@@ -51,8 +61,8 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
|
||||
{
|
||||
type: 'overall' as const,
|
||||
name: 'Overall Ranking',
|
||||
rank: driverStats.overallRank,
|
||||
totalDrivers: allRankings.length,
|
||||
rank: globalRank || driverStats.overallRank || 0,
|
||||
totalDrivers: totalDrivers,
|
||||
percentile: driverStats.percentile,
|
||||
rating: driverStats.rating,
|
||||
},
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import Card from '../ui/Card';
|
||||
import RankBadge from './RankBadge';
|
||||
import { getDriverStats, getAllDriverRankings, getLeagueRankings } from '@/lib/di-container';
|
||||
import { getLeagueRankings, getGetProfileOverviewUseCase } from '@/lib/di-container';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getPrimaryLeagueIdForDriver } from '@/lib/leagueMembership';
|
||||
|
||||
interface ProfileStatsProps {
|
||||
@@ -18,8 +19,22 @@ interface ProfileStatsProps {
|
||||
}
|
||||
|
||||
export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
||||
const driverStats = driverId ? getDriverStats(driverId) : null;
|
||||
const allRankings = getAllDriverRankings();
|
||||
const [profileData, setProfileData] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (driverId) {
|
||||
const load = async () => {
|
||||
const profileUseCase = getGetProfileOverviewUseCase();
|
||||
await profileUseCase.execute({ driverId });
|
||||
const vm = profileUseCase.presenter.getViewModel();
|
||||
setProfileData(vm);
|
||||
};
|
||||
void load();
|
||||
}
|
||||
}, [driverId]);
|
||||
|
||||
const driverStats = profileData?.stats || null;
|
||||
const totalDrivers = profileData?.currentDriver?.totalDrivers || 0;
|
||||
const primaryLeagueId = driverId ? getPrimaryLeagueIdForDriver(driverId) : null;
|
||||
const leagueRank =
|
||||
driverId && primaryLeagueId ? getLeagueRankings(driverId, primaryLeagueId) : null;
|
||||
@@ -80,7 +95,7 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
||||
<div>
|
||||
<div className="text-white font-medium text-lg">Overall Ranking</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{driverStats.overallRank} of {allRankings.length} drivers
|
||||
{driverStats.overallRank} of {totalDrivers} drivers
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, FormEvent, useCallback } from 'react';
|
||||
import React, { useEffect, useState, FormEvent, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
FileText,
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||
import Button from '../ui/Button';
|
||||
import Card from '../ui/Card';
|
||||
import LeagueMembers from './LeagueMembers';
|
||||
import ScheduleRaceForm from './ScheduleRaceForm';
|
||||
import { League } from '@gridpilot/racing/domain/entities/League';
|
||||
import {
|
||||
getLeagueMembershipRepository,
|
||||
getDriverStats,
|
||||
getAllDriverRankings,
|
||||
getDriverRepository,
|
||||
getGetLeagueFullConfigQuery,
|
||||
getRaceRepository,
|
||||
getProtestRepository,
|
||||
} from '@/lib/di-container';
|
||||
loadLeagueJoinRequests,
|
||||
approveLeagueJoinRequest,
|
||||
rejectLeagueJoinRequest,
|
||||
loadLeagueOwnerSummary,
|
||||
loadLeagueConfig,
|
||||
loadLeagueProtests,
|
||||
removeLeagueMember as removeLeagueMemberCommand,
|
||||
updateLeagueMemberRole as updateLeagueMemberRoleCommand,
|
||||
type LeagueJoinRequestViewModel,
|
||||
type LeagueOwnerSummaryViewModel,
|
||||
type LeagueAdminProtestsViewModel,
|
||||
} from '@/lib/presenters/LeagueAdminPresenter';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||
import { LeagueBasicsSection } from './LeagueBasicsSection';
|
||||
import { LeagueStructureSection } from './LeagueStructureSection';
|
||||
@@ -26,25 +29,21 @@ import { LeagueSponsorshipsSection } from './LeagueSponsorshipsSection';
|
||||
import { LeagueMembershipFeesSection } from './LeagueMembershipFeesSection';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import type { MembershipRole } from '@/lib/leagueMembership';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
|
||||
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { AlertTriangle, CheckCircle, Clock, XCircle, Flag, Calendar, User, DollarSign, Wallet, Paintbrush, Trophy, Download, Car, Upload } from 'lucide-react';
|
||||
import type { Protest } from '@gridpilot/racing/domain/entities/Protest';
|
||||
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
|
||||
interface JoinRequest {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
requestedAt: Date;
|
||||
message?: string;
|
||||
}
|
||||
type JoinRequest = LeagueJoinRequestViewModel;
|
||||
|
||||
interface LeagueAdminProps {
|
||||
league: League;
|
||||
league: {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
settings: {
|
||||
pointsSystem: string;
|
||||
};
|
||||
};
|
||||
onLeagueUpdate?: () => void;
|
||||
}
|
||||
|
||||
@@ -54,8 +53,7 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
const pathname = usePathname();
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const [joinRequests, setJoinRequests] = useState<JoinRequest[]>([]);
|
||||
const [requestDriversById, setRequestDriversById] = useState<Record<string, DriverDTO>>({});
|
||||
const [ownerDriver, setOwnerDriver] = useState<DriverDTO | null>(null);
|
||||
const [ownerSummary, setOwnerSummary] = useState<LeagueOwnerSummaryViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'protests' | 'sponsorships' | 'fees' | 'wallet' | 'prizes' | 'liveries'>('members');
|
||||
@@ -63,32 +61,14 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [configForm, setConfigForm] = useState<LeagueConfigFormModel | null>(null);
|
||||
const [configLoading, setConfigLoading] = useState(false);
|
||||
const [protests, setProtests] = useState<Protest[]>([]);
|
||||
const [protestRaces, setProtestRaces] = useState<Record<string, Race>>({});
|
||||
const [protestDriversById, setProtestDriversById] = useState<Record<string, DriverDTO>>({});
|
||||
const [protestsViewModel, setProtestsViewModel] = useState<LeagueAdminProtestsViewModel | null>(null);
|
||||
const [protestsLoading, setProtestsLoading] = useState(false);
|
||||
|
||||
const loadJoinRequests = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const requests = await membershipRepo.getJoinRequests(league.id);
|
||||
const requests = await loadLeagueJoinRequests(league.id);
|
||||
setJoinRequests(requests);
|
||||
|
||||
const driverRepo = getDriverRepository();
|
||||
const uniqueDriverIds = Array.from(new Set(requests.map((r) => r.driverId)));
|
||||
const driverEntities = await Promise.all(
|
||||
uniqueDriverIds.map((id) => driverRepo.findById(id)),
|
||||
);
|
||||
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;
|
||||
}
|
||||
setRequestDriversById(byId);
|
||||
} catch (err) {
|
||||
console.error('Failed to load join requests:', err);
|
||||
} finally {
|
||||
@@ -103,24 +83,22 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
useEffect(() => {
|
||||
async function loadOwner() {
|
||||
try {
|
||||
const driverRepo = getDriverRepository();
|
||||
const entity = await driverRepo.findById(league.ownerId);
|
||||
setOwnerDriver(EntityMappers.toDriverDTO(entity));
|
||||
const summary = await loadLeagueOwnerSummary(league);
|
||||
setOwnerSummary(summary);
|
||||
} catch (err) {
|
||||
console.error('Failed to load league owner:', err);
|
||||
}
|
||||
}
|
||||
|
||||
loadOwner();
|
||||
}, [league.ownerId]);
|
||||
}, [league]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadConfig() {
|
||||
setConfigLoading(true);
|
||||
try {
|
||||
const query = getGetLeagueFullConfigQuery();
|
||||
const form = await query.execute({ leagueId: league.id });
|
||||
setConfigForm(form);
|
||||
const configVm = await loadLeagueConfig(league.id);
|
||||
setConfigForm(configVm.form);
|
||||
} catch (err) {
|
||||
console.error('Failed to load league config:', err);
|
||||
} finally {
|
||||
@@ -136,45 +114,8 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
async function loadProtests() {
|
||||
setProtestsLoading(true);
|
||||
try {
|
||||
const raceRepo = getRaceRepository();
|
||||
const protestRepo = getProtestRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
|
||||
// Get all races for this league
|
||||
const leagueRaces = await raceRepo.findByLeagueId(league.id);
|
||||
|
||||
// Get protests for each race
|
||||
const allProtests: Protest[] = [];
|
||||
const racesById: Record<string, Race> = {};
|
||||
|
||||
for (const race of leagueRaces) {
|
||||
racesById[race.id] = race;
|
||||
const raceProtests = await protestRepo.findByRaceId(race.id);
|
||||
allProtests.push(...raceProtests);
|
||||
}
|
||||
|
||||
setProtests(allProtests);
|
||||
setProtestRaces(racesById);
|
||||
|
||||
// Load driver info for all protesters and accused
|
||||
const driverIds = new Set<string>();
|
||||
allProtests.forEach((p) => {
|
||||
driverIds.add(p.protestingDriverId);
|
||||
driverIds.add(p.accusedDriverId);
|
||||
});
|
||||
|
||||
const driverEntities = await Promise.all(
|
||||
Array.from(driverIds).map((id) => driverRepo.findById(id)),
|
||||
);
|
||||
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;
|
||||
}
|
||||
setProtestDriversById(byId);
|
||||
const vm = await loadLeagueProtests(league.id);
|
||||
setProtestsViewModel(vm);
|
||||
} catch (err) {
|
||||
console.error('Failed to load protests:', err);
|
||||
} finally {
|
||||
@@ -189,23 +130,8 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
|
||||
const handleApproveRequest = async (requestId: string) => {
|
||||
try {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const requests = await membershipRepo.getJoinRequests(league.id);
|
||||
const request = requests.find((r) => r.id === requestId);
|
||||
if (!request) {
|
||||
throw new Error('Join request not found');
|
||||
}
|
||||
|
||||
await membershipRepo.saveMembership({
|
||||
leagueId: request.leagueId,
|
||||
driverId: request.driverId,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
joinedAt: new Date(),
|
||||
});
|
||||
await membershipRepo.removeJoinRequest(requestId);
|
||||
|
||||
await loadJoinRequests();
|
||||
const updated = await approveLeagueJoinRequest(league.id, requestId);
|
||||
setJoinRequests(updated);
|
||||
onLeagueUpdate?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to approve request');
|
||||
@@ -214,14 +140,13 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
|
||||
const handleRejectRequest = async (requestId: string, trimmedReason: string) => {
|
||||
try {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
// Alpha-only: we do not persist the reason yet, but we at least log it.
|
||||
console.log('Join request rejected with reason:', {
|
||||
requestId,
|
||||
reason: trimmedReason,
|
||||
});
|
||||
await membershipRepo.removeJoinRequest(requestId);
|
||||
await loadJoinRequests();
|
||||
const updated = await rejectLeagueJoinRequest(league.id, requestId);
|
||||
setJoinRequests(updated);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to reject request');
|
||||
}
|
||||
@@ -233,21 +158,7 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
}
|
||||
|
||||
try {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const performer = await membershipRepo.getMembership(league.id, currentDriverId);
|
||||
if (!performer || (performer.role !== 'owner' && performer.role !== 'admin')) {
|
||||
throw new Error('Only owners or admins can remove members');
|
||||
}
|
||||
|
||||
const membership = await membershipRepo.getMembership(league.id, driverId);
|
||||
if (!membership) {
|
||||
throw new Error('Member not found');
|
||||
}
|
||||
if (membership.role === 'owner') {
|
||||
throw new Error('Cannot remove the league owner');
|
||||
}
|
||||
|
||||
await membershipRepo.removeMembership(league.id, driverId);
|
||||
await removeLeagueMemberCommand(league.id, currentDriverId, driverId);
|
||||
onLeagueUpdate?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to remove member');
|
||||
@@ -256,25 +167,7 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
|
||||
const handleUpdateRole = async (driverId: string, newRole: MembershipRole) => {
|
||||
try {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const performer = await membershipRepo.getMembership(league.id, currentDriverId);
|
||||
if (!performer || performer.role !== 'owner') {
|
||||
throw new Error('Only the league owner can update roles');
|
||||
}
|
||||
|
||||
const membership = await membershipRepo.getMembership(league.id, driverId);
|
||||
if (!membership) {
|
||||
throw new Error('Member not found');
|
||||
}
|
||||
if (membership.role === 'owner') {
|
||||
throw new Error('Cannot change the owner role');
|
||||
}
|
||||
|
||||
await membershipRepo.saveMembership({
|
||||
...membership,
|
||||
role: newRole,
|
||||
});
|
||||
|
||||
await updateLeagueMemberRoleCommand(league.id, currentDriverId, driverId, newRole);
|
||||
onLeagueUpdate?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update role');
|
||||
@@ -316,45 +209,6 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
router.push(url, { scroll: false });
|
||||
};
|
||||
|
||||
const ownerSummary = useMemo(() => {
|
||||
if (!ownerDriver) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = getDriverStats(ownerDriver.id);
|
||||
const allRankings = getAllDriverRankings();
|
||||
|
||||
let rating: number | null = stats?.rating ?? null;
|
||||
let rank: number | null = null;
|
||||
|
||||
if (stats) {
|
||||
if (typeof stats.overallRank === 'number' && stats.overallRank > 0) {
|
||||
rank = stats.overallRank;
|
||||
} else {
|
||||
const indexInGlobal = allRankings.findIndex(
|
||||
(stat) => stat.driverId === stats.driverId,
|
||||
);
|
||||
if (indexInGlobal !== -1) {
|
||||
rank = indexInGlobal + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (rating === null) {
|
||||
const globalEntry = allRankings.find(
|
||||
(stat) => stat.driverId === stats.driverId,
|
||||
);
|
||||
if (globalEntry) {
|
||||
rating = globalEntry.rating;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
driver: ownerDriver,
|
||||
rating,
|
||||
rank,
|
||||
};
|
||||
}, [ownerDriver]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -507,7 +361,7 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{joinRequests.map((request) => {
|
||||
const driver = requestDriversById[request.driverId];
|
||||
const driver = request.driver;
|
||||
const requestedOn = new Date(request.requestedAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -599,7 +453,7 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="animate-pulse">Loading protests...</div>
|
||||
</div>
|
||||
) : protests.length === 0 ? (
|
||||
) : !protestsViewModel || protestsViewModel.protests.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
|
||||
<Flag className="w-8 h-8 text-gray-500" />
|
||||
@@ -619,7 +473,7 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
<span className="text-xs font-medium uppercase">Pending</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{protests.filter((p) => p.status === 'pending').length}
|
||||
{protestsViewModel.protests.filter((p) => p.status === 'pending').length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
|
||||
@@ -628,7 +482,7 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
<span className="text-xs font-medium uppercase">Resolved</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{protests.filter((p) => p.status === 'upheld' || p.status === 'dismissed').length}
|
||||
{protestsViewModel.protests.filter((p) => p.status === 'upheld' || p.status === 'dismissed').length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
|
||||
@@ -637,17 +491,17 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
<span className="text-xs font-medium uppercase">Total</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{protests.length}
|
||||
{protestsViewModel.protests.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Protest list */}
|
||||
<div className="space-y-3">
|
||||
{protests.map((protest) => {
|
||||
const race = protestRaces[protest.raceId];
|
||||
const filer = protestDriversById[protest.protestingDriverId];
|
||||
const accused = protestDriversById[protest.accusedDriverId];
|
||||
{protestsViewModel.protests.map((protest) => {
|
||||
const race = protestsViewModel.racesById[protest.raceId];
|
||||
const filer = protestsViewModel.driversById[protest.protestingDriverId];
|
||||
const accused = protestsViewModel.driversById[protest.accusedDriverId];
|
||||
|
||||
const statusConfig = {
|
||||
pending: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', border: 'border-warning-amber/30', icon: Clock, label: 'Pending Review' },
|
||||
@@ -1073,7 +927,7 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
title="Reject join request"
|
||||
description={
|
||||
activeRejectRequest
|
||||
? `Provide a reason for rejecting ${requestDriversById[activeRejectRequest.driverId]?.name ?? 'this driver'}.`
|
||||
? `Provide a reason for rejecting ${activeRejectRequest.driver?.name ?? 'this driver'}.`
|
||||
: 'Provide a reason for rejecting this join request.'
|
||||
}
|
||||
primaryActionLabel="Reject"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { FileText, Gamepad2, AlertCircle, Check } from 'lucide-react';
|
||||
import Input from '@/components/ui/Input';
|
||||
import type {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { TrendingDown, Check, HelpCircle, X, Zap } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateCcw, HelpCircle, X } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Scale, Users, Clock, Bell, Shield, Vote, UserCheck, AlertTriangle } from 'lucide-react';
|
||||
import type { LeagueConfigFormModel, LeagueStewardingFormDTO } from '@gridpilot/racing/application';
|
||||
import type { StewardingDecisionMode } from '@gridpilot/racing/domain/entities/League';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useMemo, useRef } from 'react';
|
||||
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
|
||||
@@ -4,33 +4,35 @@ import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Button from '../ui/Button';
|
||||
import Input from '../ui/Input';
|
||||
import { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import { League } from '@gridpilot/racing/domain/entities/League';
|
||||
import { SessionType } from '@gridpilot/racing/domain/entities/Race';
|
||||
import { getRaceRepository, getLeagueRepository } from '../../lib/di-container';
|
||||
import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRepository';
|
||||
import {
|
||||
loadScheduleRaceFormLeagues,
|
||||
scheduleRaceFromForm,
|
||||
type ScheduleRaceFormData,
|
||||
type ScheduledRaceViewModel,
|
||||
type LeagueOptionViewModel,
|
||||
} from '@/lib/presenters/ScheduleRaceFormPresenter';
|
||||
|
||||
interface ScheduleRaceFormProps {
|
||||
preSelectedLeagueId?: string;
|
||||
onSuccess?: (race: Race) => void;
|
||||
onSuccess?: (race: ScheduledRaceViewModel) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function ScheduleRaceForm({
|
||||
preSelectedLeagueId,
|
||||
export default function ScheduleRaceForm({
|
||||
preSelectedLeagueId,
|
||||
onSuccess,
|
||||
onCancel
|
||||
onCancel
|
||||
}: ScheduleRaceFormProps) {
|
||||
const router = useRouter();
|
||||
const [leagues, setLeagues] = useState<League[]>([]);
|
||||
const [leagues, setLeagues] = useState<LeagueOptionViewModel[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
const [formData, setFormData] = useState<ScheduleRaceFormData>({
|
||||
leagueId: preSelectedLeagueId || '',
|
||||
track: '',
|
||||
car: '',
|
||||
sessionType: 'race' as SessionType,
|
||||
sessionType: 'race',
|
||||
scheduledDate: '',
|
||||
scheduledTime: '',
|
||||
});
|
||||
@@ -39,11 +41,10 @@ export default function ScheduleRaceForm({
|
||||
|
||||
useEffect(() => {
|
||||
const loadLeagues = async () => {
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const allLeagues = await leagueRepo.findAll();
|
||||
const allLeagues = await loadScheduleRaceFormLeagues();
|
||||
setLeagues(allLeagues);
|
||||
};
|
||||
loadLeagues();
|
||||
void loadLeagues();
|
||||
}, []);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
@@ -94,20 +95,7 @@ export default function ScheduleRaceForm({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const raceRepo = getRaceRepository();
|
||||
const scheduledAt = new Date(`${formData.scheduledDate}T${formData.scheduledTime}`);
|
||||
|
||||
const race = Race.create({
|
||||
id: InMemoryRaceRepository.generateId(),
|
||||
leagueId: formData.leagueId,
|
||||
track: formData.track.trim(),
|
||||
car: formData.car.trim(),
|
||||
sessionType: formData.sessionType,
|
||||
scheduledAt,
|
||||
status: 'scheduled',
|
||||
});
|
||||
|
||||
const createdRace = await raceRepo.create(race);
|
||||
const createdRace = await scheduleRaceFromForm(formData);
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(createdRace);
|
||||
@@ -187,7 +175,7 @@ export default function ScheduleRaceForm({
|
||||
`}
|
||||
>
|
||||
<option value="">Select a league</option>
|
||||
{leagues.map(league => (
|
||||
{leagues.map((league: any) => (
|
||||
<option key={league.id} value={league.id}>
|
||||
{league.name}
|
||||
</option>
|
||||
@@ -241,7 +229,7 @@ export default function ScheduleRaceForm({
|
||||
</label>
|
||||
<select
|
||||
value={formData.sessionType}
|
||||
onChange={(e) => handleChange('sessionType', e.target.value)}
|
||||
onChange={(e) => handleChange('sessionType', e.target.value as SessionType)}
|
||||
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="practice">Practice</option>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Button from '../ui/Button';
|
||||
import { Result } from '@gridpilot/racing/domain/entities/Result';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
interface ImportResultRowDTO {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}
|
||||
|
||||
interface ImportResultsFormProps {
|
||||
raceId: string;
|
||||
onSuccess: (results: Result[]) => void;
|
||||
onSuccess: (results: ImportResultRowDTO[]) => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
@@ -25,36 +34,35 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
|
||||
|
||||
const parseCSV = (content: string): CSVRow[] => {
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
|
||||
if (lines.length < 2) {
|
||||
throw new Error('CSV file is empty or invalid');
|
||||
}
|
||||
|
||||
// Parse header
|
||||
const header = lines[0].toLowerCase().split(',').map(h => h.trim());
|
||||
const header = lines[0].toLowerCase().split(',').map((h) => h.trim());
|
||||
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
|
||||
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!header.includes(field)) {
|
||||
throw new Error(`Missing required field: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse rows
|
||||
const rows: CSVRow[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = lines[i].split(',').map(v => v.trim());
|
||||
|
||||
const values = lines[i].split(',').map((v) => v.trim());
|
||||
|
||||
if (values.length !== header.length) {
|
||||
throw new Error(`Invalid row ${i}: expected ${header.length} columns, got ${values.length}`);
|
||||
throw new Error(
|
||||
`Invalid row ${i}: expected ${header.length} columns, got ${values.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const row: any = {};
|
||||
const row: Record<string, string> = {};
|
||||
header.forEach((field, index) => {
|
||||
row[field] = values[index];
|
||||
row[field] = values[index] ?? '';
|
||||
});
|
||||
|
||||
// Validate and convert types
|
||||
const driverId = row.driverid;
|
||||
const position = parseInt(row.position, 10);
|
||||
const fastestLap = parseFloat(row.fastestlap);
|
||||
@@ -65,34 +73,32 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
|
||||
throw new Error(`Row ${i}: driverId is required`);
|
||||
}
|
||||
|
||||
if (isNaN(position) || position < 1) {
|
||||
if (Number.isNaN(position) || position < 1) {
|
||||
throw new Error(`Row ${i}: position must be a positive integer`);
|
||||
}
|
||||
|
||||
if (isNaN(fastestLap) || fastestLap < 0) {
|
||||
if (Number.isNaN(fastestLap) || fastestLap < 0) {
|
||||
throw new Error(`Row ${i}: fastestLap must be a non-negative number`);
|
||||
}
|
||||
|
||||
if (isNaN(incidents) || incidents < 0) {
|
||||
if (Number.isNaN(incidents) || incidents < 0) {
|
||||
throw new Error(`Row ${i}: incidents must be a non-negative integer`);
|
||||
}
|
||||
|
||||
if (isNaN(startPosition) || startPosition < 1) {
|
||||
if (Number.isNaN(startPosition) || startPosition < 1) {
|
||||
throw new Error(`Row ${i}: startPosition must be a positive integer`);
|
||||
}
|
||||
|
||||
rows.push({ driverId, position, fastestLap, incidents, startPosition });
|
||||
}
|
||||
|
||||
// Validate no duplicate positions
|
||||
const positions = rows.map(r => r.position);
|
||||
const positions = rows.map((r) => r.position);
|
||||
const uniquePositions = new Set(positions);
|
||||
if (positions.length !== uniquePositions.size) {
|
||||
throw new Error('Duplicate positions found in CSV');
|
||||
}
|
||||
|
||||
// Validate no duplicate drivers
|
||||
const driverIds = rows.map(r => r.driverId);
|
||||
const driverIds = rows.map((r) => r.driverId);
|
||||
const uniqueDrivers = new Set(driverIds);
|
||||
if (driverIds.length !== uniqueDrivers.size) {
|
||||
throw new Error('Duplicate driver IDs found in CSV');
|
||||
@@ -109,33 +115,27 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Read file
|
||||
const content = await file.text();
|
||||
|
||||
// Parse CSV
|
||||
const rows = parseCSV(content);
|
||||
|
||||
// Create Result entities
|
||||
const results = rows.map(row =>
|
||||
Result.create({
|
||||
id: uuidv4(),
|
||||
raceId,
|
||||
driverId: row.driverId,
|
||||
position: row.position,
|
||||
fastestLap: row.fastestLap,
|
||||
incidents: row.incidents,
|
||||
startPosition: row.startPosition,
|
||||
})
|
||||
);
|
||||
const results: ImportResultRowDTO[] = rows.map((row) => ({
|
||||
id: uuidv4(),
|
||||
raceId,
|
||||
driverId: row.driverId,
|
||||
position: row.position,
|
||||
fastestLap: row.fastestLap,
|
||||
incidents: row.incidents,
|
||||
startPosition: row.startPosition,
|
||||
}));
|
||||
|
||||
onSuccess(results);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to parse CSV file';
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to parse CSV file';
|
||||
setError(errorMessage);
|
||||
onError(errorMessage);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
// Reset file input
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,32 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Result } from '@gridpilot/racing/domain/entities/Result';
|
||||
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
||||
import type { PenaltyType } from '@gridpilot/racing/domain/entities/Penalty';
|
||||
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Penalty data for display (can be domain Penalty or RacePenaltyDTO)
|
||||
*/
|
||||
type PenaltyTypeDTO =
|
||||
| 'time_penalty'
|
||||
| 'grid_penalty'
|
||||
| 'points_deduction'
|
||||
| 'disqualification'
|
||||
| 'warning'
|
||||
| 'license_points'
|
||||
| string;
|
||||
|
||||
interface ResultDTO {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
getPositionChange(): number;
|
||||
}
|
||||
|
||||
interface DriverDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PenaltyData {
|
||||
driverId: string;
|
||||
type: PenaltyType;
|
||||
type: PenaltyTypeDTO;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
interface ResultsTableProps {
|
||||
results: Result[];
|
||||
drivers: Driver[];
|
||||
results: ResultDTO[];
|
||||
drivers: DriverDTO[];
|
||||
pointsSystem: Record<number, number>;
|
||||
fastestLapTime?: number;
|
||||
penalties?: PenaltyData[];
|
||||
currentDriverId?: string;
|
||||
}
|
||||
|
||||
export default function ResultsTable({ results, drivers, pointsSystem, fastestLapTime, penalties = [], currentDriverId }: ResultsTableProps) {
|
||||
const getDriver = (driverId: string): Driver | undefined => {
|
||||
return drivers.find(d => d.id === driverId);
|
||||
export default function ResultsTable({
|
||||
results,
|
||||
drivers,
|
||||
pointsSystem,
|
||||
fastestLapTime,
|
||||
penalties = [],
|
||||
currentDriverId,
|
||||
}: ResultsTableProps) {
|
||||
const getDriver = (driverId: string): DriverDTO | undefined => {
|
||||
return drivers.find((d) => d.id === driverId);
|
||||
};
|
||||
|
||||
const getDriverName = (driverId: string): string => {
|
||||
@@ -35,7 +61,7 @@ export default function ResultsTable({ results, drivers, pointsSystem, fastestLa
|
||||
};
|
||||
|
||||
const getDriverPenalties = (driverId: string): PenaltyData[] => {
|
||||
return penalties.filter(p => p.driverId === driverId);
|
||||
return penalties.filter((p) => p.driverId === driverId);
|
||||
};
|
||||
|
||||
const getPenaltyDescription = (penalty: PenaltyData): string => {
|
||||
@@ -97,30 +123,39 @@ export default function ResultsTable({ results, drivers, pointsSystem, fastestLa
|
||||
<tbody>
|
||||
{results.map((result) => {
|
||||
const positionChange = result.getPositionChange();
|
||||
const isFastestLap = fastestLapTime && result.fastestLap === fastestLapTime;
|
||||
const isFastestLap =
|
||||
typeof fastestLapTime === 'number' && result.fastestLap === fastestLapTime;
|
||||
const driverPenalties = getDriverPenalties(result.driverId);
|
||||
const driver = getDriver(result.driverId);
|
||||
const isCurrentUser = currentDriverId === result.driverId;
|
||||
const isPodium = result.position <= 3;
|
||||
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={result.id}
|
||||
className={`
|
||||
border-b border-charcoal-outline/50 transition-colors
|
||||
${isCurrentUser
|
||||
? 'bg-gradient-to-r from-primary-blue/20 via-primary-blue/10 to-transparent hover:from-primary-blue/30'
|
||||
: 'hover:bg-iron-gray/20'}
|
||||
${
|
||||
isCurrentUser
|
||||
? 'bg-gradient-to-r from-primary-blue/20 via-primary-blue/10 to-transparent hover:from-primary-blue/30'
|
||||
: 'hover:bg-iron-gray/20'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<div className={`
|
||||
<div
|
||||
className={`
|
||||
inline-flex items-center justify-center w-8 h-8 rounded-lg font-bold text-sm
|
||||
${result.position === 1 ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
result.position === 2 ? 'bg-gray-400/20 text-gray-300' :
|
||||
result.position === 3 ? 'bg-amber-600/20 text-amber-500' :
|
||||
'text-white'}
|
||||
`}>
|
||||
${
|
||||
result.position === 1
|
||||
? 'bg-yellow-500/20 text-yellow-400'
|
||||
: result.position === 2
|
||||
? 'bg-gray-400/20 text-gray-300'
|
||||
: result.position === 3
|
||||
? 'bg-amber-600/20 text-amber-500'
|
||||
: 'text-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{result.position}
|
||||
</div>
|
||||
</td>
|
||||
@@ -128,17 +163,27 @@ export default function ResultsTable({ results, drivers, pointsSystem, fastestLa
|
||||
<div className="flex items-center gap-3">
|
||||
{driver ? (
|
||||
<>
|
||||
<div className={`
|
||||
<div
|
||||
className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0
|
||||
${isCurrentUser ? 'bg-primary-blue/30 text-primary-blue ring-2 ring-primary-blue/50' : 'bg-iron-gray text-gray-400'}
|
||||
`}>
|
||||
${
|
||||
isCurrentUser
|
||||
? 'bg-primary-blue/30 text-primary-blue ring-2 ring-primary-blue/50'
|
||||
: 'bg-iron-gray text-gray-400'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{driver.name.charAt(0)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/drivers/${driver.id}`}
|
||||
className={`
|
||||
flex items-center gap-1.5 group
|
||||
${isCurrentUser ? 'text-primary-blue font-semibold' : 'text-white hover:text-primary-blue'}
|
||||
${
|
||||
isCurrentUser
|
||||
? 'text-primary-blue font-semibold'
|
||||
: 'text-white hover:text-primary-blue'
|
||||
}
|
||||
transition-colors
|
||||
`}
|
||||
>
|
||||
@@ -157,20 +202,30 @@ export default function ResultsTable({ results, drivers, pointsSystem, fastestLa
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={isFastestLap ? 'text-performance-green font-medium' : 'text-white'}>
|
||||
<span
|
||||
className={
|
||||
isFastestLap ? 'text-performance-green font-medium' : 'text-white'
|
||||
}
|
||||
>
|
||||
{formatLapTime(result.fastestLap)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={result.incidents > 0 ? 'text-warning-amber' : 'text-white'}>
|
||||
<span
|
||||
className={result.incidents > 0 ? 'text-warning-amber' : 'text-white'}
|
||||
>
|
||||
{result.incidents}×
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-white font-medium">{getPoints(result.position)}</span>
|
||||
<span className="text-white font-medium">
|
||||
{getPoints(result.position)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`font-medium ${getPositionChangeColor(positionChange)}`}>
|
||||
<span
|
||||
className={`font-medium ${getPositionChangeColor(positionChange)}`}
|
||||
>
|
||||
{getPositionChangeText(positionChange)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -3,14 +3,22 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
||||
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import type { TeamMembership, TeamRole } from '@gridpilot/racing';
|
||||
import {
|
||||
getTeamRosterViewModel,
|
||||
type TeamRosterViewModel,
|
||||
} from '@/lib/presenters/TeamRosterPresenter';
|
||||
|
||||
type TeamRole = 'owner' | 'manager' | 'driver';
|
||||
|
||||
interface TeamMembershipSummary {
|
||||
driverId: string;
|
||||
role: TeamRole;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
interface TeamRosterProps {
|
||||
teamId: string;
|
||||
memberships: TeamMembership[];
|
||||
memberships: TeamMembershipSummary[];
|
||||
isAdmin: boolean;
|
||||
onRemoveMember?: (driverId: string) => void;
|
||||
onChangeRole?: (driverId: string, newRole: TeamRole) => void;
|
||||
@@ -23,31 +31,22 @@ export default function TeamRoster({
|
||||
onRemoveMember,
|
||||
onChangeRole,
|
||||
}: TeamRosterProps) {
|
||||
const [drivers, setDrivers] = useState<Record<string, DriverDTO>>({});
|
||||
const [viewModel, setViewModel] = useState<TeamRosterViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sortBy, setSortBy] = useState<'role' | 'rating' | 'name'>('rating');
|
||||
|
||||
useEffect(() => {
|
||||
const loadDrivers = async () => {
|
||||
const driverRepo = getDriverRepository();
|
||||
const allDrivers = await driverRepo.findAll();
|
||||
const driverMap: Record<string, DriverDTO> = {};
|
||||
|
||||
for (const membership of memberships) {
|
||||
const driver = allDrivers.find((d) => d.id === membership.driverId);
|
||||
if (driver) {
|
||||
const dto = EntityMappers.toDriverDTO(driver);
|
||||
if (dto) {
|
||||
driverMap[membership.driverId] = dto;
|
||||
}
|
||||
}
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const vm = await getTeamRosterViewModel(memberships);
|
||||
setViewModel(vm);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
setDrivers(driverMap);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
void loadDrivers();
|
||||
void load();
|
||||
}, [memberships]);
|
||||
|
||||
const getRoleBadgeColor = (role: TeamRole) => {
|
||||
@@ -65,36 +64,28 @@ export default function TeamRoster({
|
||||
return role.charAt(0).toUpperCase() + role.slice(1);
|
||||
};
|
||||
|
||||
const sortedMemberships = [...memberships].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'rating': {
|
||||
const statsA = getDriverStats(a.driverId);
|
||||
const statsB = getDriverStats(b.driverId);
|
||||
return (statsB?.rating || 0) - (statsA?.rating || 0);
|
||||
}
|
||||
case 'role': {
|
||||
const roleOrder = { owner: 0, manager: 1, driver: 2 };
|
||||
return roleOrder[a.role] - roleOrder[b.role];
|
||||
}
|
||||
case 'name': {
|
||||
const driverA = drivers[a.driverId];
|
||||
const driverB = drivers[b.driverId];
|
||||
return (driverA?.name || '').localeCompare(driverB?.name || '');
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
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': {
|
||||
const roleOrder: Record<TeamRole, number> = { owner: 0, manager: 1, driver: 2 };
|
||||
return roleOrder[a.role] - roleOrder[b.role];
|
||||
}
|
||||
case 'name': {
|
||||
return a.driver.name.localeCompare(b.driver.name);
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
: [];
|
||||
|
||||
const teamAverageRating =
|
||||
memberships.length > 0
|
||||
? Math.round(
|
||||
memberships.reduce((sum, m) => {
|
||||
const stats = getDriverStats(m.driverId);
|
||||
return sum + (stats?.rating || 0);
|
||||
}, 0) / memberships.length,
|
||||
)
|
||||
: 0;
|
||||
const teamAverageRating = viewModel?.averageRating ?? 0;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -130,43 +121,42 @@ export default function TeamRoster({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{sortedMemberships.map((membership) => {
|
||||
const driver = drivers[membership.driverId];
|
||||
const driverStats = getDriverStats(membership.driverId);
|
||||
if (!driver) return null;
|
||||
{sortedMembers.map((member) => {
|
||||
const { driver, role, joinedAt, rating, overallRank } = member;
|
||||
|
||||
const canManageMembership = isAdmin && membership.role !== 'owner';
|
||||
const canManageMembership = isAdmin && role !== 'owner';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={membership.driverId}
|
||||
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(membership.role)}
|
||||
contextLabel={getRoleLabel(role)}
|
||||
meta={
|
||||
<span>
|
||||
{driver.country} • Joined{' '}
|
||||
{new Date(membership.joinedAt).toLocaleDateString()}
|
||||
{driver.country} • Joined {new Date(joinedAt).toLocaleDateString()}
|
||||
</span>
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
{driverStats && (
|
||||
{rating !== null && (
|
||||
<div className="flex items-center gap-6 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-primary-blue">
|
||||
{driverStats.rating}
|
||||
{rating}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">Rating</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-300">#{driverStats.overallRank}</div>
|
||||
<div className="text-xs text-gray-500">Rank</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>
|
||||
)}
|
||||
|
||||
@@ -174,9 +164,9 @@ export default function TeamRoster({
|
||||
<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={membership.role}
|
||||
value={role}
|
||||
onChange={(e) =>
|
||||
onChangeRole?.(membership.driverId, e.target.value as TeamRole)
|
||||
onChangeRole?.(driver.id, e.target.value as TeamRole)
|
||||
}
|
||||
>
|
||||
<option value="driver">Driver</option>
|
||||
@@ -184,7 +174,7 @@ export default function TeamRoster({
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => onRemoveMember?.(membership.driverId)}
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ButtonHTMLAttributes, AnchorHTMLAttributes, ReactNode } from 'react';
|
||||
import React, { ButtonHTMLAttributes, AnchorHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
type ButtonAsButton = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
as?: 'button';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, MouseEventHandler } from 'react';
|
||||
import React, { ReactNode, MouseEventHandler } from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface ContainerProps {
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface HeadingProps {
|
||||
level: 1 | 2 | 3;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InputHTMLAttributes, ReactNode } from 'react';
|
||||
import React, { InputHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: boolean;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useRef, useState, useEffect } from 'react';
|
||||
import React, { useCallback, useRef, useState, useEffect } from 'react';
|
||||
|
||||
interface RangeFieldProps {
|
||||
label: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface SectionProps {
|
||||
variant?: 'default' | 'dark' | 'light';
|
||||
|
||||
Reference in New Issue
Block a user