diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index 3334cdaf4..692918697 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -20,6 +20,7 @@ import { EntityMappers, type DriverDTO, type LeagueScoringConfigDTO, + Race, } from '@gridpilot/racing'; import { getLeagueRepository, @@ -32,9 +33,10 @@ import { getSeasonRepository, getSponsorRepository, getSeasonSponsorshipRepository, + getCompleteRaceUseCase, } from '@/lib/di-container'; import { LeagueScoringConfigPresenter } from '@/lib/presenters/LeagueScoringConfigPresenter'; -import { Trophy, Star, ExternalLink } from 'lucide-react'; +import { Trophy, Star, ExternalLink, Calendar, Users } from 'lucide-react'; import { getMembership, getLeagueMembers } from '@/lib/leagueMembership'; import { useEffectiveDriverId } from '@/lib/currentDriver'; import { getLeagueRoleDisplay } from '@/lib/leagueRoles'; @@ -62,6 +64,7 @@ export default function LeagueDetailPage() { const [averageSOF, setAverageSOF] = useState(null); const [completedRacesCount, setCompletedRacesCount] = useState(0); const [sponsors, setSponsors] = useState([]); + const [runningRaces, setRunningRaces] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [refreshKey, setRefreshKey] = useState(0); @@ -139,6 +142,11 @@ export default function LeagueDetailPage() { setDrivers(driverDtos); + // Load all races for this league to find running ones + const leagueRaces = await raceRepo.findByLeagueId(leagueId); + const runningRaces = leagueRaces.filter(r => r.status === 'running'); + setRunningRaces(runningRaces); + // Load league stats including average SOF from application use case await leagueStatsUseCase.execute({ leagueId }); const leagueStatsViewModel = leagueStatsUseCase.presenter.getViewModel(); @@ -147,7 +155,6 @@ export default function LeagueDetailPage() { setCompletedRacesCount(leagueStatsViewModel.completedRaces); } else { // Fallback: count completed races manually - const leagueRaces = await raceRepo.findByLeagueId(leagueId); const completedRaces = leagueRaces.filter(r => r.status === 'completed'); setCompletedRacesCount(completedRaces.length); } @@ -306,6 +313,88 @@ export default function LeagueDetailPage() { /> )} + {/* Live Race Card - Prominently show running races */} + {runningRaces.length > 0 && ( + +
+
+

🏁 Live Race in Progress

+
+ +
+ {runningRaces.map((race) => ( +
+
+
+
+ LIVE +
+

+ {race.track} - {race.car} +

+
+
+ + {membership?.role === 'admin' && ( + + )} +
+
+ +
+
+ + Started {new Date(race.scheduledAt).toLocaleDateString()} +
+ {race.registeredCount && ( +
+ + {race.registeredCount} drivers registered +
+ )} + {race.strengthOfField && ( +
+ + SOF: {race.strengthOfField} +
+ )} +
+
+ ))} +
+
+ )} + {/* Action Card */} {!membership && !isSponsor && ( diff --git a/apps/website/app/races/[id]/page.tsx b/apps/website/app/races/[id]/page.tsx index 0b110272a..2909d1661 100644 --- a/apps/website/app/races/[id]/page.tsx +++ b/apps/website/app/races/[id]/page.tsx @@ -9,8 +9,9 @@ import Heading from '@/components/ui/Heading'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; import FileProtestModal from '@/components/races/FileProtestModal'; import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard'; -import { getGetRaceDetailUseCase, getRegisterForRaceUseCase, getWithdrawFromRaceUseCase, getCancelRaceUseCase } from '@/lib/di-container'; +import { getGetRaceDetailUseCase, getRegisterForRaceUseCase, getWithdrawFromRaceUseCase, getCancelRaceUseCase, getCompleteRaceUseCase } from '@/lib/di-container'; import { useEffectiveDriverId } from '@/lib/currentDriver'; +import { getMembership, isOwnerOrAdmin } from '@/lib/leagueMembership'; import type { RaceDetailViewModel, RaceDetailEntryViewModel, @@ -49,6 +50,7 @@ export default function RaceDetailPage() { const [ratingChange, setRatingChange] = useState(null); const [animatedRatingChange, setAnimatedRatingChange] = useState(0); const [showProtestModal, setShowProtestModal] = useState(false); + const [membership, setMembership] = useState(null); const currentDriverId = useEffectiveDriverId(); const isSponsorMode = useSponsorMode(); @@ -65,6 +67,13 @@ export default function RaceDetailPage() { throw new Error('Race detail not available'); } setViewModel(vm); + + // Fetch league membership for admin controls + if (vm.league) { + const leagueMembership = getMembership(vm.league.id, currentDriverId); + setMembership(leagueMembership); + } + const userResultRatingChange = vm.userResult?.ratingChange ?? null; setRatingChange(userResultRatingChange); if (userResultRatingChange === null) { @@ -529,7 +538,7 @@ export default function RaceDetailPage() { {animatedRatingChange > 0 ? '+' : ''} {animatedRatingChange} -
iRating
+
Rating
)} @@ -717,11 +726,11 @@ export default function RaceDetailPage() { className={` flex items-center justify-center w-8 h-8 rounded-lg font-bold text-sm ${ - index === 0 + race.status === 'completed' && index === 0 ? 'bg-yellow-500/20 text-yellow-400' - : index === 1 + : race.status === 'completed' && index === 1 ? 'bg-gray-400/20 text-gray-300' - : index === 2 + : race.status === 'completed' && index === 2 ? 'bg-amber-600/20 text-amber-500' : 'bg-iron-gray text-gray-500' } @@ -892,9 +901,55 @@ export default function RaceDetailPage() { Stewarding + {membership && isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && ( + <> + + + )} )} + {race.status === 'running' && membership && isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && ( + + )} + {race.status === 'scheduled' && ( + + + + {/* Footer */} +
+

+ This action cannot be undone. Use only for testing purposes. +

+
+ + + + ); +} \ No newline at end of file diff --git a/apps/website/components/leagues/QuickPenaltyModal.tsx b/apps/website/components/leagues/QuickPenaltyModal.tsx new file mode 100644 index 000000000..ea848799b --- /dev/null +++ b/apps/website/components/leagues/QuickPenaltyModal.tsx @@ -0,0 +1,188 @@ +'use client'; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { getQuickPenaltyUseCase } from '@/lib/di-container'; +import type { Driver } from '@gridpilot/racing/application'; +import Button from '@/components/ui/Button'; +import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react'; + +interface QuickPenaltyModalProps { + raceId: string; + drivers: Driver[]; + onClose: () => void; +} + +const INFRACTION_TYPES = [ + { value: 'track_limits', label: 'Track Limits', icon: Flag }, + { value: 'unsafe_rejoin', label: 'Unsafe Rejoin', icon: AlertTriangle }, + { value: 'aggressive_driving', label: 'Aggressive Driving', icon: Zap }, + { value: 'false_start', label: 'False Start', icon: Clock }, + { value: 'other', label: 'Other', icon: AlertTriangle }, +] as const; + +const SEVERITY_LEVELS = [ + { value: 'warning', label: 'Warning', description: 'Official warning only' }, + { value: 'minor', label: 'Minor', description: 'Light penalty' }, + { value: 'major', label: 'Major', description: 'Significant penalty' }, + { value: 'severe', label: 'Severe', description: 'Heavy penalty' }, +] as const; + +export default function QuickPenaltyModal({ raceId, drivers, onClose }: QuickPenaltyModalProps) { + const [selectedDriver, setSelectedDriver] = useState(''); + const [infractionType, setInfractionType] = useState(''); + const [severity, setSeverity] = useState(''); + const [notes, setNotes] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedDriver || !infractionType || !severity) return; + + setLoading(true); + setError(null); + + try { + const useCase = getQuickPenaltyUseCase(); + await useCase.execute({ + raceId, + driverId: selectedDriver, + adminId: 'driver-1', // TODO: Get from current user context + infractionType: infractionType as any, + severity: severity as any, + notes: notes.trim() || undefined, + }); + + // Refresh the page to show updated results + router.refresh(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to apply penalty'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

Quick Penalty

+ +
+ {/* Driver Selection */} +
+ + +
+ + {/* Infraction Type */} +
+ +
+ {INFRACTION_TYPES.map(({ value, label, icon: Icon }) => ( + + ))} +
+
+ + {/* Severity */} +
+ +
+ {SEVERITY_LEVELS.map(({ value, label, description }) => ( + + ))} +
+
+ + {/* Notes */} +
+ +