This commit is contained in:
2025-12-09 22:22:06 +01:00
parent e34a11ae7c
commit 3adf2e5e94
62 changed files with 6079 additions and 998 deletions

View File

@@ -344,8 +344,8 @@ export default function DriverDetailPage({
backLink = `/leagues/${leagueId}`;
} else if (from === 'league-members' && leagueId) {
backLink = `/leagues/${leagueId}`;
} else if (from === 'league-race' && leagueId && raceId) {
backLink = `/leagues/${leagueId}/races/${raceId}`;
} else if (from === 'league-race' && raceId) {
backLink = `/races/${raceId}`;
} else {
backLink = null;
}

View File

@@ -1,17 +1,71 @@
import React from 'react';
'use client';
import React, { useState, useEffect } from 'react';
import { useParams, usePathname, useRouter } from 'next/navigation';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import LeagueHeader from '@/components/leagues/LeagueHeader';
import { getLeagueRepository, getDriverRepository } from '@/lib/di-container';
import { getLeagueRepository, getDriverRepository, getLeagueMembershipRepository } from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import type { League } from '@gridpilot/racing/domain/entities/League';
export default async function LeagueLayout(props: {
export default function LeagueLayout({
children,
}: {
children: React.ReactNode;
params: Promise<{ id: string }>;
}) {
const { children, params } = props;
const resolvedParams = await params;
const leagueRepo = getLeagueRepository();
const driverRepo = getDriverRepository();
const league = await leagueRepo.findById(resolvedParams.id);
const params = useParams();
const pathname = usePathname();
const router = useRouter();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const [league, setLeague] = useState<League | null>(null);
const [ownerName, setOwnerName] = useState<string>('');
const [isAdmin, setIsAdmin] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadLeague() {
try {
const leagueRepo = getLeagueRepository();
const driverRepo = getDriverRepository();
const membershipRepo = getLeagueMembershipRepository();
const leagueData = await leagueRepo.findById(leagueId);
if (!leagueData) {
setLoading(false);
return;
}
setLeague(leagueData);
const owner = await driverRepo.findById(leagueData.ownerId);
setOwnerName(owner ? owner.name : `${leagueData.ownerId.slice(0, 8)}...`);
// Check if current user is admin
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
} catch (error) {
console.error('Failed to load league:', error);
} finally {
setLoading(false);
}
}
loadLeague();
}, [leagueId, currentDriverId]);
if (loading) {
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading league...</div>
</div>
</div>
);
}
if (!league) {
return (
@@ -23,8 +77,25 @@ export default async function LeagueLayout(props: {
);
}
const owner = await driverRepo.findById(league.ownerId);
const ownerName = owner ? owner.name : `${league.ownerId.slice(0, 8)}...`;
// Define tab configuration
const baseTabs = [
{ label: 'Overview', href: `/leagues/${leagueId}`, exact: true },
{ label: 'Schedule', href: `/leagues/${leagueId}/schedule`, exact: false },
{ label: 'Standings', href: `/leagues/${leagueId}/standings`, exact: false },
{ label: 'Rulebook', href: `/leagues/${leagueId}/rulebook`, exact: false },
];
const adminTabs = [
{ label: 'Stewarding', href: `/leagues/${leagueId}/stewarding`, exact: false },
{ label: 'Settings', href: `/leagues/${leagueId}/settings`, exact: false },
];
const tabs = isAdmin ? [...baseTabs, ...adminTabs] : baseTabs;
// Determine active tab
const activeTab = tabs.find(tab =>
tab.exact ? pathname === tab.href : pathname.startsWith(tab.href)
);
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
@@ -45,6 +116,25 @@ export default async function LeagueLayout(props: {
ownerName={ownerName}
/>
{/* Tab Navigation */}
<div className="mb-6 border-b border-charcoal-outline">
<div className="flex gap-6 overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.href}
onClick={() => router.push(tab.href)}
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
(tab.exact ? pathname === tab.href : pathname.startsWith(tab.href))
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
<div>{children}</div>
</div>
</div>

View File

@@ -1,15 +1,10 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useRouter, useParams, useSearchParams } from 'next/navigation';
import { useRouter, useParams } from 'next/navigation';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import JoinLeagueButton from '@/components/leagues/JoinLeagueButton';
import LeagueMembers from '@/components/leagues/LeagueMembers';
import LeagueSchedule from '@/components/leagues/LeagueSchedule';
import LeagueAdmin from '@/components/leagues/LeagueAdmin';
import StandingsTable from '@/components/leagues/StandingsTable';
import LeagueScoringTab from '@/components/leagues/LeagueScoringTab';
import LeagueActivityFeed from '@/components/leagues/LeagueActivityFeed';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
@@ -18,22 +13,21 @@ import {
Driver,
EntityMappers,
type DriverDTO,
type LeagueDriverSeasonStatsDTO,
type LeagueScoringConfigDTO,
} from '@gridpilot/racing';
import {
getLeagueRepository,
getRaceRepository,
getDriverRepository,
getGetLeagueDriverSeasonStatsQuery,
getGetLeagueScoringConfigQuery,
getDriverStats,
getAllDriverRankings,
getGetLeagueStatsQuery,
} from '@/lib/di-container';
import { Zap, Users, Trophy, Calendar } from 'lucide-react';
import { getMembership, getLeagueMembers, isOwnerOrAdmin } from '@/lib/leagueMembership';
import { getMembership, getLeagueMembers } from '@/lib/leagueMembership';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { getLeagueRoleDisplay } from '@/lib/leagueRoles';
export default function LeagueDetailPage() {
const router = useRouter();
@@ -42,22 +36,16 @@ export default function LeagueDetailPage() {
const [league, setLeague] = useState<League | null>(null);
const [owner, setOwner] = useState<Driver | null>(null);
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
const [scoringConfig, setScoringConfig] = useState<LeagueScoringConfigDTO | null>(null);
const [averageSOF, setAverageSOF] = useState<number | null>(null);
const [completedRacesCount, setCompletedRacesCount] = useState<number>(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<
'overview' | 'schedule' | 'standings' | 'scoring' | 'admin'
>('overview');
const [refreshKey, setRefreshKey] = useState(0);
const currentDriverId = useEffectiveDriverId();
const membership = getMembership(leagueId, currentDriverId);
const isAdmin = isOwnerOrAdmin(leagueId, currentDriverId);
const searchParams = useSearchParams();
const loadLeagueData = async () => {
try {
@@ -80,11 +68,6 @@ export default function LeagueDetailPage() {
const ownerData = await driverRepo.findById(leagueData.ownerId);
setOwner(ownerData);
// Load standings via rich season stats query
const getLeagueDriverSeasonStatsQuery = getGetLeagueDriverSeasonStatsQuery();
const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId });
setStandings(leagueStandings);
// Load scoring configuration for the active season
const getLeagueScoringConfigQuery = getGetLeagueScoringConfigQuery();
const scoring = await getLeagueScoringConfigQuery.execute({ leagueId });
@@ -121,23 +104,6 @@ export default function LeagueDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leagueId]);
useEffect(() => {
const initialTab = searchParams?.get('tab');
if (!initialTab) {
return;
}
if (
initialTab === 'overview' ||
initialTab === 'schedule' ||
initialTab === 'standings' ||
initialTab === 'scoring' ||
initialTab === 'admin'
) {
setActiveTab(initialTab as typeof activeTab);
}
}, [searchParams]);
const handleMembershipChange = () => {
setRefreshKey(prev => prev + 1);
loadLeagueData();
@@ -154,6 +120,7 @@ export default function LeagueDetailPage() {
const leagueMemberships = getLeagueMembers(leagueId);
const ownerMembership = leagueMemberships.find((m) => m.role === 'owner') ?? null;
const adminMemberships = leagueMemberships.filter((m) => m.role === 'admin');
const stewardMemberships = leagueMemberships.filter((m) => m.role === 'steward');
const buildDriverSummary = (driverId: string) => {
const driverDto = driversById[driverId];
@@ -230,312 +197,181 @@ export default function LeagueDetailPage() {
</Card>
)}
{/* Overview section switcher (in-page, not primary tabs) */}
<div className="mb-6">
<div className="inline-flex flex-wrap gap-2 rounded-full bg-iron-gray/60 px-2 py-1">
<button
onClick={() => setActiveTab('overview')}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
activeTab === 'overview'
? 'bg-primary-blue text-white'
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
}`}
>
Overview
</button>
<button
onClick={() => setActiveTab('schedule')}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
activeTab === 'schedule'
? 'bg-primary-blue text-white'
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
}`}
>
Schedule
</button>
<button
onClick={() => setActiveTab('standings')}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
activeTab === 'standings'
? 'bg-primary-blue text-white'
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
}`}
>
Standings
</button>
<button
onClick={() => setActiveTab('scoring')}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
activeTab === 'scoring'
? 'bg-primary-blue text-white'
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
}`}
>
Scoring
</button>
{isAdmin && (
<button
onClick={() => setActiveTab('admin')}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
activeTab === 'admin'
? 'bg-primary-blue text-white'
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
}`}
>
Admin
</button>
{/* League Overview - Activity Center with Info Sidebar */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Center - Activity Feed */}
<div className="lg:col-span-2 space-y-6">
<Card>
<h2 className="text-xl font-semibold text-white mb-6">Recent Activity</h2>
<LeagueActivityFeed leagueId={leagueId} limit={20} />
</Card>
</div>
{/* Right Sidebar - League Info */}
<div className="space-y-6">
{/* League Info - Combined */}
<Card>
<h3 className="text-lg font-semibold text-white mb-4">About</h3>
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
<div className="text-xl font-bold text-white">{leagueMemberships.length}</div>
<div className="text-xs text-gray-500">Members</div>
</div>
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
<div className="text-xl font-bold text-white">{completedRacesCount}</div>
<div className="text-xs text-gray-500">Races</div>
</div>
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
<div className="text-xl font-bold text-warning-amber">{averageSOF ?? '—'}</div>
<div className="text-xs text-gray-500">Avg SOF</div>
</div>
</div>
{/* Details */}
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
<span className="text-gray-500">Structure</span>
<span className="text-white">Solo {league.settings.maxDrivers ?? 32} max</span>
</div>
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
<span className="text-gray-500">Scoring</span>
<span className="text-white">{scoringConfig?.scoringPresetName ?? 'Standard'}</span>
</div>
<div className="flex items-center justify-between py-1.5">
<span className="text-gray-500">Created</span>
<span className="text-white">
{new Date(league.createdAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric'
})}
</span>
</div>
</div>
{league.socialLinks && (
<div className="mt-4 pt-4 border-t border-charcoal-outline">
<div className="flex flex-wrap gap-2">
{league.socialLinks.discordUrl && (
<a
href={league.socialLinks.discordUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-primary-blue/40 bg-primary-blue/10 px-2 py-1 text-xs text-primary-blue hover:bg-primary-blue/20 transition-colors"
>
Discord
</a>
)}
{league.socialLinks.youtubeUrl && (
<a
href={league.socialLinks.youtubeUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-red-500/40 bg-red-500/10 px-2 py-1 text-xs text-red-400 hover:bg-red-500/20 transition-colors"
>
YouTube
</a>
)}
{league.socialLinks.websiteUrl && (
<a
href={league.socialLinks.websiteUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-charcoal-outline bg-iron-gray/70 px-2 py-1 text-xs text-gray-100 hover:bg-iron-gray transition-colors"
>
Website
</a>
)}
</div>
</div>
)}
</Card>
{/* Management */}
{(ownerMembership || adminMemberships.length > 0 || stewardMemberships.length > 0) && (
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Management</h3>
<div className="space-y-2">
{ownerMembership && (() => {
const driverDto = driversById[ownerMembership.driverId];
const summary = buildDriverSummary(ownerMembership.driverId);
const roleDisplay = getLeagueRoleDisplay('owner');
const meta = summary && summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: null;
return driverDto ? (
<div className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={driverDto}
href={`/drivers/${ownerMembership.driverId}?from=league-management&leagueId=${leagueId}`}
meta={meta}
size="sm"
/>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
{roleDisplay.text}
</span>
</div>
) : null;
})()}
{adminMemberships.slice(0, 3).map((membership) => {
const driverDto = driversById[membership.driverId];
const summary = buildDriverSummary(membership.driverId);
const roleDisplay = getLeagueRoleDisplay('admin');
const meta = summary && summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: null;
return driverDto ? (
<div key={membership.driverId} className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={driverDto}
href={`/drivers/${membership.driverId}?from=league-management&leagueId=${leagueId}`}
meta={meta}
size="sm"
/>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
{roleDisplay.text}
</span>
</div>
) : null;
})}
{stewardMemberships.slice(0, 3).map((membership) => {
const driverDto = driversById[membership.driverId];
const summary = buildDriverSummary(membership.driverId);
const roleDisplay = getLeagueRoleDisplay('steward');
const meta = summary && summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: null;
return driverDto ? (
<div key={membership.driverId} className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={driverDto}
href={`/drivers/${membership.driverId}?from=league-management&leagueId=${leagueId}`}
meta={meta}
size="sm"
/>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
{roleDisplay.text}
</span>
</div>
) : null;
})}
</div>
</Card>
)}
</div>
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* League Info */}
<Card className="lg:col-span-2">
<h2 className="text-xl font-semibold text-white mb-4">League Information</h2>
<div className="space-y-4">
<div>
<label className="text-sm text-gray-500">Created</label>
<p className="text-white">
{new Date(league.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</p>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">At a glance</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
<div>
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
Structure
</h4>
<p className="text-gray-200 flex items-center gap-1.5">
<Users className="w-4 h-4 text-gray-500" />
Solo {league.settings.maxDrivers ?? 32} drivers
</p>
</div>
<div>
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
Schedule
</h4>
<p className="text-gray-200 flex items-center gap-1.5">
<Calendar className="w-4 h-4 text-gray-500" />
{completedRacesCount > 0 ? `${completedRacesCount} races completed` : 'Season upcoming'}
</p>
</div>
<div>
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
Scoring & drops
</h4>
<p className="text-gray-200 flex items-center gap-1.5">
<Trophy className="w-4 h-4 text-gray-500" />
{scoringConfig?.scoringPresetName ?? scoringConfig?.scoringPresetId ?? 'Standard'}
</p>
</div>
<div>
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
Avg. Strength of Field
</h4>
<p className="text-warning-amber font-medium flex items-center gap-1.5">
<Zap className="w-4 h-4" />
{averageSOF ? averageSOF : '—'}
</p>
</div>
</div>
</div>
{league.socialLinks && (
<div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">Community & Social</h3>
<div className="flex flex-wrap gap-3 text-sm">
{league.socialLinks.discordUrl && (
<a
href={league.socialLinks.discordUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-primary-blue/40 bg-primary-blue/10 px-3 py-1 text-primary-blue hover:bg-primary-blue/20 transition-colors"
>
<span>Discord</span>
</a>
)}
{league.socialLinks.youtubeUrl && (
<a
href={league.socialLinks.youtubeUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-red-500/40 bg-red-500/10 px-3 py-1 text-red-400 hover:bg-red-500/20 transition-colors"
>
<span>YouTube</span>
</a>
)}
{league.socialLinks.websiteUrl && (
<a
href={league.socialLinks.websiteUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-charcoal-outline bg-iron-gray/70 px-3 py-1 text-gray-100 hover:bg-iron-gray transition-colors"
>
<span>Website</span>
</a>
)}
</div>
</div>
)}
<div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">Management</h3>
<div className="space-y-4">
{ownerMembership && (
<div>
<label className="text-sm text-gray-500 block mb-2">Owner</label>
{buildDriverSummary(ownerMembership.driverId) ? (
<DriverSummaryPill
driver={buildDriverSummary(ownerMembership.driverId)!.driver}
rating={buildDriverSummary(ownerMembership.driverId)!.rating}
rank={buildDriverSummary(ownerMembership.driverId)!.rank}
/>
) : (
<p className="text-sm text-gray-500">
{owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`}
</p>
)}
</div>
)}
{adminMemberships.length > 0 && (
<div>
<label className="text-sm text-gray-500 block mb-2">Admins</label>
<div className="space-y-2">
{adminMemberships.map((membership) => {
const driverDto = driversById[membership.driverId];
const summary = buildDriverSummary(membership.driverId);
const meta =
summary && summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: null;
return (
<div
key={membership.driverId}
className="flex items-center justify-between gap-3"
>
{driverDto ? (
<DriverIdentity
driver={driverDto}
href={`/drivers/${membership.driverId}?from=league-management&leagueId=${leagueId}`}
contextLabel="Admin"
meta={meta}
size="sm"
/>
) : (
<span className="text-sm text-gray-400">Unknown admin</span>
)}
</div>
);
})}
</div>
</div>
)}
{adminMemberships.length === 0 && !ownerMembership && (
<p className="text-sm text-gray-500">
Management roles have not been configured for this league yet.
</p>
)}
</div>
</div>
</div>
</Card>
{/* Sidebar Container */}
<div className="space-y-6">
{/* Quick Actions */}
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Quick Actions</h2>
<div className="space-y-3">
{membership ? (
<>
<Button
variant="primary"
className="w-full"
onClick={() => setActiveTab('schedule')}
>
View Schedule
</Button>
<Button
variant="secondary"
className="w-full"
onClick={() => setActiveTab('standings')}
>
View Standings
</Button>
<JoinLeagueButton
leagueId={leagueId}
onMembershipChange={handleMembershipChange}
/>
</>
) : (
<JoinLeagueButton
leagueId={leagueId}
onMembershipChange={handleMembershipChange}
/>
)}
</div>
</Card>
{/* Recent Activity */}
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Recent Activity</h2>
<LeagueActivityFeed leagueId={leagueId} limit={8} />
</Card>
</div>
</div>
)}
{activeTab === 'schedule' && (
<Card>
<LeagueSchedule leagueId={leagueId} key={refreshKey} />
</Card>
)}
{activeTab === 'standings' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Standings</h2>
<StandingsTable standings={standings} drivers={drivers} leagueId={leagueId} />
</Card>
)}
{activeTab === 'scoring' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Scoring</h2>
<LeagueScoringTab
scoringConfig={scoringConfig}
practiceMinutes={20}
qualifyingMinutes={30}
sprintRaceMinutes={20}
mainRaceMinutes={
typeof league.settings.sessionDuration === 'number'
? league.settings.sessionDuration
: 40
}
/>
</Card>
)}
{activeTab === 'admin' && isAdmin && (
<LeagueAdmin
league={league}
onLeagueUpdate={handleMembershipChange}
key={refreshKey}
/>
)}
</>
);
}

View File

@@ -1,417 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
import type { Race } from '@gridpilot/racing/domain/entities/Race';
import type { League } from '@gridpilot/racing/domain/entities/League';
import type { Driver } from '@gridpilot/racing/domain/entities/Driver';
import {
getRaceRepository,
getLeagueRepository,
getDriverRepository,
getGetRaceRegistrationsQuery,
getIsDriverRegisteredForRaceQuery,
getRegisterForRaceUseCase,
getWithdrawFromRaceUseCase,
} from '@/lib/di-container';
import { getMembership } from '@/lib/leagueMembership';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import CompanionStatus from '@/components/alpha/CompanionStatus';
import CompanionInstructions from '@/components/alpha/CompanionInstructions';
export default function LeagueRaceDetailPage() {
const router = useRouter();
const params = useParams();
const leagueId = params.id as string;
const raceId = params.raceId as string;
const [race, setRace] = useState<Race | null>(null);
const [league, setLeague] = useState<League | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cancelling, setCancelling] = useState(false);
const [registering, setRegistering] = useState(false);
const [entryList, setEntryList] = useState<Driver[]>([]);
const [isUserRegistered, setIsUserRegistered] = useState(false);
const [canRegister, setCanRegister] = useState(false);
const currentDriverId = useEffectiveDriverId();
const loadRaceData = async () => {
try {
const raceRepo = getRaceRepository();
const leagueRepo = getLeagueRepository();
const raceData = await raceRepo.findById(raceId);
if (!raceData) {
setError('Race not found');
setLoading(false);
return;
}
setRace(raceData);
const leagueData = await leagueRepo.findById(raceData.leagueId);
setLeague(leagueData);
await loadEntryList(raceData.id, raceData.leagueId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load race');
} finally {
setLoading(false);
}
};
const loadEntryList = async (raceIdValue: string, leagueIdValue: string) => {
try {
const driverRepo = getDriverRepository();
const raceRegistrationsQuery = getGetRaceRegistrationsQuery();
const registeredDriverIds = await raceRegistrationsQuery.execute({ raceId: raceIdValue });
const drivers = await Promise.all(
registeredDriverIds.map((id: string) => driverRepo.findById(id)),
);
setEntryList(drivers.filter((d: Driver | null): d is Driver => d !== null));
const isRegisteredQuery = getIsDriverRegisteredForRaceQuery();
const userIsRegistered = await isRegisteredQuery.execute({
raceId: raceIdValue,
driverId: currentDriverId,
});
setIsUserRegistered(userIsRegistered);
const membership = getMembership(leagueIdValue, currentDriverId);
const isUpcoming = race?.status === 'scheduled';
setCanRegister(!!membership && membership.status === 'active' && !!isUpcoming);
} catch (err) {
console.error('Failed to load entry list:', err);
}
};
useEffect(() => {
loadRaceData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [raceId]);
const handleCancelRace = async () => {
if (!race || race.status !== 'scheduled') return;
const confirmed = window.confirm(
'Are you sure you want to cancel this race? This action cannot be undone.'
);
if (!confirmed) return;
setCancelling(true);
try {
const raceRepo = getRaceRepository();
const cancelledRace = race.cancel();
await raceRepo.update(cancelledRace);
setRace(cancelledRace);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to cancel race');
} finally {
setCancelling(false);
}
};
const handleRegister = async () => {
if (!race || !league) return;
const confirmed = window.confirm(
`Register for ${race.track}?\n\nYou'll be added to the entry list for this race.`
);
if (!confirmed) return;
setRegistering(true);
try {
const useCase = getRegisterForRaceUseCase();
await useCase.execute({
raceId: race.id,
leagueId: league.id,
driverId: currentDriverId,
});
await loadEntryList(race.id, league.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to register for race');
} finally {
setRegistering(false);
}
};
const handleWithdraw = async () => {
if (!race || !league) return;
const confirmed = window.confirm(
'Withdraw from this race?\n\nYou can register again later if you change your mind.'
);
if (!confirmed) return;
setRegistering(true);
try {
const useCase = getWithdrawFromRaceUseCase();
await useCase.execute({
raceId: race.id,
driverId: currentDriverId,
});
await loadEntryList(race.id, league.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to withdraw from race');
} finally {
setRegistering(false);
}
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const formatTime = (date: Date) => {
return new Date(date).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
});
};
const formatDateTime = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
});
};
const statusColors = {
scheduled: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30',
completed: 'bg-green-500/20 text-green-400 border-green-500/30',
cancelled: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
} as const;
if (loading) {
return (
<div className="text-center text-gray-400">Loading race details...</div>
);
}
if (error || !race) {
return (
<Card className="text-center py-12">
<div className="text-warning-amber mb-4">
{error || 'Race not found'}
</div>
<Button
variant="secondary"
onClick={() => router.push(`/leagues/${leagueId}`)}
>
Back to League
</Button>
</Card>
);
}
return (
<div className="max-w-4xl mx-auto">
<div className="mb-6">
<button
onClick={() => router.push(`/leagues/${leagueId}`)}
className="text-gray-400 hover:text-primary-blue transition-colors text-sm flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to League
</button>
</div>
<FeatureLimitationTooltip message="Companion automation available in production">
<div className="mb-6">
<CompanionStatus />
</div>
</FeatureLimitationTooltip>
<div className="mb-8">
<div className="flex items-start justify-between mb-4">
<div>
<h1 className="text-3xl font-bold text-white mb-2">{race.track}</h1>
{league && (
<p className="text-gray-400">{league.name}</p>
)}
</div>
<span
className={`px-3 py-1 text-sm font-medium rounded border ${
statusColors[race.status as keyof typeof statusColors]
}`}
>
{race.status.charAt(0).toUpperCase() + race.status.slice(1)}
</span>
</div>
</div>
{race.status === 'scheduled' && (
<div className="mb-6">
<CompanionInstructions race={race} leagueName={league?.name} />
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2">
<h2 className="text-xl font-semibold text-white mb-6">Race Details</h2>
<div className="space-y-6">
<div>
<label className="text-sm text-gray-500 block mb-1">Scheduled Date & Time</label>
<p className="text-white text-lg font-medium">
{formatDateTime(race.scheduledAt)}
</p>
<div className="flex gap-4 mt-2 text-sm">
<span className="text-gray-400">{formatDate(race.scheduledAt)}</span>
<span className="text-gray-400">{formatTime(race.scheduledAt)}</span>
</div>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<label className="text-sm text-gray-500 block mb-1">Track</label>
<p className="text-white">{race.track}</p>
</div>
<div>
<label className="text-sm text-gray-500 block mb-1">Car</label>
<p className="text-white">{race.car}</p>
</div>
<div>
<label className="text-sm text-gray-500 block mb-1">Session Type</label>
<p className="text-white capitalize">{race.sessionType}</p>
</div>
</div>
</Card>
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Actions</h2>
<div className="space-y-3">
{race.status === 'scheduled' && canRegister && !isUserRegistered && (
<Button
variant="primary"
className="w-full"
onClick={handleRegister}
disabled={registering}
>
{registering ? 'Registering...' : 'Register for Race'}
</Button>
)}
{race.status === 'scheduled' && isUserRegistered && (
<div className="space-y-2">
<div className="px-3 py-2 bg-green-500/10 border border-green-500/30 rounded text-green-400 text-sm text-center">
Registered
</div>
<Button
variant="secondary"
className="w-full"
onClick={handleWithdraw}
disabled={registering}
>
{registering ? 'Withdrawing...' : 'Withdraw'}
</Button>
</div>
)}
{race.status === 'completed' && (
<Button
variant="primary"
className="w-full"
onClick={() => router.push(`/races/${race.id}/results`)}
>
View Results
</Button>
)}
{race.status === 'scheduled' && (
<Button
variant="secondary"
className="w-full"
onClick={handleCancelRace}
disabled={cancelling}
>
{cancelling ? 'Cancelling...' : 'Cancel Race'}
</Button>
)}
<Button
variant="secondary"
className="w-full"
onClick={() => router.push(`/leagues/${leagueId}`)}
>
Back to League
</Button>
</div>
</Card>
</div>
{race.status === 'scheduled' && (
<Card className="mt-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-white">Entry List</h2>
<span className="text-sm text-gray-400">
{entryList.length} {entryList.length === 1 ? 'driver' : 'drivers'} registered
</span>
</div>
{entryList.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p className="mb-2">No drivers registered yet</p>
<p className="text-sm text-gray-500">Be the first to register!</p>
</div>
) : (
<div className="space-y-2">
{entryList.map((driver, index) => (
<div
key={driver.id}
className="flex items-center gap-4 p-3 bg-iron-gray/50 rounded-lg border border-charcoal-outline hover:border-primary-blue/50 transition-colors cursor-pointer"
onClick={() =>
router.push(
`/drivers/${driver.id}?from=league&leagueId=${leagueId}`,
)
}
>
<div className="w-8 text-center text-gray-400 font-mono text-sm">
#{index + 1}
</div>
<div className="w-10 h-10 bg-charcoal-outline rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-lg font-bold text-gray-500">
{driver.name.charAt(0)}
</span>
</div>
<div className="flex-1">
<p className="text-white font-medium">{driver.name}</p>
<p className="text-sm text-gray-400">{driver.country}</p>
</div>
{driver.id === currentDriverId && (
<span className="px-2 py-1 text-xs font-medium bg-primary-blue/20 text-primary-blue rounded">
You
</span>
)}
</div>
))}
</div>
)}
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,314 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import Card from '@/components/ui/Card';
import {
getLeagueRepository,
getGetLeagueScoringConfigQuery
} from '@/lib/di-container';
import type { LeagueScoringConfigDTO } from '@gridpilot/racing/application/dto/LeagueScoringConfigDTO';
import type { League } from '@gridpilot/racing/domain/entities/League';
type RulebookSection = 'scoring' | 'conduct' | 'protests' | 'penalties';
export default function LeagueRulebookPage() {
const params = useParams();
const leagueId = params.id as string;
const [league, setLeague] = useState<League | null>(null);
const [scoringConfig, setScoringConfig] = useState<LeagueScoringConfigDTO | null>(null);
const [loading, setLoading] = useState(true);
const [activeSection, setActiveSection] = useState<RulebookSection>('scoring');
useEffect(() => {
async function loadData() {
try {
const leagueRepo = getLeagueRepository();
const scoringQuery = getGetLeagueScoringConfigQuery();
const leagueData = await leagueRepo.findById(leagueId);
if (!leagueData) {
setLoading(false);
return;
}
setLeague(leagueData);
const scoring = await scoringQuery.execute({ leagueId });
setScoringConfig(scoring);
} catch (err) {
console.error('Failed to load scoring config:', err);
} finally {
setLoading(false);
}
}
loadData();
}, [leagueId]);
if (loading) {
return (
<Card>
<div className="text-center py-12 text-gray-400">Loading rulebook...</div>
</Card>
);
}
if (!league || !scoringConfig) {
return (
<Card>
<div className="text-center py-12 text-gray-400">Unable to load rulebook</div>
</Card>
);
}
const primaryChampionship = scoringConfig.championships.find(c => c.type === 'driver') ?? scoringConfig.championships[0];
const positionPoints = primaryChampionship?.pointsPreview
.filter(p => p.sessionType === primaryChampionship.sessionTypes[0])
.map(p => ({ position: p.position, points: p.points }))
.sort((a, b) => a.position - b.position) || [];
const sections: { id: RulebookSection; label: string }[] = [
{ id: 'scoring', label: 'Scoring' },
{ id: 'conduct', label: 'Conduct' },
{ id: 'protests', label: 'Protests' },
{ id: 'penalties', label: 'Penalties' },
];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Rulebook</h1>
<p className="text-sm text-gray-400 mt-1">Official rules and regulations</p>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/20">
<span className="text-sm font-medium text-primary-blue">{scoringConfig.scoringPresetName || 'Custom Rules'}</span>
</div>
</div>
{/* Navigation Tabs */}
<div className="flex gap-1 p-1 bg-deep-graphite rounded-lg border border-charcoal-outline">
{sections.map((section) => (
<button
key={section.id}
onClick={() => setActiveSection(section.id)}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
activeSection === section.id
? 'bg-iron-gray text-white'
: 'text-gray-400 hover:text-white hover:bg-iron-gray/50'
}`}
>
{section.label}
</button>
))}
</div>
{/* Content Sections */}
{activeSection === 'scoring' && (
<div className="space-y-6">
{/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Platform</p>
<p className="text-lg font-semibold text-white">{scoringConfig.gameName}</p>
</div>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Championships</p>
<p className="text-lg font-semibold text-white">{scoringConfig.championships.length}</p>
</div>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Sessions Scored</p>
<p className="text-lg font-semibold text-white capitalize">
{primaryChampionship?.sessionTypes.join(', ') || 'Main'}
</p>
</div>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Drop Policy</p>
<p className="text-lg font-semibold text-white truncate" title={scoringConfig.dropPolicySummary}>
{scoringConfig.dropPolicySummary.includes('All') ? 'None' : 'Active'}
</p>
</div>
</div>
{/* Points Table */}
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Points Distribution</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 font-medium text-gray-400">Position</th>
<th className="text-right py-3 px-4 font-medium text-gray-400">Points</th>
</tr>
</thead>
<tbody>
{positionPoints.map(({ position, points }) => (
<tr
key={position}
className={`border-b border-charcoal-outline/50 transition-colors hover:bg-iron-gray/30 ${
position <= 3 ? 'bg-iron-gray/20' : ''
}`}
>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${
position === 1 ? 'bg-yellow-500 text-black' :
position === 2 ? 'bg-gray-400 text-black' :
position === 3 ? 'bg-amber-600 text-white' :
'bg-charcoal-outline text-white'
}`}>
{position}
</div>
<span className="text-white font-medium">
{position === 1 ? '1st' : position === 2 ? '2nd' : position === 3 ? '3rd' : `${position}th`}
</span>
</div>
</td>
<td className="py-3 px-4 text-right">
<span className="text-white font-semibold tabular-nums">{points}</span>
<span className="text-gray-500 ml-1">pts</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* Bonus Points */}
{primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Bonus Points</h2>
<div className="space-y-2">
{primaryChampionship.bonusSummary.map((bonus, idx) => (
<div
key={idx}
className="flex items-center gap-4 p-3 bg-deep-graphite rounded-lg border border-charcoal-outline"
>
<div className="w-8 h-8 rounded-full bg-performance-green/10 border border-performance-green/20 flex items-center justify-center shrink-0">
<span className="text-performance-green text-sm font-bold">+</span>
</div>
<p className="text-sm text-gray-300">{bonus}</p>
</div>
))}
</div>
</Card>
)}
{/* Drop Policy */}
{!scoringConfig.dropPolicySummary.includes('All results count') && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Drop Policy</h2>
<p className="text-sm text-gray-300">{scoringConfig.dropPolicySummary}</p>
<p className="text-xs text-gray-500 mt-3">
Drop rules are applied automatically when calculating championship standings.
</p>
</Card>
)}
</div>
)}
{activeSection === 'conduct' && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Driver Conduct</h2>
<div className="space-y-4 text-sm text-gray-300">
<div>
<h3 className="font-medium text-white mb-2">1. Respect</h3>
<p>All drivers must treat each other with respect. Abusive language, harassment, or unsportsmanlike behavior will not be tolerated.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">2. Clean Racing</h3>
<p>Intentional wrecking, blocking, or dangerous driving is prohibited. Leave space for other drivers and race cleanly.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">3. Track Limits</h3>
<p>Drivers must stay within track limits. Gaining a lasting advantage by exceeding track limits may result in penalties.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">4. Blue Flags</h3>
<p>Lapped cars must yield to faster traffic within a reasonable time. Failure to do so may result in penalties.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">5. Communication</h3>
<p>Drivers are expected to communicate respectfully in voice and text chat during sessions.</p>
</div>
</div>
</Card>
)}
{activeSection === 'protests' && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Protest Process</h2>
<div className="space-y-4 text-sm text-gray-300">
<div>
<h3 className="font-medium text-white mb-2">Filing a Protest</h3>
<p>Protests can be filed within 48 hours of the race conclusion. Include the lap number, drivers involved, and a clear description of the incident.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">Evidence</h3>
<p>Video evidence is highly recommended but not required. Stewards will review available replay data.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">Review Process</h3>
<p>League stewards will review protests and make decisions within 72 hours. Decisions are final unless new evidence is presented.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">Outcomes</h3>
<p>Protests may result in no action, warnings, time penalties, position penalties, or points deductions depending on severity.</p>
</div>
</div>
</Card>
)}
{activeSection === 'penalties' && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Penalty Guidelines</h2>
<div className="space-y-4 text-sm text-gray-300">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-2 font-medium text-gray-400">Infraction</th>
<th className="text-left py-2 font-medium text-gray-400">Typical Penalty</th>
</tr>
</thead>
<tbody className="divide-y divide-charcoal-outline/50">
<tr>
<td className="py-3">Causing avoidable contact</td>
<td className="py-3 text-warning-amber">5-10 second time penalty</td>
</tr>
<tr>
<td className="py-3">Unsafe rejoin</td>
<td className="py-3 text-warning-amber">5 second time penalty</td>
</tr>
<tr>
<td className="py-3">Blocking</td>
<td className="py-3 text-warning-amber">Warning or 3 second penalty</td>
</tr>
<tr>
<td className="py-3">Repeated track limit violations</td>
<td className="py-3 text-warning-amber">5 second penalty</td>
</tr>
<tr>
<td className="py-3">Intentional wrecking</td>
<td className="py-3 text-red-400">Disqualification</td>
</tr>
<tr>
<td className="py-3">Unsportsmanlike conduct</td>
<td className="py-3 text-red-400">Points deduction or ban</td>
</tr>
</tbody>
</table>
</div>
<p className="text-xs text-gray-500 mt-4">
Penalties are applied at steward discretion based on incident severity and driver history.
</p>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,38 @@
'use client';
import { useParams } from 'next/navigation';
import Card from '@/components/ui/Card';
import LeagueSchedule from '@/components/leagues/LeagueSchedule';
import ScheduleRaceForm from '@/components/leagues/ScheduleRaceForm';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { getLeagueMembershipRepository } from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
export default function LeagueSchedulePage() {
const params = useParams();
const router = useRouter();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const [isAdmin, setIsAdmin] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
useEffect(() => {
async function checkAdmin() {
const membershipRepo = getLeagueMembershipRepository();
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
}
checkAdmin();
}, [leagueId, currentDriverId]);
return (
<div className="space-y-6">
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Schedule</h2>
<LeagueSchedule leagueId={leagueId} />
</Card>
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { redirect } from 'next/navigation';
export default async function ScoringPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
redirect(`/leagues/${id}/rulebook`);
}

View File

@@ -0,0 +1,299 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
getLeagueRepository,
getDriverRepository,
getGetLeagueFullConfigQuery,
getLeagueMembershipRepository,
getDriverStats,
getAllDriverRankings,
getListLeagueScoringPresetsQuery,
getTransferLeagueOwnershipUseCase
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import { ScoringPatternSection, ChampionshipsSection } from '@/components/leagues/LeagueScoringSection';
import { LeagueDropSection } from '@/components/leagues/LeagueDropSection';
import { ReadonlyLeagueInfo } from '@/components/leagues/ReadonlyLeagueInfo';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import type { League } from '@gridpilot/racing/domain/entities/League';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import { AlertTriangle, Settings, Trophy, Calendar, TrendingDown, Edit, Users, UserCog } from 'lucide-react';
export default function LeagueSettingsPage() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const [league, setLeague] = useState<League | null>(null);
const [configForm, setConfigForm] = useState<LeagueConfigFormModel | null>(null);
const [presets, setPresets] = useState<LeagueScoringPresetDTO[]>([]);
const [ownerDriver, setOwnerDriver] = useState<DriverDTO | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [showTransferDialog, setShowTransferDialog] = useState(false);
const [selectedNewOwner, setSelectedNewOwner] = useState<string>('');
const [transferring, setTransferring] = useState(false);
const [allMembers, setAllMembers] = useState<DriverDTO[]>([]);
const router = useRouter();
useEffect(() => {
async function checkAdmin() {
const membershipRepo = getLeagueMembershipRepository();
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
}
checkAdmin();
}, [leagueId, currentDriverId]);
useEffect(() => {
async function loadSettings() {
setLoading(true);
try {
const leagueRepo = getLeagueRepository();
const driverRepo = getDriverRepository();
const query = getGetLeagueFullConfigQuery();
const presetsQuery = getListLeagueScoringPresetsQuery();
const leagueData = await leagueRepo.findById(leagueId);
if (!leagueData) {
setLoading(false);
return;
}
setLeague(leagueData);
const form = await query.execute({ leagueId });
setConfigForm(form);
const presetsData = await presetsQuery.execute();
setPresets(presetsData);
const entity = await driverRepo.findById(leagueData.ownerId);
if (entity) {
setOwnerDriver(EntityMappers.toDriverDTO(entity));
}
const membershipRepo = getLeagueMembershipRepository();
const memberships = await membershipRepo.getLeagueMembers(leagueId);
const memberDrivers: DriverDTO[] = [];
for (const m of memberships) {
if (m.driverId !== leagueData.ownerId && m.status === 'active') {
const d = await driverRepo.findById(m.driverId);
if (d) {
const dto = EntityMappers.toDriverDTO(d);
if (dto) {
memberDrivers.push(dto);
}
}
}
}
setAllMembers(memberDrivers);
} catch (err) {
console.error('Failed to load league settings:', err);
} finally {
setLoading(false);
}
}
if (isAdmin) {
loadSettings();
}
}, [leagueId, isAdmin]);
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]);
const handleTransferOwnership = async () => {
if (!selectedNewOwner || !league) return;
setTransferring(true);
try {
const useCase = getTransferLeagueOwnershipUseCase();
await useCase.execute({
leagueId,
currentOwnerId: currentDriverId,
newOwnerId: selectedNewOwner,
});
setShowTransferDialog(false);
router.refresh();
} catch (err) {
console.error('Failed to transfer ownership:', err);
alert(err instanceof Error ? err.message : 'Failed to transfer ownership');
} finally {
setTransferring(false);
}
};
if (!isAdmin) {
return (
<Card>
<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">
<AlertTriangle className="w-8 h-8 text-warning-amber" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
<p className="text-sm text-gray-400">
Only league admins can access settings.
</p>
</div>
</Card>
);
}
if (loading) {
return (
<Card>
<div className="py-6 text-sm text-gray-400">Loading configuration</div>
</Card>
);
}
if (!configForm || !league) {
return (
<Card>
<div className="py-6 text-sm text-gray-500">
Unable to load league configuration for this demo league.
</div>
</Card>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<Settings className="w-6 h-6 text-primary-blue" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">League Settings</h1>
<p className="text-sm text-gray-400">Manage your league configuration</p>
</div>
</div>
{/* READONLY INFORMATION SECTION - Compact */}
<div className="space-y-4">
<ReadonlyLeagueInfo league={league} configForm={configForm} />
{/* League Owner - Compact */}
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/40 to-iron-gray/20 p-5">
<h3 className="text-sm font-semibold text-gray-400 mb-3">League Owner</h3>
{ownerSummary ? (
<DriverSummaryPill
driver={ownerSummary.driver}
rating={ownerSummary.rating}
rank={ownerSummary.rank}
/>
) : (
<p className="text-sm text-gray-500">Loading owner details...</p>
)}
</div>
{/* Transfer Ownership - Owner Only */}
{league.ownerId === currentDriverId && allMembers.length > 0 && (
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/40 to-iron-gray/20 p-5">
<div className="flex items-center gap-2 mb-3">
<UserCog className="w-4 h-4 text-gray-400" />
<h3 className="text-sm font-semibold text-gray-400">Transfer Ownership</h3>
</div>
<p className="text-xs text-gray-500 mb-4">
Transfer league ownership to another active member. You will become an admin.
</p>
{!showTransferDialog ? (
<Button
variant="secondary"
onClick={() => setShowTransferDialog(true)}
>
Transfer Ownership
</Button>
) : (
<div className="space-y-3">
<select
value={selectedNewOwner}
onChange={(e) => setSelectedNewOwner(e.target.value)}
className="w-full rounded-lg border border-charcoal-outline bg-charcoal-card px-3 py-2 text-sm text-white focus:border-primary-blue focus:outline-none"
>
<option value="">Select new owner...</option>
{allMembers.map((member) => (
<option key={member.id} value={member.id}>
{member.name}
</option>
))}
</select>
<div className="flex gap-2">
<Button
variant="primary"
onClick={handleTransferOwnership}
disabled={!selectedNewOwner || transferring}
>
{transferring ? 'Transferring...' : 'Confirm Transfer'}
</Button>
<Button
variant="secondary"
onClick={() => {
setShowTransferDialog(false);
setSelectedNewOwner('');
}}
disabled={transferring}
>
Cancel
</Button>
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import Card from '@/components/ui/Card';
import StandingsTable from '@/components/leagues/StandingsTable';
import {
@@ -8,20 +9,32 @@ import {
type DriverDTO,
type LeagueDriverSeasonStatsDTO,
} from '@gridpilot/racing';
import { getGetLeagueDriverSeasonStatsQuery, getDriverRepository } from '@/lib/di-container';
import {
getGetLeagueDriverSeasonStatsQuery,
getDriverRepository,
getLeagueMembershipRepository
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import type { MembershipRole, LeagueMembership } from '@/lib/leagueMembership';
export default function LeagueStandingsPage({ params }: any) {
const leagueId = params.id;
export default function LeagueStandingsPage() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
const [memberships, setMemberships] = useState<LeagueMembership[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const loadData = async () => {
const loadData = useCallback(async () => {
try {
const getLeagueDriverSeasonStatsQuery = getGetLeagueDriverSeasonStatsQuery();
const driverRepo = getDriverRepository();
const membershipRepo = getLeagueMembershipRepository();
const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId });
setStandings(leagueStandings);
@@ -31,17 +44,86 @@ export default function LeagueStandingsPage({ params }: any) {
.map((driver) => EntityMappers.toDriverDTO(driver))
.filter((dto): dto is DriverDTO => dto !== null);
setDrivers(driverDtos);
// Load league memberships from repository (consistent with other data)
const allMemberships = await membershipRepo.getLeagueMembers(leagueId);
// Convert to the format expected by StandingsTable
const membershipData: LeagueMembership[] = allMemberships.map(m => ({
leagueId: m.leagueId,
driverId: m.driverId,
role: m.role,
status: m.status,
joinedAt: m.joinedAt instanceof Date ? m.joinedAt.toISOString() : String(m.joinedAt),
}));
setMemberships(membershipData);
// Check if current user is admin
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load standings');
} finally {
setLoading(false);
}
};
}, [leagueId, currentDriverId]);
useEffect(() => {
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leagueId]);
}, [loadData]);
const handleRemoveMember = async (driverId: string) => {
if (!confirm('Are you sure you want to remove this member?')) {
return;
}
try {
const membershipRepo = getLeagueMembershipRepository();
const performer = await membershipRepo.getMembership(leagueId, currentDriverId);
if (!performer || (performer.role !== 'owner' && performer.role !== 'admin')) {
throw new Error('Only owners or admins can remove members');
}
const membership = await membershipRepo.getMembership(leagueId, driverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot remove the league owner');
}
await membershipRepo.removeMembership(leagueId, driverId);
await loadData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove member');
}
};
const handleUpdateRole = async (driverId: string, newRole: MembershipRole) => {
try {
const membershipRepo = getLeagueMembershipRepository();
const performer = await membershipRepo.getMembership(leagueId, currentDriverId);
if (!performer || performer.role !== 'owner') {
throw new Error('Only the league owner can update roles');
}
const membership = await membershipRepo.getMembership(leagueId, 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 loadData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update role');
}
};
if (loading) {
return (
@@ -59,10 +141,68 @@ export default function LeagueStandingsPage({ params }: any) {
);
}
const leader = standings[0];
const totalRaces = Math.max(...standings.map(s => s.racesStarted), 0);
return (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Standings</h2>
<StandingsTable standings={standings} drivers={drivers} leagueId={leagueId} />
</Card>
<div className="space-y-6">
{/* Championship Stats */}
{standings.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-yellow-500/10 flex items-center justify-center">
<span className="text-2xl">🏆</span>
</div>
<div>
<p className="text-xs text-gray-400 mb-1">Championship Leader</p>
<p className="font-bold text-white">{drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}</p>
<p className="text-sm text-yellow-400 font-medium">{leader?.totalPoints || 0} points</p>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-primary-blue/10 flex items-center justify-center">
<span className="text-2xl">🏁</span>
</div>
<div>
<p className="text-xs text-gray-400 mb-1">Races Completed</p>
<p className="text-2xl font-bold text-white">{totalRaces}</p>
<p className="text-sm text-gray-400">Season in progress</p>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-performance-green/10 flex items-center justify-center">
<span className="text-2xl">👥</span>
</div>
<div>
<p className="text-xs text-gray-400 mb-1">Active Drivers</p>
<p className="text-2xl font-bold text-white">{standings.length}</p>
<p className="text-sm text-gray-400">Competing for points</p>
</div>
</div>
</Card>
</div>
)}
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Championship Standings</h2>
<StandingsTable
standings={standings}
drivers={drivers}
leagueId={leagueId}
memberships={memberships}
currentDriverId={currentDriverId}
isAdmin={isAdmin}
onRemoveMember={handleRemoveMember}
onUpdateRole={handleUpdateRole}
/>
</Card>
</div>
);
}

View File

@@ -0,0 +1,508 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
import {
getRaceRepository,
getProtestRepository,
getDriverRepository,
getLeagueMembershipRepository,
getReviewProtestUseCase,
getApplyPenaltyUseCase,
getPenaltyRepository
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import type { Protest } from '@gridpilot/racing/domain/entities/Protest';
import type { Race } from '@gridpilot/racing/domain/entities/Race';
import type { Penalty, PenaltyType } from '@gridpilot/racing/domain/entities/Penalty';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import {
AlertTriangle, Clock, CheckCircle, Flag, ChevronRight,
Calendar, MapPin, AlertCircle, Video, Gavel
} from 'lucide-react';
interface RaceWithProtests {
race: Race;
pendingProtests: Protest[];
resolvedProtests: Protest[];
penalties: Penalty[];
}
export default function LeagueStewardingPage() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const [races, setRaces] = useState<Race[]>([]);
const [protestsByRace, setProtestsByRace] = useState<Record<string, Protest[]>>({});
const [penaltiesByRace, setPenaltiesByRace] = useState<Record<string, Penalty[]>>({});
const [driversById, setDriversById] = useState<Record<string, DriverDTO>>({});
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<Protest | null>(null);
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(new Set());
useEffect(() => {
async function checkAdmin() {
const membershipRepo = getLeagueMembershipRepository();
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
}
checkAdmin();
}, [leagueId, currentDriverId]);
useEffect(() => {
async function loadData() {
setLoading(true);
try {
const raceRepo = getRaceRepository();
const protestRepo = getProtestRepository();
const penaltyRepo = getPenaltyRepository();
const driverRepo = getDriverRepository();
// Get all races for this league
const leagueRaces = await raceRepo.findByLeagueId(leagueId);
setRaces(leagueRaces);
// Get protests and penalties for each race
const protestsMap: Record<string, Protest[]> = {};
const penaltiesMap: Record<string, Penalty[]> = {};
const driverIds = new Set<string>();
for (const race of leagueRaces) {
const raceProtests = await protestRepo.findByRaceId(race.id);
const racePenalties = await penaltyRepo.findByRaceId(race.id);
protestsMap[race.id] = raceProtests;
penaltiesMap[race.id] = racePenalties;
// Collect driver IDs
raceProtests.forEach((p) => {
driverIds.add(p.protestingDriverId);
driverIds.add(p.accusedDriverId);
});
racePenalties.forEach((p) => {
driverIds.add(p.driverId);
});
}
setProtestsByRace(protestsMap);
setPenaltiesByRace(penaltiesMap);
// Load driver info
const driverEntities = await Promise.all(
Array.from(driverIds).map((id) => driverRepo.findById(id)),
);
const byId: Record<string, DriverDTO> = {};
driverEntities.forEach((driver) => {
if (driver) {
const dto = EntityMappers.toDriverDTO(driver);
if (dto) {
byId[dto.id] = dto;
}
}
});
setDriversById(byId);
// Auto-expand races with pending protests
const racesWithPending = new Set<string>();
Object.entries(protestsMap).forEach(([raceId, protests]) => {
if (protests.some(p => p.status === 'pending' || p.status === 'under_review')) {
racesWithPending.add(raceId);
}
});
setExpandedRaces(racesWithPending);
} catch (err) {
console.error('Failed to load data:', err);
} finally {
setLoading(false);
}
}
if (isAdmin) {
loadData();
}
}, [leagueId, isAdmin]);
// Compute race data with protest/penalty info
const racesWithData = useMemo((): RaceWithProtests[] => {
return races.map(race => {
const protests = protestsByRace[race.id] || [];
const penalties = penaltiesByRace[race.id] || [];
return {
race,
pendingProtests: protests.filter(p => p.status === 'pending' || p.status === 'under_review'),
resolvedProtests: protests.filter(p => p.status === 'upheld' || p.status === 'dismissed' || p.status === 'withdrawn'),
penalties
};
}).sort((a, b) => b.race.scheduledAt.getTime() - a.race.scheduledAt.getTime());
}, [races, protestsByRace, penaltiesByRace]);
// Filter races based on active tab
const filteredRaces = useMemo(() => {
if (activeTab === 'pending') {
return racesWithData.filter(r => r.pendingProtests.length > 0);
}
return racesWithData.filter(r => r.resolvedProtests.length > 0 || r.penalties.length > 0);
}, [racesWithData, activeTab]);
// Stats
const totalPending = racesWithData.reduce((sum, r) => sum + r.pendingProtests.length, 0);
const totalResolved = racesWithData.reduce((sum, r) => sum + r.resolvedProtests.length, 0);
const totalPenalties = racesWithData.reduce((sum, r) => sum + r.penalties.length, 0);
const handleAcceptProtest = async (
protestId: string,
penaltyType: PenaltyType,
penaltyValue: number,
stewardNotes: string
) => {
const reviewUseCase = getReviewProtestUseCase();
const penaltyUseCase = getApplyPenaltyUseCase();
await reviewUseCase.execute({
protestId,
stewardId: currentDriverId,
decision: 'uphold',
decisionNotes: stewardNotes,
});
// Find the protest
let foundProtest: Protest | undefined;
Object.values(protestsByRace).forEach(protests => {
const p = protests.find(pr => pr.id === protestId);
if (p) foundProtest = p;
});
if (foundProtest) {
await penaltyUseCase.execute({
raceId: foundProtest.raceId,
driverId: foundProtest.accusedDriverId,
stewardId: currentDriverId,
type: penaltyType,
value: penaltyValue,
reason: foundProtest.incident.description,
protestId,
notes: stewardNotes,
});
}
};
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
const reviewUseCase = getReviewProtestUseCase();
await reviewUseCase.execute({
protestId,
stewardId: currentDriverId,
decision: 'dismiss',
decisionNotes: stewardNotes,
});
};
const handleProtestReviewed = () => {
setSelectedProtest(null);
window.location.reload();
};
const toggleRaceExpanded = (raceId: string) => {
setExpandedRaces(prev => {
const next = new Set(prev);
if (next.has(raceId)) {
next.delete(raceId);
} else {
next.add(raceId);
}
return next;
});
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'pending':
case 'under_review':
return <span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">Pending</span>;
case 'upheld':
return <span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">Upheld</span>;
case 'dismissed':
return <span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">Dismissed</span>;
case 'withdrawn':
return <span className="px-2 py-0.5 text-xs font-medium bg-blue-500/20 text-blue-400 rounded-full">Withdrawn</span>;
default:
return null;
}
};
if (!isAdmin) {
return (
<Card>
<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">
<AlertTriangle className="w-8 h-8 text-warning-amber" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
<p className="text-sm text-gray-400">
Only league admins can access stewarding functions.
</p>
</div>
</Card>
);
}
return (
<div className="space-y-6">
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
<p className="text-sm text-gray-400 mt-1">
Quick overview of protests and penalties across all races
</p>
</div>
</div>
{/* Stats summary */}
{!loading && (
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-warning-amber mb-1">
<Clock className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Pending Review</span>
</div>
<div className="text-2xl font-bold text-white">{totalPending}</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-performance-green mb-1">
<CheckCircle className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Resolved</span>
</div>
<div className="text-2xl font-bold text-white">{totalResolved}</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-red-400 mb-1">
<Gavel className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Penalties</span>
</div>
<div className="text-2xl font-bold text-white">{totalPenalties}</div>
</div>
</div>
)}
{/* Tab navigation */}
<div className="border-b border-charcoal-outline mb-6">
<div className="flex gap-4">
<button
onClick={() => setActiveTab('pending')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'pending'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Pending Protests
{totalPending > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
{totalPending}
</span>
)}
</button>
<button
onClick={() => setActiveTab('history')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'history'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
History
</button>
</div>
</div>
{/* Content */}
{loading ? (
<div className="text-center py-12 text-gray-400">
<div className="animate-pulse">Loading stewarding data...</div>
</div>
) : filteredRaces.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
<Flag className="w-8 h-8 text-performance-green" />
</div>
<p className="font-semibold text-lg text-white mb-2">
{activeTab === 'pending' ? 'All Clear!' : 'No History Yet'}
</p>
<p className="text-sm text-gray-400">
{activeTab === 'pending'
? 'No pending protests to review'
: 'No resolved protests or penalties'}
</p>
</div>
) : (
<div className="space-y-4">
{filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => {
const isExpanded = expandedRaces.has(race.id);
const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests;
return (
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
{/* Race Header */}
<button
onClick={() => toggleRaceExpanded(race.id)}
className="w-full px-4 py-3 bg-iron-gray/30 hover:bg-iron-gray/50 transition-colors flex items-center justify-between"
>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<span className="font-medium text-white">{race.track}</span>
</div>
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Calendar className="w-4 h-4" />
<span>{race.scheduledAt.toLocaleDateString()}</span>
</div>
{activeTab === 'pending' && pendingProtests.length > 0 && (
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
{pendingProtests.length} pending
</span>
)}
{activeTab === 'history' && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
{resolvedProtests.length} protests, {penalties.length} penalties
</span>
)}
</div>
<ChevronRight className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="p-4 space-y-3 bg-deep-graphite/50">
{displayProtests.length === 0 && penalties.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
) : (
<>
{displayProtests.map((protest) => {
const protester = driversById[protest.protestingDriverId];
const accused = driversById[protest.accusedDriverId];
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
const isUrgent = daysSinceFiled > 2 && (protest.status === 'pending' || protest.status === 'under_review');
return (
<div
key={protest.id}
className={`rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
<span className="font-medium text-white">
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
</span>
{getStatusBadge(protest.status)}
{isUrgent && (
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
<span>Lap {protest.incident.lap}</span>
<span></span>
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span>
{protest.proofVideoUrl && (
<>
<span></span>
<span className="flex items-center gap-1 text-primary-blue">
<Video className="w-3 h-3" />
Video
</span>
</>
)}
</div>
<p className="text-sm text-gray-300 line-clamp-2">
{protest.incident.description}
</p>
{protest.decisionNotes && (
<div className="mt-2 p-2 rounded bg-iron-gray/50 border border-charcoal-outline/50">
<p className="text-xs text-gray-400">
<span className="font-medium">Steward:</span> {protest.decisionNotes}
</p>
</div>
)}
</div>
{(protest.status === 'pending' || protest.status === 'under_review') && (
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
<Button variant="primary">
Review
</Button>
</Link>
)}
</div>
</div>
);
})}
{activeTab === 'history' && penalties.map((penalty) => {
const driver = driversById[penalty.driverId];
return (
<div
key={penalty.id}
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
<Gavel className="w-4 h-4 text-red-400" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span>
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
{penalty.type.replace('_', ' ')}
</span>
</div>
<p className="text-sm text-gray-400">{penalty.reason}</p>
</div>
<div className="text-right">
<span className="text-lg font-bold text-red-400">
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
{penalty.type === 'disqualification' && 'DSQ'}
{penalty.type === 'warning' && 'Warning'}
{penalty.type === 'license_points' && `${penalty.value} LP`}
</span>
</div>
</div>
</div>
);
})}
</>
)}
</div>
)}
</div>
);
})}
</div>
)}
</Card>
{selectedProtest && (
<ReviewProtestModal
protest={selectedProtest}
onClose={() => setSelectedProtest(null)}
onAccept={handleAcceptProtest}
onReject={handleRejectProtest}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,759 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
getProtestRepository,
getRaceRepository,
getDriverRepository,
getLeagueMembershipRepository,
getReviewProtestUseCase,
getApplyPenaltyUseCase,
getRequestProtestDefenseUseCase,
getSendNotificationUseCase
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import type { Protest } from '@gridpilot/racing/domain/entities/Protest';
import type { Race } from '@gridpilot/racing/domain/entities/Race';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import type { PenaltyType } from '@gridpilot/racing/domain/entities/Penalty';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import {
AlertCircle,
Video,
Clock,
Grid3x3,
TrendingDown,
XCircle,
CheckCircle,
ArrowLeft,
Flag,
AlertTriangle,
ShieldAlert,
Ban,
DollarSign,
FileWarning,
User,
Calendar,
MapPin,
MessageCircle,
Shield,
Gavel,
Send,
ChevronDown,
ExternalLink
} from 'lucide-react';
// Timeline event types
interface TimelineEvent {
id: string;
type: 'protest_filed' | 'defense_requested' | 'defense_submitted' | 'steward_comment' | 'decision' | 'penalty_applied';
timestamp: Date;
actor: DriverDTO | null;
content: string;
metadata?: Record<string, unknown>;
}
const PENALTY_TYPES = [
{
type: 'time_penalty' as PenaltyType,
label: 'Time Penalty',
description: 'Add seconds to race result',
icon: Clock,
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
requiresValue: true,
valueLabel: 'seconds',
defaultValue: 5
},
{
type: 'grid_penalty' as PenaltyType,
label: 'Grid Penalty',
description: 'Grid positions for next race',
icon: Grid3x3,
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
requiresValue: true,
valueLabel: 'positions',
defaultValue: 3
},
{
type: 'points_deduction' as PenaltyType,
label: 'Points Deduction',
description: 'Deduct championship points',
icon: TrendingDown,
color: 'text-red-400 bg-red-500/10 border-red-500/20',
requiresValue: true,
valueLabel: 'points',
defaultValue: 5
},
{
type: 'disqualification' as PenaltyType,
label: 'Disqualification',
description: 'Disqualify from race',
icon: XCircle,
color: 'text-red-500 bg-red-500/10 border-red-500/20',
requiresValue: false,
valueLabel: '',
defaultValue: 0
},
{
type: 'warning' as PenaltyType,
label: 'Warning',
description: 'Official warning only',
icon: AlertTriangle,
color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
requiresValue: false,
valueLabel: '',
defaultValue: 0
},
{
type: 'license_points' as PenaltyType,
label: 'License Points',
description: 'Safety rating penalty',
icon: ShieldAlert,
color: 'text-orange-400 bg-orange-500/10 border-orange-500/20',
requiresValue: true,
valueLabel: 'points',
defaultValue: 2
},
];
export default function ProtestReviewPage() {
const params = useParams();
const router = useRouter();
const leagueId = params.id as string;
const protestId = params.protestId as string;
const currentDriverId = useEffectiveDriverId();
const [protest, setProtest] = useState<Protest | null>(null);
const [race, setRace] = useState<Race | null>(null);
const [protestingDriver, setProtestingDriver] = useState<DriverDTO | null>(null);
const [accusedDriver, setAccusedDriver] = useState<DriverDTO | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
// Decision state
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
const [penaltyType, setPenaltyType] = useState<PenaltyType>('time_penalty');
const [penaltyValue, setPenaltyValue] = useState<number>(5);
const [stewardNotes, setStewardNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
// Comment state
const [newComment, setNewComment] = useState('');
useEffect(() => {
async function checkAdmin() {
const membershipRepo = getLeagueMembershipRepository();
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
}
checkAdmin();
}, [leagueId, currentDriverId]);
useEffect(() => {
async function loadProtest() {
setLoading(true);
try {
const protestRepo = getProtestRepository();
const raceRepo = getRaceRepository();
const driverRepo = getDriverRepository();
const protestEntity = await protestRepo.findById(protestId);
if (!protestEntity) {
throw new Error('Protest not found');
}
setProtest(protestEntity);
const raceEntity = await raceRepo.findById(protestEntity.raceId);
if (!raceEntity) {
throw new Error('Race not found');
}
setRace(raceEntity);
const protestingDriverEntity = await driverRepo.findById(protestEntity.protestingDriverId);
const accusedDriverEntity = await driverRepo.findById(protestEntity.accusedDriverId);
setProtestingDriver(protestingDriverEntity ? EntityMappers.toDriverDTO(protestingDriverEntity) : null);
setAccusedDriver(accusedDriverEntity ? EntityMappers.toDriverDTO(accusedDriverEntity) : null);
} catch (err) {
console.error('Failed to load protest:', err);
alert('Failed to load protest details');
router.push(`/leagues/${leagueId}/stewarding`);
} finally {
setLoading(false);
}
}
if (isAdmin) {
loadProtest();
}
}, [protestId, leagueId, isAdmin, currentDriverId, router]);
// Build timeline from protest data
const timeline = useMemo((): TimelineEvent[] => {
if (!protest) return [];
const events: TimelineEvent[] = [
{
id: 'filed',
type: 'protest_filed',
timestamp: new Date(protest.filedAt),
actor: protestingDriver,
content: protest.incident.description,
metadata: {
lap: protest.incident.lap,
comment: protest.comment
}
}
];
// Add decision event if resolved
if (protest.status === 'upheld' || protest.status === 'dismissed') {
events.push({
id: 'decision',
type: 'decision',
timestamp: protest.reviewedAt ? new Date(protest.reviewedAt) : new Date(),
actor: null, // Would need to load steward driver
content: protest.decisionNotes || (protest.status === 'upheld' ? 'Protest upheld' : 'Protest dismissed'),
metadata: {
decision: protest.status
}
});
}
return events.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
}, [protest, protestingDriver]);
const handleSubmitDecision = async () => {
if (!decision || !stewardNotes.trim() || !protest) return;
setSubmitting(true);
try {
const reviewUseCase = getReviewProtestUseCase();
const penaltyUseCase = getApplyPenaltyUseCase();
if (decision === 'uphold') {
await reviewUseCase.execute({
protestId: protest.id,
stewardId: currentDriverId,
decision: 'uphold',
decisionNotes: stewardNotes,
});
const selectedPenalty = PENALTY_TYPES.find(p => p.type === penaltyType);
await penaltyUseCase.execute({
raceId: protest.raceId,
driverId: protest.accusedDriverId,
stewardId: currentDriverId,
type: penaltyType,
value: selectedPenalty?.requiresValue ? penaltyValue : undefined,
reason: protest.incident.description,
protestId: protest.id,
notes: stewardNotes,
});
} else {
await reviewUseCase.execute({
protestId: protest.id,
stewardId: currentDriverId,
decision: 'dismiss',
decisionNotes: stewardNotes,
});
}
router.push(`/leagues/${leagueId}/stewarding`);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to submit decision');
} finally {
setSubmitting(false);
}
};
const handleRequestDefense = async () => {
if (!protest) return;
try {
const requestDefenseUseCase = getRequestProtestDefenseUseCase();
const sendNotificationUseCase = getSendNotificationUseCase();
// Request defense
const result = await requestDefenseUseCase.execute({
protestId: protest.id,
stewardId: currentDriverId,
});
// Send notification to accused driver
await sendNotificationUseCase.execute({
recipientId: result.accusedDriverId,
type: 'protest_filed',
title: 'Defense Requested',
body: `A steward has requested your defense for a protest filed against you.`,
actionUrl: `/leagues/${leagueId}/stewarding/protests/${protest.id}`,
data: {
protestId: protest.id,
raceId: protest.raceId,
leagueId,
},
});
// Reload page to show updated status
window.location.reload();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to request defense');
}
};
const getStatusConfig = (status: string) => {
switch (status) {
case 'pending':
return { label: 'Pending Review', color: 'bg-warning-amber/20 text-warning-amber border-warning-amber/30', icon: Clock };
case 'under_review':
return { label: 'Under Review', color: 'bg-blue-500/20 text-blue-400 border-blue-500/30', icon: Shield };
case 'awaiting_defense':
return { label: 'Awaiting Defense', color: 'bg-purple-500/20 text-purple-400 border-purple-500/30', icon: MessageCircle };
case 'upheld':
return { label: 'Upheld', color: 'bg-red-500/20 text-red-400 border-red-500/30', icon: CheckCircle };
case 'dismissed':
return { label: 'Dismissed', color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: XCircle };
default:
return { label: status, color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: AlertCircle };
}
};
if (!isAdmin) {
return (
<Card>
<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">
<AlertTriangle className="w-8 h-8 text-warning-amber" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
<p className="text-sm text-gray-400">
Only league admins can review protests.
</p>
</div>
</Card>
);
}
if (loading || !protest || !race) {
return (
<Card>
<div className="text-center py-12">
<div className="animate-pulse text-gray-400">Loading protest details...</div>
</div>
</Card>
);
}
const statusConfig = getStatusConfig(protest.status);
const StatusIcon = statusConfig.icon;
const isPending = protest.status === 'pending' || protest.status === 'under_review' || protest.status === 'awaiting_defense';
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
return (
<div className="min-h-screen">
{/* Compact Header */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-4">
<Link href={`/leagues/${leagueId}/stewarding`} className="text-gray-400 hover:text-white transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<div className="flex-1 flex items-center gap-3">
<h1 className="text-xl font-bold text-white">Protest Review</h1>
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${statusConfig.color}`}>
<StatusIcon className="w-3 h-3" />
{statusConfig.label}
</div>
{daysSinceFiled > 2 && isPending && (
<span className="flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
</div>
</div>
{/* Main Layout: Feed + Sidebar */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left Sidebar - Incident Info */}
<div className="lg:col-span-3 space-y-4">
{/* Drivers Involved */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Parties Involved</h3>
<div className="space-y-3">
{/* Protesting Driver */}
<Link href={`/drivers/${protestingDriver?.id || ''}`} className="block">
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors cursor-pointer">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-blue-400 font-medium">Protesting</p>
<p className="text-sm font-semibold text-white truncate">{protestingDriver?.name || 'Unknown'}</p>
</div>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
{/* Accused Driver */}
<Link href={`/drivers/${accusedDriver?.id || ''}`} className="block">
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-orange-500/50 hover:bg-orange-500/5 transition-colors cursor-pointer">
<div className="w-10 h-10 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-orange-400 font-medium">Accused</p>
<p className="text-sm font-semibold text-white truncate">{accusedDriver?.name || 'Unknown'}</p>
</div>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
</div>
</Card>
{/* Race Info */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Race Details</h3>
<Link
href={`/races/${race.id}`}
className="block mb-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/50 hover:bg-primary-blue/5 transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white">{race.track}</span>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<MapPin className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.track}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.scheduledAt.toLocaleDateString()}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Flag className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">Lap {protest.incident.lap}</span>
</div>
</div>
</Card>
{/* Evidence */}
{protest.proofVideoUrl && (
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
<a
href={protest.proofVideoUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-3 rounded-lg bg-primary-blue/10 border border-primary-blue/20 text-primary-blue hover:bg-primary-blue/20 transition-colors"
>
<Video className="w-4 h-4" />
<span className="text-sm font-medium flex-1">Watch Video</span>
<ExternalLink className="w-3 h-3" />
</a>
</Card>
)}
{/* Quick Stats */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Timeline</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Filed</span>
<span className="text-gray-300">{new Date(protest.filedAt).toLocaleDateString()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Age</span>
<span className={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</span>
</div>
{protest.reviewedAt && (
<div className="flex justify-between">
<span className="text-gray-500">Resolved</span>
<span className="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</span>
</div>
)}
</div>
</Card>
</div>
{/* Center - Discussion Feed */}
<div className="lg:col-span-6 space-y-4">
{/* Timeline / Feed */}
<Card className="p-0 overflow-hidden">
<div className="p-4 border-b border-charcoal-outline bg-iron-gray/30">
<h2 className="text-sm font-semibold text-white">Discussion</h2>
</div>
<div className="divide-y divide-charcoal-outline/50">
{/* Initial Protest Filing */}
<div className="p-4">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<AlertCircle className="w-5 h-5 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-white text-sm">{protestingDriver?.name || 'Unknown'}</span>
<span className="text-xs text-blue-400 font-medium">filed protest</span>
<span className="text-xs text-gray-500"></span>
<span className="text-xs text-gray-500">{new Date(protest.filedAt).toLocaleString()}</span>
</div>
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
<p className="text-sm text-gray-300 mb-3">{protest.incident.description}</p>
{protest.comment && (
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<p className="text-xs text-gray-500 mb-1">Additional details:</p>
<p className="text-sm text-gray-400">{protest.comment}</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* Defense placeholder - will be populated when defense system is implemented */}
{protest.status === 'awaiting_defense' && (
<div className="p-4 bg-purple-500/5">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center flex-shrink-0">
<MessageCircle className="w-5 h-5 text-purple-400" />
</div>
<div className="flex-1">
<p className="text-sm text-purple-400 font-medium mb-1">Defense Requested</p>
<p className="text-sm text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</p>
</div>
</div>
</div>
)}
{/* Decision (if resolved) */}
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
<div className={`p-4 ${protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}`}>
<div className="flex gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'
}`}>
<Gavel className={`w-5 h-5 ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-white text-sm">Steward Decision</span>
<span className={`text-xs font-medium ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`}>
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
</span>
{protest.reviewedAt && (
<>
<span className="text-xs text-gray-500"></span>
<span className="text-xs text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</span>
</>
)}
</div>
<div className={`rounded-lg p-4 border ${
protest.status === 'upheld'
? 'bg-red-500/10 border-red-500/20'
: 'bg-gray-500/10 border-gray-500/20'
}`}>
<p className="text-sm text-gray-300">{protest.decisionNotes}</p>
</div>
</div>
</div>
</div>
)}
</div>
{/* Add Comment (future feature) */}
{isPending && (
<div className="p-4 border-t border-charcoal-outline bg-iron-gray/20">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-gray-500" />
</div>
<div className="flex-1">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Add a comment or request more information..."
rows={2}
className="w-full px-4 py-3 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue text-sm resize-none"
/>
<div className="flex justify-end mt-2">
<Button variant="secondary" disabled={!newComment.trim()}>
<Send className="w-3 h-3 mr-1" />
Comment
</Button>
</div>
</div>
</div>
</div>
)}
</Card>
</div>
{/* Right Sidebar - Actions */}
<div className="lg:col-span-3 space-y-4">
{isPending && (
<>
{/* Quick Actions */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Actions</h3>
<div className="space-y-2">
<Button
variant="secondary"
className="w-full justify-start"
onClick={handleRequestDefense}
>
<MessageCircle className="w-4 h-4 mr-2" />
Request Defense
</Button>
<Button
variant="primary"
className="w-full justify-start"
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
>
<Gavel className="w-4 h-4 mr-2" />
Make Decision
<ChevronDown className={`w-4 h-4 ml-auto transition-transform ${showDecisionPanel ? 'rotate-180' : ''}`} />
</Button>
</div>
</Card>
{/* Decision Panel */}
{showDecisionPanel && (
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Stewarding Decision</h3>
{/* Decision Selection */}
<div className="grid grid-cols-2 gap-2 mb-4">
<button
onClick={() => setDecision('uphold')}
className={`p-3 rounded-lg border-2 transition-all ${
decision === 'uphold'
? 'border-red-500 bg-red-500/10'
: 'border-charcoal-outline hover:border-gray-600'
}`}
>
<CheckCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'uphold' ? 'text-red-400' : 'text-gray-500'}`} />
<p className={`text-xs font-medium ${decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}`}>Uphold</p>
</button>
<button
onClick={() => setDecision('dismiss')}
className={`p-3 rounded-lg border-2 transition-all ${
decision === 'dismiss'
? 'border-gray-500 bg-gray-500/10'
: 'border-charcoal-outline hover:border-gray-600'
}`}
>
<XCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'}`} />
<p className={`text-xs font-medium ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}`}>Dismiss</p>
</button>
</div>
{/* Penalty Selection (if upholding) */}
{decision === 'uphold' && (
<div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label>
<div className="grid grid-cols-2 gap-1.5">
{PENALTY_TYPES.map((penalty) => {
const Icon = penalty.icon;
const isSelected = penaltyType === penalty.type;
return (
<button
key={penalty.type}
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
className={`p-2 rounded-lg border transition-all text-left ${
isSelected
? `${penalty.color} border`
: 'border-charcoal-outline hover:border-gray-600 bg-iron-gray/30'
}`}
title={penalty.description}
>
<Icon className={`h-3.5 w-3.5 mb-0.5 ${isSelected ? '' : 'text-gray-500'}`} />
<p className={`text-[10px] font-medium leading-tight ${isSelected ? '' : 'text-gray-500'}`}>
{penalty.label}
</p>
</button>
);
})}
</div>
{PENALTY_TYPES.find(p => p.type === penaltyType)?.requiresValue && (
<div className="mt-3">
<label className="text-xs font-medium text-gray-400 mb-1 block">
Value ({PENALTY_TYPES.find(p => p.type === penaltyType)?.valueLabel})
</label>
<input
type="number"
value={penaltyValue}
onChange={(e) => setPenaltyValue(Number(e.target.value))}
min="1"
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:border-primary-blue"
/>
</div>
)}
</div>
)}
{/* Steward Notes */}
<div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-1 block">Decision Reasoning *</label>
<textarea
value={stewardNotes}
onChange={(e) => setStewardNotes(e.target.value)}
placeholder="Explain your decision..."
rows={4}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 text-sm focus:outline-none focus:border-primary-blue resize-none"
/>
</div>
{/* Submit */}
<Button
variant="primary"
className="w-full"
onClick={handleSubmitDecision}
disabled={!decision || !stewardNotes.trim() || submitting}
>
{submitting ? 'Submitting...' : 'Submit Decision'}
</Button>
</Card>
)}
</>
)}
{/* Already Resolved Info */}
{!isPending && (
<Card className="p-4">
<div className={`text-center py-4 ${
protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'
}`}>
<Gavel className="w-8 h-8 mx-auto mb-2" />
<p className="font-semibold">Case Closed</p>
<p className="text-xs text-gray-500 mt-1">
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
</p>
</div>
</Card>
)}
</div>
</div>
</div>
);
}