241 lines
9.0 KiB
TypeScript
241 lines
9.0 KiB
TypeScript
'use client';
|
|
|
|
import { usePenaltyMutation } from "@/hooks/league/usePenaltyMutation";
|
|
import { Button } from '@/ui/Button';
|
|
import { Heading } from '@/ui/Heading';
|
|
import { Icon } from '@/ui/Icon';
|
|
import { Stack } from '@/ui/Stack';
|
|
import { Select } from '@/ui/Select';
|
|
import { Text } from '@/ui/Text';
|
|
import { TextArea } from '@/ui/TextArea';
|
|
import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
|
|
interface DriverOption {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
interface QuickPenaltyModalProps {
|
|
raceId?: string;
|
|
drivers: DriverOption[];
|
|
onClose: () => void;
|
|
onRefresh?: () => void;
|
|
preSelectedDriver?: DriverOption;
|
|
adminId: string;
|
|
races?: { id: string; track: string; scheduledAt: Date }[];
|
|
}
|
|
|
|
const INFRACTION_TYPES = [
|
|
{ value: 'track_limits', label: 'Track Limits', icon: Flag },
|
|
{ value: 'unsafe_rejoin', label: 'Unsafe Rejoin', icon: AlertTriangle },
|
|
{ value: 'aggressive_driving', label: 'Aggressive Driving', icon: Zap },
|
|
{ value: 'false_start', label: 'False Start', icon: Clock },
|
|
{ value: 'other', label: 'Other', icon: AlertTriangle },
|
|
] as const;
|
|
|
|
const SEVERITY_LEVELS = [
|
|
{ value: 'warning', label: 'Warning', description: 'Official warning only' },
|
|
{ value: 'minor', label: 'Minor', description: 'Light penalty' },
|
|
{ value: 'major', label: 'Major', description: 'Significant penalty' },
|
|
{ value: 'severe', label: 'Severe', description: 'Heavy penalty' },
|
|
] as const;
|
|
|
|
export function QuickPenaltyModal({ raceId, drivers, onClose, onRefresh, preSelectedDriver, adminId, races }: QuickPenaltyModalProps) {
|
|
const [selectedRaceId, setSelectedRaceId] = useState<string>(raceId || '');
|
|
const [selectedDriver, setSelectedDriver] = useState<string>(preSelectedDriver?.id || '');
|
|
const [infractionType, setInfractionType] = useState<string>('');
|
|
const [severity, setSeverity] = useState<string>('');
|
|
const [notes, setNotes] = useState<string>('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const penaltyMutation = usePenaltyMutation();
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!selectedRaceId || !selectedDriver || !infractionType || !severity) return;
|
|
|
|
setError(null);
|
|
|
|
try {
|
|
const command = {
|
|
raceId: selectedRaceId,
|
|
driverId: selectedDriver,
|
|
stewardId: adminId,
|
|
type: infractionType,
|
|
reason: severity,
|
|
notes: notes.trim() || undefined,
|
|
};
|
|
|
|
await penaltyMutation.mutateAsync(command);
|
|
|
|
// Refresh the page to show updated results
|
|
onRefresh?.();
|
|
onClose();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to apply penalty');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Stack position="fixed" inset="0" zIndex={50} display="flex" alignItems="center" justifyContent="center" p={4} bg="bg-black/70"
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
className="backdrop-blur-sm"
|
|
>
|
|
<Stack w="full" maxWidth="md" bg="bg-iron-gray" rounded="xl" border borderColor="border-charcoal-outline" shadow="2xl">
|
|
<Stack p={6}>
|
|
<Heading level={2} fontSize="xl" weight="bold" color="text-white" mb={4}>Quick Penalty</Heading>
|
|
|
|
<Stack as="form" onSubmit={handleSubmit} display="flex" flexDirection="col" gap={4}>
|
|
{/* Race Selection */}
|
|
{races && !raceId && (
|
|
<Stack>
|
|
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
|
|
Race
|
|
</Text>
|
|
<Select
|
|
value={selectedRaceId}
|
|
onChange={(e) => setSelectedRaceId(e.target.value)}
|
|
required
|
|
options={[
|
|
{ value: '', label: 'Select race...' },
|
|
...races.map((race) => ({
|
|
value: race.id,
|
|
label: `${race.track} (${race.scheduledAt.toLocaleDateString()})`,
|
|
})),
|
|
]}
|
|
/>
|
|
</Stack>
|
|
)}
|
|
|
|
{/* Driver Selection */}
|
|
<Stack>
|
|
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
|
|
Driver
|
|
</Text>
|
|
{preSelectedDriver ? (
|
|
<Stack w="full" px={3} py={2} bg="bg-deep-graphite" border borderColor="border-charcoal-outline" rounded="lg" color="text-white">
|
|
{preSelectedDriver.name}
|
|
</Stack>
|
|
) : (
|
|
<Select
|
|
value={selectedDriver}
|
|
onChange={(e) => setSelectedDriver(e.target.value)}
|
|
required
|
|
options={[
|
|
{ value: '', label: 'Select driver...' },
|
|
...drivers.map((driver) => ({
|
|
value: driver.id,
|
|
label: driver.name,
|
|
})),
|
|
]}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
|
|
{/* Infraction Type */}
|
|
<Stack>
|
|
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
|
|
Infraction Type
|
|
</Text>
|
|
<Stack display="grid" gridCols={2} gap={2}>
|
|
{INFRACTION_TYPES.map(({ value, label, icon: InfractionIcon }) => (
|
|
<Stack
|
|
key={value}
|
|
as="button"
|
|
type="button"
|
|
onClick={() => setInfractionType(value)}
|
|
display="flex"
|
|
alignItems="center"
|
|
gap={2}
|
|
p={3}
|
|
rounded="lg"
|
|
border
|
|
transition
|
|
borderColor={infractionType === value ? 'border-primary-blue' : 'border-charcoal-outline'}
|
|
bg={infractionType === value ? 'bg-primary-blue/10' : 'bg-deep-graphite'}
|
|
color={infractionType === value ? 'text-primary-blue' : 'text-gray-300'}
|
|
hoverBorderColor={infractionType !== value ? 'border-gray-500' : undefined}
|
|
>
|
|
<Icon icon={InfractionIcon} size={4} />
|
|
<Text size="sm">{label}</Text>
|
|
</Stack>
|
|
))}
|
|
</Stack>
|
|
</Stack>
|
|
|
|
{/* Severity */}
|
|
<Stack>
|
|
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
|
|
Severity
|
|
</Text>
|
|
<Stack gap={2}>
|
|
{SEVERITY_LEVELS.map(({ value, label, description }) => (
|
|
<Stack
|
|
key={value}
|
|
as="button"
|
|
type="button"
|
|
onClick={() => setSeverity(value)}
|
|
w="full"
|
|
textAlign="left"
|
|
p={3}
|
|
rounded="lg"
|
|
border
|
|
transition
|
|
borderColor={severity === value ? 'border-primary-blue' : 'border-charcoal-outline'}
|
|
bg={severity === value ? 'bg-primary-blue/10' : 'bg-deep-graphite'}
|
|
color={severity === value ? 'text-primary-blue' : 'text-gray-300'}
|
|
hoverBorderColor={severity !== value ? 'border-gray-500' : undefined}
|
|
>
|
|
<Text weight="medium" block>{label}</Text>
|
|
<Text size="xs" opacity={0.75} block>{description}</Text>
|
|
</Stack>
|
|
))}
|
|
</Stack>
|
|
</Stack>
|
|
|
|
{/* Notes */}
|
|
<Stack>
|
|
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
|
|
Notes (Optional)
|
|
</Text>
|
|
<TextArea
|
|
value={notes}
|
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNotes(e.target.value)}
|
|
placeholder="Additional details..."
|
|
rows={3}
|
|
/>
|
|
</Stack>
|
|
|
|
{error && (
|
|
<Stack p={3} bg="bg-red-500/10" border borderColor="border-red-500/20" rounded="lg">
|
|
<Text size="sm" color="text-red-400" block>{error}</Text>
|
|
</Stack>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<Stack display="flex" gap={3} pt={4}>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={onClose}
|
|
fullWidth
|
|
disabled={penaltyMutation.isPending}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
fullWidth
|
|
disabled={penaltyMutation.isPending || !selectedRaceId || !selectedDriver || !infractionType || !severity}
|
|
>
|
|
{penaltyMutation.isPending ? 'Applying...' : 'Apply Penalty'}
|
|
</Button>
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
);
|
|
}
|