move static data

This commit is contained in:
2025-12-26 00:20:53 +01:00
parent c977defd6a
commit b6cbb81388
63 changed files with 1482 additions and 418 deletions

View File

@@ -24,9 +24,6 @@ import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
// Local type definitions to replace core imports
type PenaltyType = 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points' | 'probation' | 'fine' | 'race_ban';
export default function LeagueStewardingPage() {
const params = useParams();
const leagueId = params.id as string;
@@ -81,7 +78,7 @@ export default function LeagueStewardingPage() {
const handleAcceptProtest = async (
protestId: string,
penaltyType: PenaltyType,
penaltyType: string,
penaltyValue: number,
stewardNotes: string
) => {

View File

@@ -8,7 +8,8 @@ import { useServices } from '@/lib/services/ServiceProvider';
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
import { ProtestDriverViewModel } from '@/lib/view-models/ProtestDriverViewModel';
import { ProtestDecisionCommandModel, type PenaltyType } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
import type { PenaltyTypesReferenceDTO, PenaltyValueKindDTO } from '@/lib/types/PenaltyTypesReferenceDTO';
import {
AlertCircle,
AlertTriangle,
@@ -45,68 +46,88 @@ interface TimelineEvent {
metadata?: Record<string, unknown>;
}
const PENALTY_TYPES = [
{
type: 'time_penalty' as PenaltyType,
type PenaltyUiConfig = {
label: string;
description: string;
icon: typeof Gavel;
color: string;
defaultValue?: number;
};
const PENALTY_UI: Record<string, PenaltyUiConfig> = {
time_penalty: {
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
defaultValue: 5,
},
{
type: 'grid_penalty' as PenaltyType,
grid_penalty: {
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
defaultValue: 3,
},
{
type: 'points_deduction' as PenaltyType,
points_deduction: {
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
defaultValue: 5,
},
{
type: 'disqualification' as PenaltyType,
disqualification: {
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
defaultValue: 0,
},
{
type: 'warning' as PenaltyType,
warning: {
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
defaultValue: 0,
},
{
type: 'license_points' as PenaltyType,
license_points: {
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
defaultValue: 2,
},
];
};
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();
@@ -114,7 +135,7 @@ export default function ProtestReviewPage() {
const leagueId = params.id as string;
const protestId = params.protestId as string;
const currentDriverId = useEffectiveDriverId();
const { protestService, leagueMembershipService } = useServices();
const { protestService, leagueMembershipService, penaltyService } = useServices();
const [protest, setProtest] = useState<ProtestViewModel | null>(null);
const [race, setRace] = useState<RaceViewModel | null>(null);
@@ -122,14 +143,41 @@ export default function ProtestReviewPage() {
const [accusedDriver, setAccusedDriver] = useState<ProtestDriverViewModel | 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);
const [penaltyType, setPenaltyType] = useState<PenaltyType>('time_penalty');
const [penaltyType, setPenaltyType] = useState<string>('time_penalty');
const [penaltyValue, setPenaltyValue] = useState<number>(5);
const [stewardNotes, setStewardNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const penaltyTypes = useMemo(() => {
const referenceItems = penaltyTypesReference?.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),
};
});
}, [penaltyTypesReference]);
const selectedPenalty = useMemo(() => {
return penaltyTypes.find((p) => p.type === penaltyType);
}, [penaltyTypes, penaltyType]);
// Comment state
const [newComment, setNewComment] = useState('');
@@ -168,15 +216,47 @@ export default function ProtestReviewPage() {
if (isAdmin) {
loadProtest();
}
}, [protestId, leagueId, isAdmin, router]);
}, [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]);
const handleSubmitDecision = async () => {
if (!decision || !stewardNotes.trim() || !protest) return;
if (penaltyTypesLoading) return;
setSubmitting(true);
try {
const defaultUpheldReason = penaltyTypesReference?.defaultReasons?.upheld;
const defaultDismissedReason = penaltyTypesReference?.defaultReasons?.dismissed;
if (decision === 'uphold') {
const requiresValue = selectedPenalty?.requiresValue ?? true;
const commandModel = new ProtestDecisionCommandModel({
decision,
penaltyType,
@@ -184,17 +264,32 @@ export default function ProtestReviewPage() {
stewardNotes,
});
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
}
if (defaultDismissedReason) {
options.defaultDismissedReason = defaultDismissedReason;
}
const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId,
protest.accusedDriverId,
currentDriverId,
protest.id
protest.id,
options,
);
await protestService.applyPenalty(penaltyCommand);
} else {
// For dismiss, we might need a separate endpoint
// For now, just apply a warning penalty with 0 value or create a separate endpoint
const warningRef = penaltyTypesReference?.penaltyTypes.find((p) => p.type === 'warning');
const requiresValue = warningRef?.requiresValue ?? false;
const commandModel = new ProtestDecisionCommandModel({
decision,
penaltyType: 'warning',
@@ -202,15 +297,27 @@ export default function ProtestReviewPage() {
stewardNotes,
});
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
}
if (defaultDismissedReason) {
options.defaultDismissedReason = defaultDismissedReason;
}
const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId,
protest.accusedDriverId,
currentDriverId,
protest.id
protest.id,
options,
);
penaltyCommand.reason = stewardNotes || 'Protest dismissed';
await protestService.applyPenalty(penaltyCommand);
}
@@ -601,46 +708,55 @@ export default function ProtestReviewPage() {
{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"
/>
{penaltyTypes.length === 0 ? (
<div className="text-xs text-gray-500">
Loading penalty types...
</div>
) : (
<>
<div className="grid grid-cols-2 gap-1.5">
{penaltyTypes.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>
{selectedPenalty?.requiresValue && (
<div className="mt-3">
<label className="text-xs font-medium text-gray-400 mb-1 block">
Value ({selectedPenalty.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>
)}

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import RaceDetailPage from './page';
import { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel';
@@ -67,6 +68,17 @@ vi.mock('@/lib/utilities/LeagueMembershipUtility', () => ({
},
}));
const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
const createViewModel = (status: string) => {
return new RaceDetailViewModel({
race: {
@@ -119,7 +131,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<RaceDetailPage />);
renderWithQueryClient(<RaceDetailPage />);
const reopenButton = await screen.findByText('Re-open Race');
expect(reopenButton).toBeInTheDocument();
@@ -145,7 +157,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
const viewModel = createViewModel('completed');
mockGetRaceDetail.mockResolvedValue(viewModel);
render(<RaceDetailPage />);
renderWithQueryClient(<RaceDetailPage />);
await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled();
@@ -159,7 +171,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
const viewModel = createViewModel('scheduled');
mockGetRaceDetail.mockResolvedValue(viewModel);
render(<RaceDetailPage />);
renderWithQueryClient(<RaceDetailPage />);
await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled();

View File

@@ -33,7 +33,7 @@ import {
} from 'lucide-react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
export default function RaceDetailPage() {
const router = useRouter();