streamline components
This commit is contained in:
@@ -5,6 +5,9 @@ import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateC
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
|
||||
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { LEAGUE_SETTINGS_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import type { CustomPointsConfig } from '@/lib/view-models/ScoringConfigurationViewModel';
|
||||
|
||||
// ============================================================================
|
||||
// INFO FLYOUT COMPONENT
|
||||
@@ -304,14 +307,6 @@ interface ScoringPatternSectionProps {
|
||||
onUpdateCustomPoints?: (points: CustomPointsConfig) => void;
|
||||
}
|
||||
|
||||
// Custom points configuration for inline editor
|
||||
export interface CustomPointsConfig {
|
||||
racePoints: number[];
|
||||
poleBonusPoints: number;
|
||||
fastestLapPoints: number;
|
||||
leaderLapPoints: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CUSTOM_POINTS: CustomPointsConfig = {
|
||||
racePoints: [25, 18, 15, 12, 10, 8, 6, 4, 2, 1],
|
||||
poleBonusPoints: 1,
|
||||
@@ -333,29 +328,19 @@ export function LeagueScoringSection({
|
||||
patternOnly,
|
||||
championshipsOnly,
|
||||
}: LeagueScoringSectionProps) {
|
||||
const leagueSettingsService = useInject(LEAGUE_SETTINGS_SERVICE_TOKEN);
|
||||
const disabled = readOnly || !onChange;
|
||||
|
||||
const handleSelectPreset = (presetId: string) => {
|
||||
if (disabled || !onChange) return;
|
||||
onChange({
|
||||
...form,
|
||||
scoring: {
|
||||
...form.scoring,
|
||||
patternId: presetId,
|
||||
customScoringEnabled: false,
|
||||
},
|
||||
});
|
||||
const updatedForm = leagueSettingsService.selectScoringPreset(form, presetId);
|
||||
onChange(updatedForm);
|
||||
};
|
||||
|
||||
const handleToggleCustomScoring = () => {
|
||||
if (disabled || !onChange) return;
|
||||
onChange({
|
||||
...form,
|
||||
scoring: {
|
||||
...form.scoring,
|
||||
customScoringEnabled: !form.scoring.customScoringEnabled,
|
||||
},
|
||||
});
|
||||
const updatedForm = leagueSettingsService.toggleCustomScoring(form);
|
||||
onChange(updatedForm);
|
||||
};
|
||||
|
||||
const patternProps: ScoringPatternSectionProps = {
|
||||
@@ -465,6 +450,7 @@ export function ScoringPatternSection({
|
||||
onToggleCustomScoring,
|
||||
onUpdateCustomPoints,
|
||||
}: ScoringPatternSectionProps) {
|
||||
const leagueSettingsService = useInject(LEAGUE_SETTINGS_SERVICE_TOKEN);
|
||||
const disabled = readOnly || !onChangePatternId;
|
||||
const currentPreset = presets.find((p) => p.id === scoring.patternId) ?? null;
|
||||
const isCustom = scoring.customScoringEnabled;
|
||||
@@ -514,19 +500,11 @@ export function ScoringPatternSection({
|
||||
};
|
||||
|
||||
const getPresetEmoji = (preset: LeagueScoringPresetViewModel) => {
|
||||
const name = preset.name.toLowerCase();
|
||||
if (name.includes('sprint') || name.includes('double')) return '⚡';
|
||||
if (name.includes('endurance') || name.includes('long')) return '🏆';
|
||||
if (name.includes('club') || name.includes('casual')) return '🏅';
|
||||
return '🏁';
|
||||
return leagueSettingsService.getPresetEmoji(preset);
|
||||
};
|
||||
|
||||
const getPresetDescription = (preset: LeagueScoringPresetViewModel) => {
|
||||
const name = preset.name.toLowerCase();
|
||||
if (name.includes('sprint')) return 'Sprint + Feature race';
|
||||
if (name.includes('endurance')) return 'Long-form endurance';
|
||||
if (name.includes('club')) return 'Casual league format';
|
||||
return preset.sessionSummary;
|
||||
return leagueSettingsService.getPresetDescription(preset);
|
||||
};
|
||||
|
||||
// Flyout state
|
||||
@@ -939,6 +917,7 @@ export function ChampionshipsSection({
|
||||
onChange,
|
||||
readOnly,
|
||||
}: ChampionshipsSectionProps) {
|
||||
const leagueSettingsService = useInject(LEAGUE_SETTINGS_SERVICE_TOKEN);
|
||||
const disabled = readOnly || !onChange;
|
||||
const isTeamsMode = form.structure.mode === 'fixedTeams';
|
||||
const [showChampFlyout, setShowChampFlyout] = useState(false);
|
||||
@@ -948,13 +927,8 @@ export function ChampionshipsSection({
|
||||
|
||||
const updateChampionship = (key: keyof LeagueConfigFormModel['championships'], value: boolean) => {
|
||||
if (!onChange) return;
|
||||
onChange({
|
||||
...form,
|
||||
championships: {
|
||||
...form.championships,
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
const updatedForm = leagueSettingsService.updateChampionship(form, key, value);
|
||||
onChange(updatedForm);
|
||||
};
|
||||
|
||||
const championships = [
|
||||
@@ -1157,4 +1131,4 @@ export function ChampionshipsSection({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useState } from 'react';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import Button from '@/components/ui/Button';
|
||||
import type { FileProtestCommandDTO } from '@/lib/types/generated/FileProtestCommandDTO';
|
||||
import type { ProtestIncidentDTO } from '@/lib/types/generated/ProtestIncidentDTO';
|
||||
import { useFileProtest } from '@/hooks/race/useFileProtest';
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -16,11 +15,9 @@ import {
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
|
||||
type ProtestParticipant = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import type { ProtestParticipant } from '@/lib/services/protests/ProtestService';
|
||||
|
||||
interface FileProtestModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -41,6 +38,7 @@ export default function FileProtestModal({
|
||||
}: FileProtestModalProps) {
|
||||
const fileProtestMutation = useFileProtest();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const protestService = useInject(PROTEST_SERVICE_TOKEN);
|
||||
|
||||
// Form state
|
||||
const [accusedDriverId, setAccusedDriverId] = useState<string>('');
|
||||
@@ -53,51 +51,41 @@ export default function FileProtestModal({
|
||||
const otherParticipants = participants.filter(p => p.id !== protestingDriverId);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validation
|
||||
if (!accusedDriverId) {
|
||||
setErrorMessage('Please select the driver you are protesting against.');
|
||||
return;
|
||||
try {
|
||||
// Use ProtestService for validation and command construction
|
||||
const command = protestService.constructFileProtestCommand({
|
||||
raceId,
|
||||
leagueId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
lap,
|
||||
timeInRace,
|
||||
description,
|
||||
comment,
|
||||
proofVideoUrl,
|
||||
});
|
||||
|
||||
setErrorMessage(null);
|
||||
|
||||
// Use existing hook for the actual API call
|
||||
fileProtestMutation.mutate(command, {
|
||||
onSuccess: () => {
|
||||
// Reset form state on success
|
||||
setAccusedDriverId('');
|
||||
setLap('');
|
||||
setTimeInRace('');
|
||||
setDescription('');
|
||||
setComment('');
|
||||
setProofVideoUrl('');
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorMessage(error.message || 'Failed to file protest');
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to validate protest input';
|
||||
setErrorMessage(errorMessage);
|
||||
}
|
||||
if (!lap || parseInt(lap, 10) < 0) {
|
||||
setErrorMessage('Please enter a valid lap number.');
|
||||
return;
|
||||
}
|
||||
if (!description.trim()) {
|
||||
setErrorMessage('Please describe what happened.');
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
|
||||
const incident: ProtestIncidentDTO = {
|
||||
lap: parseInt(lap, 10),
|
||||
description: description.trim(),
|
||||
...(timeInRace ? { timeInRace: parseInt(timeInRace, 10) } : {}),
|
||||
};
|
||||
|
||||
const command = {
|
||||
raceId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
incident,
|
||||
...(comment.trim() ? { comment: comment.trim() } : {}),
|
||||
...(proofVideoUrl.trim() ? { proofVideoUrl: proofVideoUrl.trim() } : {}),
|
||||
} satisfies FileProtestCommandDTO;
|
||||
|
||||
fileProtestMutation.mutate(command, {
|
||||
onSuccess: () => {
|
||||
// Reset form state on success
|
||||
setAccusedDriverId('');
|
||||
setLap('');
|
||||
setTimeInRace('');
|
||||
setDescription('');
|
||||
setComment('');
|
||||
setProofVideoUrl('');
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorMessage(error.message || 'Failed to file protest');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -207,7 +195,7 @@ export default function FileProtestModal({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Incident Description */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
|
||||
@@ -223,7 +211,7 @@ export default function FileProtestModal({
|
||||
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Additional Comment */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
|
||||
@@ -239,7 +227,7 @@ export default function FileProtestModal({
|
||||
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Video Proof */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
|
||||
@@ -258,7 +246,7 @@ export default function FileProtestModal({
|
||||
Providing video evidence significantly helps the stewards review your protest.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="p-3 bg-iron-gray rounded-lg border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-400">
|
||||
@@ -267,7 +255,7 @@ export default function FileProtestModal({
|
||||
to grid penalties for future races, depending on the severity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
@@ -290,4 +278,4 @@ export default function FileProtestModal({
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
interface ImportResultRowDTO {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { RACE_RESULTS_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import type { ImportResultRowDTO } from '@/lib/services/races/RaceResultsService';
|
||||
|
||||
interface ImportResultsFormProps {
|
||||
raceId: string;
|
||||
@@ -20,97 +12,10 @@ interface ImportResultsFormProps {
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
interface CSVRow {
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}
|
||||
|
||||
export default function ImportResultsForm({ raceId, onSuccess, onError }: ImportResultsFormProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const parseCSV = (content: string): CSVRow[] => {
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
if (lines.length < 2) {
|
||||
throw new Error('CSV file is empty or invalid');
|
||||
}
|
||||
|
||||
const headerLine = lines[0]!;
|
||||
const header = headerLine.toLowerCase().split(',').map((h) => h.trim());
|
||||
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!header.includes(field)) {
|
||||
throw new Error(`Missing required field: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: CSVRow[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
const values = line.split(',').map((v) => v.trim());
|
||||
|
||||
if (values.length !== header.length) {
|
||||
throw new Error(
|
||||
`Invalid row ${i}: expected ${header.length} columns, got ${values.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const row: Record<string, string> = {};
|
||||
header.forEach((field, index) => {
|
||||
row[field] = values[index] ?? '';
|
||||
});
|
||||
|
||||
const driverId = row['driverid'] ?? '';
|
||||
const position = parseInt(row['position'] ?? '', 10);
|
||||
const fastestLap = parseFloat(row['fastestlap'] ?? '');
|
||||
const incidents = parseInt(row['incidents'] ?? '', 10);
|
||||
const startPosition = parseInt(row['startposition'] ?? '', 10);
|
||||
|
||||
if (!driverId || driverId.length === 0) {
|
||||
throw new Error(`Row ${i}: driverId is required`);
|
||||
}
|
||||
|
||||
if (Number.isNaN(position) || position < 1) {
|
||||
throw new Error(`Row ${i}: position must be a positive integer`);
|
||||
}
|
||||
|
||||
if (Number.isNaN(fastestLap) || fastestLap < 0) {
|
||||
throw new Error(`Row ${i}: fastestLap must be a non-negative number`);
|
||||
}
|
||||
|
||||
if (Number.isNaN(incidents) || incidents < 0) {
|
||||
throw new Error(`Row ${i}: incidents must be a non-negative integer`);
|
||||
}
|
||||
|
||||
if (Number.isNaN(startPosition) || startPosition < 1) {
|
||||
throw new Error(`Row ${i}: startPosition must be a positive integer`);
|
||||
}
|
||||
|
||||
rows.push({ driverId, position, fastestLap, incidents, startPosition });
|
||||
}
|
||||
|
||||
const positions = rows.map((r) => r.position);
|
||||
const uniquePositions = new Set(positions);
|
||||
if (positions.length !== uniquePositions.size) {
|
||||
throw new Error('Duplicate positions found in CSV');
|
||||
}
|
||||
|
||||
const driverIds = rows.map((r) => r.driverId);
|
||||
const uniqueDrivers = new Set(driverIds);
|
||||
if (driverIds.length !== uniqueDrivers.size) {
|
||||
throw new Error('Duplicate driver IDs found in CSV');
|
||||
}
|
||||
|
||||
return rows;
|
||||
};
|
||||
const raceResultsService = useInject(RACE_RESULTS_SERVICE_TOKEN);
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -121,18 +26,7 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
const rows = parseCSV(content);
|
||||
|
||||
const results: ImportResultRowDTO[] = rows.map((row) => ({
|
||||
id: uuidv4(),
|
||||
raceId,
|
||||
driverId: row.driverId,
|
||||
position: row.position,
|
||||
fastestLap: row.fastestLap,
|
||||
incidents: row.incidents,
|
||||
startPosition: row.startPosition,
|
||||
}));
|
||||
|
||||
const results = raceResultsService.parseAndTransformCSV(content, raceId);
|
||||
onSuccess(results);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { useCapability } from '@/hooks/useCapability';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { POLICY_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
|
||||
type CapabilityGateProps = {
|
||||
capabilityKey: string;
|
||||
@@ -16,19 +18,19 @@ export function CapabilityGate({
|
||||
fallback = null,
|
||||
comingSoon = null,
|
||||
}: CapabilityGateProps) {
|
||||
const { isLoading, isError, capabilityState } = useCapability(capabilityKey);
|
||||
const policyService = useInject(POLICY_SERVICE_TOKEN);
|
||||
const { isLoading, isError, data: snapshot } = useCapability(capabilityKey);
|
||||
|
||||
if (isLoading || isError || !capabilityState) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
// Use PolicyService to centralize the evaluation logic
|
||||
const content = policyService.getCapabilityContent(
|
||||
snapshot || null,
|
||||
capabilityKey,
|
||||
isLoading,
|
||||
isError,
|
||||
children,
|
||||
fallback,
|
||||
comingSoon
|
||||
);
|
||||
|
||||
if (capabilityState === 'enabled') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (capabilityState === 'coming_soon') {
|
||||
return <>{comingSoon ?? fallback}</>;
|
||||
}
|
||||
|
||||
return <>{fallback}</>;
|
||||
return <>{content}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user