This commit is contained in:
2025-12-11 00:57:32 +01:00
parent 1303a14493
commit 6a427eab57
112 changed files with 6148 additions and 2272 deletions

View File

@@ -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,
},

View File

@@ -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>

View File

@@ -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,

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState, useMemo, useRef } from 'react';
import React, { useEffect, useState, useMemo, useRef } from 'react';
import {
Calendar,
Clock,

View File

@@ -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>

View File

@@ -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';

View File

@@ -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 = '';
}
};

View File

@@ -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>

View File

@@ -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

View File

@@ -1,4 +1,4 @@
import { ButtonHTMLAttributes, AnchorHTMLAttributes, ReactNode } from 'react';
import React, { ButtonHTMLAttributes, AnchorHTMLAttributes, ReactNode } from 'react';
type ButtonAsButton = ButtonHTMLAttributes<HTMLButtonElement> & {
as?: 'button';

View File

@@ -1,4 +1,4 @@
import { ReactNode, MouseEventHandler } from 'react';
import React, { ReactNode, MouseEventHandler } from 'react';
interface CardProps {
children: ReactNode;

View File

@@ -1,4 +1,4 @@
import { ReactNode } from 'react';
import React, { ReactNode } from 'react';
interface ContainerProps {
size?: 'sm' | 'md' | 'lg' | 'xl';

View File

@@ -1,4 +1,4 @@
import { ReactNode } from 'react';
import React, { ReactNode } from 'react';
interface HeadingProps {
level: 1 | 2 | 3;

View File

@@ -1,4 +1,4 @@
import { InputHTMLAttributes, ReactNode } from 'react';
import React, { InputHTMLAttributes, ReactNode } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
error?: boolean;

View File

@@ -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;

View File

@@ -1,4 +1,4 @@
import { ReactNode } from 'react';
import React, { ReactNode } from 'react';
interface SectionProps {
variant?: 'default' | 'dark' | 'light';