streamline components

This commit is contained in:
2026-01-07 14:16:02 +01:00
parent 94d60527f4
commit 3b3971e653
16 changed files with 685 additions and 667 deletions

View File

@@ -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>
);
}
}

View File

@@ -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 =