wip league admin tools

This commit is contained in:
2025-12-28 12:04:12 +01:00
parent 5dc8c2399c
commit 6edf12fda8
401 changed files with 15365 additions and 6047 deletions

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import ProtestReviewPage from './page';
// Mocks for Next.js navigation
const mockPush = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
useParams: () => ({ id: 'league-1', protestId: 'protest-1' }),
}));
// Mock effective driver id hook
vi.mock('@/hooks/useEffectiveDriverId', () => ({
useEffectiveDriverId: () => 'driver-1',
}));
const mockGetProtestDetailViewModel = vi.fn();
const mockFetchLeagueMemberships = vi.fn();
const mockGetMembership = vi.fn();
vi.mock('@/lib/services/ServiceProvider', () => ({
useServices: () => ({
leagueStewardingService: {
getProtestDetailViewModel: mockGetProtestDetailViewModel,
},
protestService: {
applyPenalty: vi.fn(),
requestDefense: vi.fn(),
},
leagueMembershipService: {
fetchLeagueMemberships: mockFetchLeagueMemberships,
getMembership: mockGetMembership,
},
}),
}));
const mockIsLeagueAdminOrHigherRole = vi.fn();
vi.mock('@/lib/utilities/LeagueRoleUtility', () => ({
LeagueRoleUtility: {
isLeagueAdminOrHigherRole: (...args: unknown[]) => mockIsLeagueAdminOrHigherRole(...args),
},
}));
describe('ProtestReviewPage', () => {
beforeEach(() => {
mockPush.mockReset();
mockGetProtestDetailViewModel.mockReset();
mockFetchLeagueMemberships.mockReset();
mockGetMembership.mockReset();
mockIsLeagueAdminOrHigherRole.mockReset();
mockFetchLeagueMemberships.mockResolvedValue(undefined);
mockGetMembership.mockReturnValue({ role: 'admin' });
mockIsLeagueAdminOrHigherRole.mockReturnValue(true);
});
it('loads protest detail via LeagueStewardingService view model method', async () => {
mockGetProtestDetailViewModel.mockResolvedValue({
protest: {
id: 'protest-1',
raceId: 'race-1',
protestingDriverId: 'driver-1',
accusedDriverId: 'driver-2',
description: 'desc',
submittedAt: '2023-10-01T10:00:00Z',
status: 'pending',
incident: { lap: 1 },
},
race: {
id: 'race-1',
name: 'Test Race',
formattedDate: '10/1/2023',
},
protestingDriver: { id: 'driver-1', name: 'Driver 1' },
accusedDriver: { id: 'driver-2', name: 'Driver 2' },
penaltyTypes: [
{
type: 'time_penalty',
label: 'Time Penalty',
description: 'Add seconds to race result',
requiresValue: true,
valueLabel: 'seconds',
defaultValue: 5,
},
],
defaultReasons: { upheld: 'Upheld reason', dismissed: 'Dismissed reason' },
initialPenaltyType: 'time_penalty',
initialPenaltyValue: 5,
});
render(<ProtestReviewPage />);
await waitFor(() => {
expect(mockGetProtestDetailViewModel).toHaveBeenCalledWith('league-1', 'protest-1');
});
expect(await screen.findByText('Protest Review')).toBeInTheDocument();
});
});

View File

@@ -5,11 +5,9 @@ import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useServices } from '@/lib/services/ServiceProvider';
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
import type { ProtestDetailViewModel } from '@/lib/view-models/ProtestDetailViewModel';
import { ProtestDriverViewModel } from '@/lib/view-models/ProtestDriverViewModel';
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
import type { PenaltyTypesReferenceDTO, PenaltyValueKindDTO } from '@/lib/types/PenaltyTypesReferenceDTO';
import {
AlertCircle,
AlertTriangle,
@@ -99,54 +97,18 @@ const PENALTY_UI: Record<string, PenaltyUiConfig> = {
},
};
function getPenaltyValueLabel(valueKind: PenaltyValueKindDTO): string {
switch (valueKind) {
case 'seconds':
return 'seconds';
case 'grid_positions':
return 'positions';
case 'points':
return 'points';
case 'races':
return 'races';
case 'none':
return '';
}
}
function getFallbackDefaultValue(valueKind: PenaltyValueKindDTO): number {
switch (valueKind) {
case 'seconds':
return 5;
case 'grid_positions':
return 3;
case 'points':
return 5;
case 'races':
return 1;
case 'none':
return 0;
}
}
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 { protestService, leagueMembershipService, penaltyService } = useServices();
const { leagueStewardingService, protestService, leagueMembershipService } = useServices();
const [protest, setProtest] = useState<ProtestViewModel | null>(null);
const [race, setRace] = useState<RaceViewModel | null>(null);
const [protestingDriver, setProtestingDriver] = useState<ProtestDriverViewModel | null>(null);
const [accusedDriver, setAccusedDriver] = useState<ProtestDriverViewModel | null>(null);
const [detail, setDetail] = useState<ProtestDetailViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [penaltyTypesReference, setPenaltyTypesReference] = useState<PenaltyTypesReferenceDTO | null>(null);
const [penaltyTypesLoading, setPenaltyTypesLoading] = useState(false);
// Decision state
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
@@ -156,24 +118,20 @@ export default function ProtestReviewPage() {
const [submitting, setSubmitting] = useState(false);
const penaltyTypes = useMemo(() => {
const referenceItems = penaltyTypesReference?.penaltyTypes ?? [];
const referenceItems = detail?.penaltyTypes ?? [];
return referenceItems.map((ref) => {
const ui = PENALTY_UI[ref.type] ?? {
label: ref.type.replaceAll('_', ' '),
description: '',
icon: Gavel,
color: 'text-gray-400 bg-gray-500/10 border-gray-500/20',
defaultValue: getFallbackDefaultValue(ref.valueKind),
};
return {
...ref,
...ui,
valueLabel: getPenaltyValueLabel(ref.valueKind),
defaultValue: ui.defaultValue ?? getFallbackDefaultValue(ref.valueKind),
icon: ui.icon,
color: ui.color,
};
});
}, [penaltyTypesReference]);
}, [detail?.penaltyTypes]);
const selectedPenalty = useMemo(() => {
return penaltyTypes.find((p) => p.type === penaltyType);
@@ -195,15 +153,14 @@ export default function ProtestReviewPage() {
async function loadProtest() {
setLoading(true);
try {
const protestData = await protestService.getProtestById(leagueId, protestId);
if (!protestData) {
throw new Error('Protest not found');
}
const protestDetail = await leagueStewardingService.getProtestDetailViewModel(leagueId, protestId);
setProtest(protestData.protest);
setRace(protestData.race);
setProtestingDriver(protestData.protestingDriver);
setAccusedDriver(protestData.accusedDriver);
setDetail(protestDetail);
if (protestDetail.initialPenaltyType) {
setPenaltyType(protestDetail.initialPenaltyType);
setPenaltyValue(protestDetail.initialPenaltyValue);
}
} catch (err) {
console.error('Failed to load protest:', err);
alert('Failed to load protest details');
@@ -216,43 +173,18 @@ export default function ProtestReviewPage() {
if (isAdmin) {
loadProtest();
}
}, [protestId, leagueId, isAdmin, router, protestService]);
useEffect(() => {
async function loadPenaltyTypes() {
if (!isAdmin) return;
if (penaltyTypesReference) return;
setPenaltyTypesLoading(true);
try {
const ref = await penaltyService.getPenaltyTypesReference();
setPenaltyTypesReference(ref);
const hasSelected = ref.penaltyTypes.some((p) => p.type === penaltyType);
const [first] = ref.penaltyTypes;
if (!hasSelected && first) {
setPenaltyType(first.type);
setPenaltyValue(PENALTY_UI[first.type]?.defaultValue ?? getFallbackDefaultValue(first.valueKind));
}
} catch (err) {
console.error('Failed to load penalty types reference:', err);
} finally {
setPenaltyTypesLoading(false);
}
}
loadPenaltyTypes();
}, [isAdmin, penaltyService, penaltyTypesReference, penaltyType]);
}, [protestId, leagueId, isAdmin, router, leagueStewardingService]);
const handleSubmitDecision = async () => {
if (!decision || !stewardNotes.trim() || !protest) return;
if (penaltyTypesLoading) return;
if (!decision || !stewardNotes.trim() || !detail) return;
setSubmitting(true);
try {
const defaultUpheldReason = penaltyTypesReference?.defaultReasons?.upheld;
const defaultDismissedReason = penaltyTypesReference?.defaultReasons?.dismissed;
const protest = detail.protest;
const defaultUpheldReason = detail.defaultReasons?.upheld;
const defaultDismissedReason = detail.defaultReasons?.dismissed;
if (decision === 'uphold') {
const requiresValue = selectedPenalty?.requiresValue ?? true;
@@ -287,7 +219,7 @@ export default function ProtestReviewPage() {
await protestService.applyPenalty(penaltyCommand);
} else {
const warningRef = penaltyTypesReference?.penaltyTypes.find((p) => p.type === 'warning');
const warningRef = detail.penaltyTypes.find((p) => p.type === 'warning');
const requiresValue = warningRef?.requiresValue ?? false;
const commandModel = new ProtestDecisionCommandModel({
@@ -330,12 +262,12 @@ export default function ProtestReviewPage() {
};
const handleRequestDefense = async () => {
if (!protest) return;
if (!detail) return;
try {
// Request defense
await protestService.requestDefense({
protestId: protest.id,
protestId: detail.protest.id,
stewardId: currentDriverId,
});
@@ -379,7 +311,7 @@ export default function ProtestReviewPage() {
);
}
if (loading || !protest || !race) {
if (loading || !detail) {
return (
<Card>
<div className="text-center py-12">
@@ -389,6 +321,11 @@ export default function ProtestReviewPage() {
);
}
const protest = detail.protest;
const race = detail.race;
const protestingDriver = detail.protestingDriver;
const accusedDriver = detail.accusedDriver;
const statusConfig = getStatusConfig(protest.status);
const StatusIcon = statusConfig.icon;
const isPending = protest.status === 'pending';