Files
gridpilot.gg/apps/website/client-wrapper/ProtestDetailPageClient.tsx
2026-01-19 01:45:41 +01:00

810 lines
38 KiB
TypeScript

'use client';
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
import { useInject } from '@/lib/di/hooks/useInject';
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import {
AlertCircle,
AlertTriangle,
ArrowLeft,
Calendar,
CheckCircle,
ChevronDown,
Clock,
ExternalLink,
Flag,
Gavel,
Grid3x3,
MapPin,
MessageCircle,
Send,
Shield,
ShieldAlert,
TrendingDown,
User,
Video,
XCircle,
type LucideIcon
} from 'lucide-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
// Shared state components
import { LoadingWrapper } from '@/ui/LoadingWrapper';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useLeagueAdminStatus } from "@/hooks/league/useLeagueAdminStatus";
import { useProtestDetail } from "@/hooks/league/useProtestDetail";
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Icon as UIIcon } from '@/ui/Icon';
import { Link as UILink } from '@/ui/Link';
import { Grid } from '@/ui/Grid';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
const GridItem = ({ children, colSpan, lgSpan }: { children: React.ReactNode; colSpan?: number; lgSpan?: number }) => (
<Box gridCols={colSpan} style={{ gridColumn: `span ${colSpan} / span ${colSpan}` }} className={lgSpan ? `lg:col-span-${lgSpan}` : ''}>
{children}
</Box>
);
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>
<Box marginBottom={3}>
<UILink
href={routes.race.detail(race?.id || '')}
block
>
<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>
</Box>
<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..."
style={{ height: '4rem' }}
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}>
<Box padding={3} border borderColor={decision === 'uphold' ? 'border-racing-red' : 'border-charcoal-outline'} bg={decision === 'uphold' ? 'bg-racing-red/10' : 'transparent'} rounded="lg">
<Button
variant="ghost"
onClick={() => setDecision('uphold')}
fullWidth
>
<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>
</Box>
<Box padding={3} border borderColor={decision === 'dismiss' ? 'border-gray-500' : 'border-charcoal-outline'} bg={decision === 'dismiss' ? 'bg-gray-500/10' : 'transparent'} rounded="lg">
<Button
variant="ghost"
onClick={() => setDecision('dismiss')}
fullWidth
>
<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>
</Box>
</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 (
<Box key={penalty.type} padding={2} border borderColor={isSelected ? undefined : 'border-charcoal-outline'} bg={isSelected ? undefined : 'bg-iron-gray/30'} rounded="lg">
<Button
variant="ghost"
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
fullWidth
title={penalty.description}
>
<Stack align="start" gap={0.5}>
<UIIcon icon={Icon} size={3.5} color={isSelected ? penalty.color : 'text-gray-500'} />
<Text size="xs" weight="medium" fontSize="10px" color={isSelected ? penalty.color : 'text-gray-500'}>
{penalty.label}
</Text>
</Stack>
</Button>
</Box>
);
})}
</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..."
style={{ height: '8rem' }}
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>
);
}