wip league admin tools
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user