517 lines
22 KiB
TypeScript
517 lines
22 KiB
TypeScript
'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 QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
|
|
import PenaltyFAB from '@/components/leagues/PenaltyFAB';
|
|
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
|
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
|
import type { Protest } from '@core/racing/domain/entities/Protest';
|
|
import type { Race } from '@core/racing/domain/entities/Race';
|
|
import type { Penalty, PenaltyType } from '@core/racing/domain/entities/Penalty';
|
|
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
|
|
import { EntityMappers } from '@core/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 [allDrivers, setAllDrivers] = useState<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());
|
|
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = 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]);
|
|
|
|
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);
|
|
setAllDrivers(Object.values(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>
|
|
|
|
{activeTab === 'history' && (
|
|
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
|
|
)}
|
|
|
|
{selectedProtest && (
|
|
<ReviewProtestModal
|
|
protest={selectedProtest}
|
|
onClose={() => setSelectedProtest(null)}
|
|
onAccept={handleAcceptProtest}
|
|
onReject={handleRejectProtest}
|
|
/>
|
|
)}
|
|
|
|
{showQuickPenaltyModal && (
|
|
<QuickPenaltyModal
|
|
drivers={allDrivers}
|
|
onClose={() => setShowQuickPenaltyModal(false)}
|
|
adminId={currentDriverId}
|
|
races={races.map(r => ({ id: r.id, track: r.track, scheduledAt: r.scheduledAt }))}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
} |