website refactor
This commit is contained in:
@@ -0,0 +1,808 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Flag,
|
||||
Gavel,
|
||||
Grid3x3,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
Send,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
TrendingDown,
|
||||
User,
|
||||
Video,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
type LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
|
||||
// Shared state components
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||
import { useLeagueAdminStatus } from "@/hooks/league/useLeagueAdminStatus";
|
||||
import { useProtestDetail } from "@/hooks/league/useProtestDetail";
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { GridItem } from '@/ui/GridItem';
|
||||
import { Icon as UIIcon } from '@/ui/Icon';
|
||||
import { Link as UILink } from '@/ui/Link';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
type PenaltyUiConfig = {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
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',
|
||||
defaultValue: 5,
|
||||
},
|
||||
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',
|
||||
defaultValue: 3,
|
||||
},
|
||||
points_deduction: {
|
||||
label: 'Points Deduction',
|
||||
description: 'Deduct championship points',
|
||||
icon: TrendingDown,
|
||||
color: 'text-red-400 bg-red-500/10 border-red-500/20',
|
||||
defaultValue: 5,
|
||||
},
|
||||
disqualification: {
|
||||
label: 'Disqualification',
|
||||
description: 'Disqualify from race',
|
||||
icon: XCircle,
|
||||
color: 'text-red-500 bg-red-500/10 border-red-500/20',
|
||||
defaultValue: 0,
|
||||
},
|
||||
warning: {
|
||||
label: 'Warning',
|
||||
description: 'Official warning only',
|
||||
icon: AlertTriangle,
|
||||
color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
|
||||
defaultValue: 0,
|
||||
},
|
||||
license_points: {
|
||||
label: 'License Points',
|
||||
description: 'Safety rating penalty',
|
||||
icon: ShieldAlert,
|
||||
color: 'text-orange-400 bg-orange-500/10 border-orange-500/20',
|
||||
defaultValue: 2,
|
||||
},
|
||||
};
|
||||
|
||||
export function ProtestDetailPageClient({ initialViewData }: { initialViewData: unknown }) {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const leagueId = params.id as string;
|
||||
const protestId = params.protestId as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const protestService = useInject(PROTEST_SERVICE_TOKEN);
|
||||
|
||||
// Decision state
|
||||
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
|
||||
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
|
||||
const [penaltyType, setPenaltyType] = useState<string>('time_penalty');
|
||||
const [penaltyValue, setPenaltyValue] = useState<number>(5);
|
||||
const [stewardNotes, setStewardNotes] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
|
||||
// Check admin status using hook
|
||||
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId || '');
|
||||
|
||||
// Load protest detail using hook
|
||||
const { data: detail, isLoading: detailLoading, error, retry } = useProtestDetail(leagueId, protestId, isAdmin || false);
|
||||
|
||||
// Use initial data if available
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const protestDetail = (detail || initialViewData) as any;
|
||||
|
||||
// Set initial penalty values when data loads
|
||||
useEffect(() => {
|
||||
if (protestDetail?.initialPenaltyType) {
|
||||
setPenaltyType(protestDetail.initialPenaltyType);
|
||||
setPenaltyValue(protestDetail.initialPenaltyValue);
|
||||
}
|
||||
}, [protestDetail]);
|
||||
|
||||
const penaltyTypes = useMemo(() => {
|
||||
const referenceItems = protestDetail?.penaltyTypes ?? [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return referenceItems.map((ref: any) => {
|
||||
const ui = PENALTY_UI[ref.type] ?? {
|
||||
icon: Gavel,
|
||||
color: 'text-gray-400 bg-gray-500/10 border-gray-500/20',
|
||||
};
|
||||
|
||||
return {
|
||||
...ref,
|
||||
icon: ui.icon,
|
||||
color: ui.color,
|
||||
};
|
||||
});
|
||||
}, [protestDetail?.penaltyTypes]);
|
||||
|
||||
const selectedPenalty = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return penaltyTypes.find((p: any) => p.type === penaltyType);
|
||||
}, [penaltyTypes, penaltyType]);
|
||||
|
||||
const handleSubmitDecision = async () => {
|
||||
if (!decision || !stewardNotes.trim() || !protestDetail || !currentDriverId) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const protest = protestDetail.protest || protestDetail;
|
||||
|
||||
const defaultUpheldReason = protestDetail.defaultReasons?.upheld;
|
||||
const defaultDismissedReason = protestDetail.defaultReasons?.dismissed;
|
||||
|
||||
if (decision === 'uphold') {
|
||||
const requiresValue = selectedPenalty?.requiresValue ?? true;
|
||||
|
||||
const commandModel = new ProtestDecisionCommandModel({
|
||||
decision,
|
||||
penaltyType,
|
||||
penaltyValue,
|
||||
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 || protestDetail.race?.id,
|
||||
protest.accusedDriverId || protestDetail.accusedDriver?.id,
|
||||
currentDriverId,
|
||||
protest.id || protestDetail.protestId,
|
||||
options,
|
||||
);
|
||||
|
||||
const result = await protestService.applyPenalty(penaltyCommand);
|
||||
if (result.isErr()) {
|
||||
throw new Error(result.getError().message);
|
||||
}
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const warningRef = protestDetail.penaltyTypes.find((p: any) => p.type === 'warning');
|
||||
const requiresValue = warningRef?.requiresValue ?? false;
|
||||
|
||||
const commandModel = new ProtestDecisionCommandModel({
|
||||
decision,
|
||||
penaltyType: 'warning',
|
||||
penaltyValue: 0,
|
||||
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 || protestDetail.race?.id,
|
||||
protest.accusedDriverId || protestDetail.accusedDriver?.id,
|
||||
currentDriverId,
|
||||
protest.id || protestDetail.protestId,
|
||||
options,
|
||||
);
|
||||
|
||||
const result = await protestService.applyPenalty(penaltyCommand);
|
||||
if (result.isErr()) {
|
||||
throw new Error(result.getError().message);
|
||||
}
|
||||
}
|
||||
|
||||
router.push(routes.league.stewarding(leagueId));
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to submit decision');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestDefense = async () => {
|
||||
if (!protestDetail || !currentDriverId) return;
|
||||
|
||||
try {
|
||||
// Request defense
|
||||
const result = await protestService.requestDefense({
|
||||
protestId: protestDetail.protest?.id || protestDetail.protestId,
|
||||
stewardId: currentDriverId,
|
||||
});
|
||||
if (result.isErr()) {
|
||||
throw new Error(result.getError().message);
|
||||
}
|
||||
|
||||
// Reload page to show updated status
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to request defense');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return { label: 'Pending Review', bg: 'bg-warning-amber/20', color: 'text-warning-amber', borderColor: 'border-warning-amber/30', icon: Clock };
|
||||
case 'under_review':
|
||||
return { label: 'Under Review', bg: 'bg-blue-500/20', color: 'text-blue-400', borderColor: 'border-blue-500/30', icon: Shield };
|
||||
case 'awaiting_defense':
|
||||
return { label: 'Awaiting Defense', bg: 'bg-purple-500/20', color: 'text-purple-400', borderColor: 'border-purple-500/30', icon: MessageCircle };
|
||||
case 'upheld':
|
||||
return { label: 'Upheld', bg: 'bg-red-500/20', color: 'text-red-400', borderColor: 'border-red-500/30', icon: CheckCircle };
|
||||
case 'dismissed':
|
||||
return { label: 'Dismissed', bg: 'bg-gray-500/20', color: 'text-gray-400', borderColor: 'border-gray-500/30', icon: XCircle };
|
||||
default:
|
||||
return { label: status, bg: 'bg-gray-500/20', color: 'text-gray-400', borderColor: 'border-gray-500/30', icon: AlertCircle };
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading for admin check
|
||||
if (adminLoading) {
|
||||
return <LoadingWrapper variant="full-screen" message="Checking permissions..." />;
|
||||
}
|
||||
|
||||
// Show access denied if not admin
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Card>
|
||||
<Box p={12} textAlign="center">
|
||||
<Box w={16} h={16} mx="auto" mb={4} rounded="full" bg="bg-iron-gray/50" display="flex" alignItems="center" justifyContent="center">
|
||||
<UIIcon icon={AlertTriangle} size={8} color="text-warning-amber" />
|
||||
</Box>
|
||||
<Heading level={3}>Admin Access Required</Heading>
|
||||
<Box mt={2}>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
Only league admins can review protests.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StateContainer
|
||||
data={protestDetail}
|
||||
isLoading={detailLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={{
|
||||
loading: { variant: 'spinner', message: 'Loading protest details...' },
|
||||
error: { variant: 'full-screen' },
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{(pd: any) => {
|
||||
if (!pd) return null;
|
||||
|
||||
const protest = pd.protest || pd;
|
||||
const race = pd.race;
|
||||
const protestingDriver = pd.protestingDriver;
|
||||
const accusedDriver = pd.accusedDriver;
|
||||
|
||||
const statusConfig = getStatusConfig(protest.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const isPending = protest.status === 'pending';
|
||||
const submittedAt = protest.submittedAt || pd.submittedAt;
|
||||
const daysSinceFiled = Math.floor((Date.now() - new Date(submittedAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return (
|
||||
<Box minHeight="100vh">
|
||||
{/* Compact Header */}
|
||||
<Box mb={6}>
|
||||
<Stack direction="row" align="center" gap={3} mb={4}>
|
||||
<UILink href={routes.league.stewarding(leagueId)}>
|
||||
<UIIcon icon={ArrowLeft} size={5} color="text-gray-400" />
|
||||
</UILink>
|
||||
<Stack direction="row" align="center" gap={3} flexGrow={1}>
|
||||
<Heading level={1}>Protest Review</Heading>
|
||||
<Box display="flex" alignItems="center" gap={1.5} px={2.5} py={1} rounded="full" fontSize="0.75rem" weight="medium" border bg={statusConfig.bg} color={statusConfig.color} borderColor={statusConfig.borderColor}>
|
||||
<UIIcon icon={StatusIcon} size={3} />
|
||||
<Text>{statusConfig.label}</Text>
|
||||
</Box>
|
||||
{daysSinceFiled > 2 && isPending && (
|
||||
<Box display="flex" alignItems="center" gap={1} px={2} py={0.5} fontSize="0.75rem" weight="medium" bg="bg-red-500/20" color="text-red-400" rounded="full">
|
||||
<UIIcon icon={AlertTriangle} size={3} />
|
||||
<Text>{daysSinceFiled}d old</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Main Layout: Feed + Sidebar */}
|
||||
<Grid cols={12} gap={6}>
|
||||
{/* Left Sidebar - Incident Info */}
|
||||
<GridItem colSpan={12} lgSpan={3}>
|
||||
<Stack gap={4}>
|
||||
{/* Drivers Involved */}
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Parties Involved</Heading>
|
||||
|
||||
<Stack gap={3}>
|
||||
{/* Protesting Driver */}
|
||||
<UILink href={routes.driver.detail(protestingDriver?.id || '')} block>
|
||||
<Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-blue-500/50" hoverBg="bg-blue-500/5" transition cursor="pointer">
|
||||
<Box w={10} h={10} rounded="full" bg="bg-blue-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<UIIcon icon={User} size={5} color="text-blue-400" />
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth={0}>
|
||||
<Text size="xs" color="text-blue-400" weight="medium" block>Protesting</Text>
|
||||
<Text size="sm" weight="semibold" color="text-white" truncate block>{protestingDriver?.name || 'Unknown'}</Text>
|
||||
</Box>
|
||||
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
|
||||
</Box>
|
||||
</UILink>
|
||||
|
||||
{/* Accused Driver */}
|
||||
<UILink href={routes.driver.detail(accusedDriver?.id || '')} block>
|
||||
<Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-orange-500/50" hoverBg="bg-orange-500/5" transition cursor="pointer">
|
||||
<Box w={10} h={10} rounded="full" bg="bg-orange-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<UIIcon icon={User} size={5} color="text-orange-400" />
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth={0}>
|
||||
<Text size="xs" color="text-orange-400" weight="medium" block>Accused</Text>
|
||||
<Text size="sm" weight="semibold" color="text-white" truncate block>{accusedDriver?.name || 'Unknown'}</Text>
|
||||
</Box>
|
||||
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
|
||||
</Box>
|
||||
</UILink>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Race Info */}
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Race Details</Heading>
|
||||
|
||||
<UILink
|
||||
href={routes.race.detail(race?.id || '')}
|
||||
block
|
||||
mb={3}
|
||||
>
|
||||
<Box p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-primary-blue/50" hoverBg="bg-primary-blue/5" transition>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Text size="sm" weight="medium" color="text-white">{race?.name || 'Unknown Race'}</Text>
|
||||
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
|
||||
</Box>
|
||||
</Box>
|
||||
</UILink>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<UIIcon icon={MapPin} size={4} color="text-gray-500" />
|
||||
<Text size="sm" color="text-gray-300">{race?.name || 'Unknown Track'}</Text>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<UIIcon icon={Calendar} size={4} color="text-gray-500" />
|
||||
<Text size="sm" color="text-gray-300">{race?.formattedDate || (race?.scheduledAt ? new Date(race.scheduledAt).toLocaleDateString() : 'Unknown Date')}</Text>
|
||||
</Box>
|
||||
{protest.incident?.lap && (
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<UIIcon icon={Flag} size={4} color="text-gray-500" />
|
||||
<Text size="sm" color="text-gray-300">Lap {protest.incident.lap}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{protest.proofVideoUrl && (
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Evidence</Heading>
|
||||
<UILink
|
||||
href={protest.proofVideoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
block
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={2} p={3} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" color="text-primary-blue" hoverBg="bg-primary-blue/20" transition>
|
||||
<UIIcon icon={Video} size={4} />
|
||||
<Text size="sm" weight="medium" flexGrow={1}>Watch Video</Text>
|
||||
<UIIcon icon={ExternalLink} size={3} />
|
||||
</Box>
|
||||
</UILink>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Timeline</Heading>
|
||||
<Stack gap={2}>
|
||||
<Box display="flex" justifyContent="between">
|
||||
<Text size="sm" color="text-gray-500">Filed</Text>
|
||||
<Text size="sm" color="text-gray-300">{new Date(submittedAt).toLocaleDateString()}</Text>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="between">
|
||||
<Text size="sm" color="text-gray-500">Age</Text>
|
||||
<Text size="sm" color={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</Text>
|
||||
</Box>
|
||||
{protest.reviewedAt && (
|
||||
<Box display="flex" justifyContent="between">
|
||||
<Text size="sm" color="text-gray-500">Resolved</Text>
|
||||
<Text size="sm" color="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
</Stack>
|
||||
</GridItem>
|
||||
|
||||
{/* Center - Discussion Feed */}
|
||||
<GridItem colSpan={12} lgSpan={6}>
|
||||
<Stack gap={4}>
|
||||
{/* Timeline / Feed */}
|
||||
<Card>
|
||||
<Box borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/30" p={4}>
|
||||
<Heading level={2}>Discussion</Heading>
|
||||
</Box>
|
||||
|
||||
<Stack gap={0}>
|
||||
{/* Initial Protest Filing */}
|
||||
<Box p={4}>
|
||||
<Box display="flex" gap={3}>
|
||||
<Box w={10} h={10} rounded="full" bg="bg-blue-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<UIIcon icon={AlertCircle} size={5} color="text-blue-400" />
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth={0}>
|
||||
<Box display="flex" alignItems="center" gap={2} mb={1}>
|
||||
<Text weight="semibold" color="text-white" size="sm">{protestingDriver?.name || 'Unknown'}</Text>
|
||||
<Text size="xs" color="text-blue-400" weight="medium">filed protest</Text>
|
||||
<Text size="xs" color="text-gray-500">•</Text>
|
||||
<Text size="xs" color="text-gray-500">{new Date(submittedAt).toLocaleString()}</Text>
|
||||
</Box>
|
||||
|
||||
<Box bg="bg-deep-graphite" rounded="lg" p={4} border borderColor="border-charcoal-outline">
|
||||
<Text size="sm" color="text-gray-300" block mb={3}>{protest.description || pd.incident?.description}</Text>
|
||||
|
||||
{(protest.comment || pd.comment) && (
|
||||
<Box mt={3} pt={3} borderTop borderColor="border-charcoal-outline/50">
|
||||
<Text size="xs" color="text-gray-500" block mb={1}>Additional details:</Text>
|
||||
<Text size="sm" color="text-gray-400">{protest.comment || pd.comment}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Defense placeholder */}
|
||||
{protest.status === 'awaiting_defense' && (
|
||||
<Box p={4} bg="bg-purple-500/5">
|
||||
<Box display="flex" gap={3}>
|
||||
<Box w={10} h={10} rounded="full" bg="bg-purple-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<UIIcon icon={MessageCircle} size={5} color="text-purple-400" />
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text size="sm" color="text-purple-400" weight="medium" block mb={1}>Defense Requested</Text>
|
||||
<Text size="sm" color="text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Decision (if resolved) */}
|
||||
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
|
||||
<Box p={4} bg={protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}>
|
||||
<Box display="flex" gap={3}>
|
||||
<Box w={10} h={10} rounded="full" display="flex" alignItems="center" justifyContent="center" flexShrink={0} bg={protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'}>
|
||||
<UIIcon icon={Gavel} size={5} color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'} />
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth={0}>
|
||||
<Box display="flex" alignItems="center" gap={2} mb={1}>
|
||||
<Text weight="semibold" color="text-white" size="sm">Steward Decision</Text>
|
||||
<Text size="xs" weight="medium" color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}>
|
||||
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
|
||||
</Text>
|
||||
{protest.reviewedAt && (
|
||||
<>
|
||||
<Text size="xs" color="text-gray-500">•</Text>
|
||||
<Text size="xs" color="text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box rounded="lg" p={4} border bg={protest.status === 'upheld' ? 'bg-red-500/10' : 'bg-gray-500/10'} borderColor={protest.status === 'upheld' ? 'border-red-500/20' : 'border-gray-500/20'}>
|
||||
<Text size="sm" color="text-gray-300">{protest.decisionNotes}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Add Comment */}
|
||||
{isPending && (
|
||||
<Box p={4} borderTop borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
|
||||
<Box display="flex" gap={3}>
|
||||
<Box w={10} h={10} rounded="full" bg="bg-iron-gray" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<UIIcon icon={User} size={5} color="text-gray-500" />
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Box as="textarea"
|
||||
value={newComment}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewComment(e.target.value)}
|
||||
placeholder="Add a comment or request more information..."
|
||||
rows={2}
|
||||
w="full"
|
||||
px={4}
|
||||
py={3}
|
||||
bg="bg-deep-graphite"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="lg"
|
||||
color="text-white"
|
||||
fontSize="sm"
|
||||
/>
|
||||
<Box display="flex" justifyContent="end" mt={2}>
|
||||
<Button variant="secondary" disabled={!newComment.trim()}>
|
||||
<UIIcon icon={Send} size={3} mr={1} />
|
||||
Comment
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</GridItem>
|
||||
|
||||
{/* Right Sidebar - Actions */}
|
||||
<GridItem colSpan={12} lgSpan={3}>
|
||||
<Stack gap={4}>
|
||||
{isPending && (
|
||||
<>
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Actions</Heading>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={handleRequestDefense}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<UIIcon icon={MessageCircle} size={4} />
|
||||
<Text>Request Defense</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2} fullWidth>
|
||||
<UIIcon icon={Gavel} size={4} />
|
||||
<Text>Make Decision</Text>
|
||||
<Box ml="auto" transition transform={showDecisionPanel ? 'rotate(180deg)' : 'none'}>
|
||||
<UIIcon icon={ChevronDown} size={4} />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Decision Panel */}
|
||||
{showDecisionPanel && (
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Stewarding Decision</Heading>
|
||||
|
||||
{/* Decision Selection */}
|
||||
<Grid cols={2} gap={2} mb={4}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDecision('uphold')}
|
||||
p={3}
|
||||
border
|
||||
borderColor={decision === 'uphold' ? 'border-racing-red' : 'border-charcoal-outline'}
|
||||
bg={decision === 'uphold' ? 'bg-racing-red/10' : 'transparent'}
|
||||
>
|
||||
<Stack align="center" gap={1}>
|
||||
<UIIcon icon={CheckCircle} size={5} color={decision === 'uphold' ? 'text-red-400' : 'text-gray-500'} />
|
||||
<Text size="xs" weight="medium" color={decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}>Uphold</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDecision('dismiss')}
|
||||
p={3}
|
||||
border
|
||||
borderColor={decision === 'dismiss' ? 'border-gray-500' : 'border-charcoal-outline'}
|
||||
bg={decision === 'dismiss' ? 'bg-gray-500/10' : 'transparent'}
|
||||
>
|
||||
<Stack align="center" gap={1}>
|
||||
<UIIcon icon={XCircle} size={5} color={decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'} />
|
||||
<Text size="xs" weight="medium" color={decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}>Dismiss</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
{/* Penalty Selection (if upholding) */}
|
||||
{decision === 'uphold' && (
|
||||
<Box mb={4}>
|
||||
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={2}>Penalty Type</Text>
|
||||
|
||||
{penaltyTypes.length === 0 ? (
|
||||
<Text size="xs" color="text-gray-500">
|
||||
Loading penalty types...
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Grid cols={2} gap={2}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{penaltyTypes.map((penalty: any) => {
|
||||
const Icon = penalty.icon;
|
||||
const isSelected = penaltyType === penalty.type;
|
||||
return (
|
||||
<Button
|
||||
key={penalty.type}
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setPenaltyType(penalty.type);
|
||||
setPenaltyValue(penalty.defaultValue);
|
||||
}}
|
||||
p={2}
|
||||
border
|
||||
borderColor={isSelected ? undefined : 'border-charcoal-outline'}
|
||||
bg={isSelected ? undefined : 'bg-iron-gray/30'}
|
||||
color={isSelected ? penalty.color : undefined}
|
||||
title={penalty.description}
|
||||
>
|
||||
<Stack align="start" gap={0.5}>
|
||||
<UIIcon icon={Icon} size={3.5} color={isSelected ? undefined : 'text-gray-500'} />
|
||||
<Text size="xs" weight="medium" fontSize="10px" color={isSelected ? undefined : 'text-gray-500'}>
|
||||
{penalty.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
|
||||
{selectedPenalty?.requiresValue && (
|
||||
<Box mt={3}>
|
||||
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1}>
|
||||
Value ({selectedPenalty.valueLabel})
|
||||
</Text>
|
||||
<Box as="input"
|
||||
type="number"
|
||||
value={penaltyValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPenaltyValue(Number(e.target.value))}
|
||||
min="1"
|
||||
w="full"
|
||||
px={3}
|
||||
py={2}
|
||||
bg="bg-deep-graphite"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="lg"
|
||||
color="text-white"
|
||||
fontSize="sm"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Steward Notes */}
|
||||
<Box mb={4}>
|
||||
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1}>Decision Reasoning *</Text>
|
||||
<Box as="textarea"
|
||||
value={stewardNotes}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setStewardNotes(e.target.value)}
|
||||
placeholder="Explain your decision..."
|
||||
rows={4}
|
||||
w="full"
|
||||
px={3}
|
||||
py={2}
|
||||
bg="bg-deep-graphite"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="lg"
|
||||
color="text-white"
|
||||
fontSize="sm"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={handleSubmitDecision}
|
||||
disabled={!decision || !stewardNotes.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Decision'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Already Resolved Info */}
|
||||
{!isPending && (
|
||||
<Card>
|
||||
<Box p={4} textAlign="center">
|
||||
<Box py={4} color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}>
|
||||
<UIIcon icon={Gavel} size={8} mx="auto" mb={2} />
|
||||
<Text weight="semibold" block>Case Closed</Text>
|
||||
<Text size="xs" color="text-gray-500" block mt={1}>
|
||||
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,737 +1,25 @@
|
||||
'use client';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LeagueProtestDetailPageQuery } from '@/lib/page-queries/LeagueProtestDetailPageQuery';
|
||||
import { ProtestDetailPageClient } from './ProtestDetailPageClient';
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import type { ProtestDetailViewModel } from '@/lib/view-models/ProtestDetailViewModel';
|
||||
import { ProtestDriverViewModel } from '@/lib/view-models/ProtestDriverViewModel';
|
||||
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Flag,
|
||||
Gavel,
|
||||
Grid3x3,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
Send,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
TrendingDown,
|
||||
User,
|
||||
Video,
|
||||
XCircle
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
// Shared state components
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||
import { useLeagueAdminStatus } from "@/lib/hooks/league/useLeagueAdminStatus";
|
||||
import { useProtestDetail } from "@/lib/hooks/league/useProtestDetail";
|
||||
|
||||
// Timeline event types
|
||||
interface TimelineEvent {
|
||||
id: string;
|
||||
type: 'protest_filed' | 'defense_requested' | 'defense_submitted' | 'steward_comment' | 'decision' | 'penalty_applied';
|
||||
timestamp: Date;
|
||||
actor: ProtestDriverViewModel | null;
|
||||
content: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
interface Props {
|
||||
params: Promise<{ id: string; protestId: string }>;
|
||||
}
|
||||
|
||||
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',
|
||||
defaultValue: 5,
|
||||
},
|
||||
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',
|
||||
defaultValue: 3,
|
||||
},
|
||||
points_deduction: {
|
||||
label: 'Points Deduction',
|
||||
description: 'Deduct championship points',
|
||||
icon: TrendingDown,
|
||||
color: 'text-red-400 bg-red-500/10 border-red-500/20',
|
||||
defaultValue: 5,
|
||||
},
|
||||
disqualification: {
|
||||
label: 'Disqualification',
|
||||
description: 'Disqualify from race',
|
||||
icon: XCircle,
|
||||
color: 'text-red-500 bg-red-500/10 border-red-500/20',
|
||||
defaultValue: 0,
|
||||
},
|
||||
warning: {
|
||||
label: 'Warning',
|
||||
description: 'Official warning only',
|
||||
icon: AlertTriangle,
|
||||
color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
|
||||
defaultValue: 0,
|
||||
},
|
||||
license_points: {
|
||||
label: 'License Points',
|
||||
description: 'Safety rating penalty',
|
||||
icon: ShieldAlert,
|
||||
color: 'text-orange-400 bg-orange-500/10 border-orange-500/20',
|
||||
defaultValue: 2,
|
||||
},
|
||||
};
|
||||
|
||||
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 = useInject(PROTEST_SERVICE_TOKEN);
|
||||
|
||||
// Decision state
|
||||
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
|
||||
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
|
||||
const [penaltyType, setPenaltyType] = useState<string>('time_penalty');
|
||||
const [penaltyValue, setPenaltyValue] = useState<number>(5);
|
||||
const [stewardNotes, setStewardNotes] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
|
||||
// Check admin status using hook
|
||||
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId || '');
|
||||
|
||||
// Load protest detail using hook
|
||||
const { data: detail, isLoading: detailLoading, error, retry } = useProtestDetail(leagueId, protestId, isAdmin || false);
|
||||
|
||||
// Set initial penalty values when data loads
|
||||
useMemo(() => {
|
||||
if (detail?.initialPenaltyType) {
|
||||
setPenaltyType(detail.initialPenaltyType);
|
||||
setPenaltyValue(detail.initialPenaltyValue);
|
||||
export default async function Page({ params }: Props) {
|
||||
const { id, protestId } = await params;
|
||||
|
||||
// Execute the PageQuery
|
||||
const result = await LeagueProtestDetailPageQuery.execute({ leagueId: id, protestId });
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
if (error === 'notFound') {
|
||||
notFound();
|
||||
}
|
||||
}, [detail]);
|
||||
|
||||
const penaltyTypes = useMemo(() => {
|
||||
const referenceItems = detail?.penaltyTypes ?? [];
|
||||
return referenceItems.map((ref) => {
|
||||
const ui = PENALTY_UI[ref.type] ?? {
|
||||
icon: Gavel,
|
||||
color: 'text-gray-400 bg-gray-500/10 border-gray-500/20',
|
||||
};
|
||||
|
||||
return {
|
||||
...ref,
|
||||
icon: ui.icon,
|
||||
color: ui.color,
|
||||
};
|
||||
});
|
||||
}, [detail?.penaltyTypes]);
|
||||
|
||||
const selectedPenalty = useMemo(() => {
|
||||
return penaltyTypes.find((p) => p.type === penaltyType);
|
||||
}, [penaltyTypes, penaltyType]);
|
||||
|
||||
const handleSubmitDecision = async () => {
|
||||
if (!decision || !stewardNotes.trim() || !detail || !currentDriverId) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const protest = detail.protest;
|
||||
|
||||
const defaultUpheldReason = detail.defaultReasons?.upheld;
|
||||
const defaultDismissedReason = detail.defaultReasons?.dismissed;
|
||||
|
||||
if (decision === 'uphold') {
|
||||
const requiresValue = selectedPenalty?.requiresValue ?? true;
|
||||
|
||||
const commandModel = new ProtestDecisionCommandModel({
|
||||
decision,
|
||||
penaltyType,
|
||||
penaltyValue,
|
||||
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,
|
||||
options,
|
||||
);
|
||||
|
||||
await protestService.applyPenalty(penaltyCommand);
|
||||
} else {
|
||||
const warningRef = detail.penaltyTypes.find((p) => p.type === 'warning');
|
||||
const requiresValue = warningRef?.requiresValue ?? false;
|
||||
|
||||
const commandModel = new ProtestDecisionCommandModel({
|
||||
decision,
|
||||
penaltyType: 'warning',
|
||||
penaltyValue: 0,
|
||||
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,
|
||||
options,
|
||||
);
|
||||
|
||||
await protestService.applyPenalty(penaltyCommand);
|
||||
}
|
||||
|
||||
router.push(`/leagues/${leagueId}/stewarding`);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to submit decision');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestDefense = async () => {
|
||||
if (!detail || !currentDriverId) return;
|
||||
|
||||
try {
|
||||
// Request defense
|
||||
await protestService.requestDefense({
|
||||
protestId: detail.protest.id,
|
||||
stewardId: currentDriverId,
|
||||
});
|
||||
|
||||
// Reload page to show updated status
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to request defense');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return { label: 'Pending Review', color: 'bg-warning-amber/20 text-warning-amber border-warning-amber/30', icon: Clock };
|
||||
case 'under_review':
|
||||
return { label: 'Under Review', color: 'bg-blue-500/20 text-blue-400 border-blue-500/30', icon: Shield };
|
||||
case 'awaiting_defense':
|
||||
return { label: 'Awaiting Defense', color: 'bg-purple-500/20 text-purple-400 border-purple-500/30', icon: MessageCircle };
|
||||
case 'upheld':
|
||||
return { label: 'Upheld', color: 'bg-red-500/20 text-red-400 border-red-500/30', icon: CheckCircle };
|
||||
case 'dismissed':
|
||||
return { label: 'Dismissed', color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: XCircle };
|
||||
default:
|
||||
return { label: status, color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: AlertCircle };
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading for admin check
|
||||
if (adminLoading) {
|
||||
return <LoadingWrapper variant="full-screen" message="Checking permissions..." />;
|
||||
}
|
||||
|
||||
// Show access denied if not admin
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
|
||||
<AlertTriangle className="w-8 h-8 text-warning-amber" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Only league admins can review protests.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
const viewData = result.isOk() ? result.unwrap() : null;
|
||||
|
||||
return (
|
||||
<StateContainer
|
||||
data={detail}
|
||||
isLoading={detailLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={{
|
||||
loading: { variant: 'spinner', message: 'Loading protest details...' },
|
||||
error: { variant: 'full-screen' },
|
||||
}}
|
||||
>
|
||||
{(protestDetail) => {
|
||||
if (!protestDetail) return null;
|
||||
|
||||
const protest = protestDetail.protest;
|
||||
const race = protestDetail.race;
|
||||
const protestingDriver = protestDetail.protestingDriver;
|
||||
const accusedDriver = protestDetail.accusedDriver;
|
||||
|
||||
const statusConfig = getStatusConfig(protest.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const isPending = protest.status === 'pending';
|
||||
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.submittedAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Compact Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link href={`/leagues/${leagueId}/stewarding`} className="text-gray-400 hover:text-white transition-colors">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-white">Protest Review</h1>
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${statusConfig.color}`}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{statusConfig.label}
|
||||
</div>
|
||||
{daysSinceFiled > 2 && isPending && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{daysSinceFiled}d old
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Layout: Feed + Sidebar */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* Left Sidebar - Incident Info */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{/* Drivers Involved */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Parties Involved</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Protesting Driver */}
|
||||
<Link href={`/drivers/${protestingDriver?.id || ''}`} className="block">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors cursor-pointer">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-blue-400 font-medium">Protesting</p>
|
||||
<p className="text-sm font-semibold text-white truncate">{protestingDriver?.name || 'Unknown'}</p>
|
||||
</div>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Accused Driver */}
|
||||
<Link href={`/drivers/${accusedDriver?.id || ''}`} className="block">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-orange-500/50 hover:bg-orange-500/5 transition-colors cursor-pointer">
|
||||
<div className="w-10 h-10 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-orange-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-orange-400 font-medium">Accused</p>
|
||||
<p className="text-sm font-semibold text-white truncate">{accusedDriver?.name || 'Unknown'}</p>
|
||||
</div>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Race Info */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Race Details</h3>
|
||||
|
||||
<Link
|
||||
href={`/races/${race.id}`}
|
||||
className="block mb-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/50 hover:bg-primary-blue/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-white">{race.name}</span>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MapPin className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">{race.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">{race.formattedDate}</span>
|
||||
</div>
|
||||
{protest.incident?.lap && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Flag className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">Lap {protest.incident.lap}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{protest.proofVideoUrl && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
|
||||
<a
|
||||
href={protest.proofVideoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 p-3 rounded-lg bg-primary-blue/10 border border-primary-blue/20 text-primary-blue hover:bg-primary-blue/20 transition-colors"
|
||||
>
|
||||
<Video className="w-4 h-4" />
|
||||
<span className="text-sm font-medium flex-1">Watch Video</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Timeline</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Filed</span>
|
||||
<span className="text-gray-300">{new Date(protest.submittedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Age</span>
|
||||
<span className={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</span>
|
||||
</div>
|
||||
{protest.reviewedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Resolved</span>
|
||||
<span className="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Center - Discussion Feed */}
|
||||
<div className="lg:col-span-6 space-y-4">
|
||||
{/* Timeline / Feed */}
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<div className="p-4 border-b border-charcoal-outline bg-iron-gray/30">
|
||||
<h2 className="text-sm font-semibold text-white">Discussion</h2>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
{/* Initial Protest Filing */}
|
||||
<div className="p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<AlertCircle className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-white text-sm">{protestingDriver?.name || 'Unknown'}</span>
|
||||
<span className="text-xs text-blue-400 font-medium">filed protest</span>
|
||||
<span className="text-xs text-gray-500">•</span>
|
||||
<span className="text-xs text-gray-500">{new Date(protest.submittedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-sm text-gray-300 mb-3">{protest.description}</p>
|
||||
|
||||
{protest.comment && (
|
||||
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||
<p className="text-xs text-gray-500 mb-1">Additional details:</p>
|
||||
<p className="text-sm text-gray-400">{protest.comment}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Defense placeholder - will be populated when defense system is implemented */}
|
||||
{protest.status === 'awaiting_defense' && (
|
||||
<div className="p-4 bg-purple-500/5">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<MessageCircle className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-purple-400 font-medium mb-1">Defense Requested</p>
|
||||
<p className="text-sm text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decision (if resolved) */}
|
||||
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
|
||||
<div className={`p-4 ${protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}`}>
|
||||
<div className="flex gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'
|
||||
}`}>
|
||||
<Gavel className={`w-5 h-5 ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-white text-sm">Steward Decision</span>
|
||||
<span className={`text-xs font-medium ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`}>
|
||||
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
|
||||
</span>
|
||||
{protest.reviewedAt && (
|
||||
<>
|
||||
<span className="text-xs text-gray-500">•</span>
|
||||
<span className="text-xs text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`rounded-lg p-4 border ${
|
||||
protest.status === 'upheld'
|
||||
? 'bg-red-500/10 border-red-500/20'
|
||||
: 'bg-gray-500/10 border-gray-500/20'
|
||||
}`}>
|
||||
<p className="text-sm text-gray-300">{protest.decisionNotes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Comment (future feature) */}
|
||||
{isPending && (
|
||||
<div className="p-4 border-t border-charcoal-outline bg-iron-gray/20">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-gray-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Add a comment or request more information..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue text-sm resize-none"
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button variant="secondary" disabled={!newComment.trim()}>
|
||||
<Send className="w-3 h-3 mr-1" />
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Actions */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{isPending && (
|
||||
<>
|
||||
{/* Quick Actions */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Actions</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full justify-start"
|
||||
onClick={handleRequestDefense}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 mr-2" />
|
||||
Request Defense
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
|
||||
>
|
||||
<Gavel className="w-4 h-4 mr-2" />
|
||||
Make Decision
|
||||
<ChevronDown className={`w-4 h-4 ml-auto transition-transform ${showDecisionPanel ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Decision Panel */}
|
||||
{showDecisionPanel && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Stewarding Decision</h3>
|
||||
|
||||
{/* Decision Selection */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setDecision('uphold')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
decision === 'uphold'
|
||||
? 'border-red-500 bg-red-500/10'
|
||||
: 'border-charcoal-outline hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'uphold' ? 'text-red-400' : 'text-gray-500'}`} />
|
||||
<p className={`text-xs font-medium ${decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}`}>Uphold</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDecision('dismiss')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
decision === 'dismiss'
|
||||
? 'border-gray-500 bg-gray-500/10'
|
||||
: 'border-charcoal-outline hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<XCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'}`} />
|
||||
<p className={`text-xs font-medium ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}`}>Dismiss</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Penalty Selection (if upholding) */}
|
||||
{decision === 'uphold' && (
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Steward Notes */}
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-medium text-gray-400 mb-1 block">Decision Reasoning *</label>
|
||||
<textarea
|
||||
value={stewardNotes}
|
||||
onChange={(e) => setStewardNotes(e.target.value)}
|
||||
placeholder="Explain your decision..."
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 text-sm focus:outline-none focus:border-primary-blue resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleSubmitDecision}
|
||||
disabled={!decision || !stewardNotes.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Decision'}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Already Resolved Info */}
|
||||
{!isPending && (
|
||||
<Card className="p-4">
|
||||
<div className={`text-center py-4 ${
|
||||
protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'
|
||||
}`}>
|
||||
<Gavel className="w-8 h-8 mx-auto mb-2" />
|
||||
<p className="font-semibold">Case Closed</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</StateContainer>
|
||||
);
|
||||
return <ProtestDetailPageClient initialViewData={viewData} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user