error and load state

This commit is contained in:
2026-01-06 11:05:16 +01:00
parent 4a1bfa57a3
commit 6aad7897db
29 changed files with 5172 additions and 1462 deletions

View File

@@ -32,7 +32,12 @@ import {
} from 'lucide-react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
// Timeline event types
interface TimelineEvent {
@@ -105,10 +110,6 @@ export default function ProtestReviewPage() {
const currentDriverId = useEffectiveDriverId();
const { leagueStewardingService, protestService, leagueMembershipService } = useServices();
const [detail, setDetail] = useState<ProtestDetailViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
// Decision state
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
@@ -116,6 +117,30 @@ export default function ProtestReviewPage() {
const [penaltyValue, setPenaltyValue] = useState<number>(5);
const [stewardNotes, setStewardNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [newComment, setNewComment] = useState('');
// Check admin status
const { data: isAdmin, isLoading: adminLoading } = useDataFetching({
queryKey: ['leagueMembership', leagueId, currentDriverId],
queryFn: async () => {
await leagueMembershipService.fetchLeagueMemberships(leagueId);
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
},
});
// Load protest detail
const { data: detail, isLoading: detailLoading, error, retry } = useDataFetching({
queryKey: ['protestDetail', leagueId, protestId],
queryFn: () => leagueStewardingService.getProtestDetailViewModel(leagueId, protestId),
enabled: !!isAdmin,
onSuccess: (protestDetail) => {
if (protestDetail.initialPenaltyType) {
setPenaltyType(protestDetail.initialPenaltyType);
setPenaltyValue(protestDetail.initialPenaltyValue);
}
},
});
const penaltyTypes = useMemo(() => {
const referenceItems = detail?.penaltyTypes ?? [];
@@ -136,45 +161,6 @@ export default function ProtestReviewPage() {
const selectedPenalty = useMemo(() => {
return penaltyTypes.find((p) => p.type === penaltyType);
}, [penaltyTypes, penaltyType]);
// Comment state
const [newComment, setNewComment] = useState('');
useEffect(() => {
async function checkAdmin() {
await leagueMembershipService.fetchLeagueMemberships(leagueId);
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
}
checkAdmin();
}, [leagueId, currentDriverId, leagueMembershipService]);
useEffect(() => {
async function loadProtest() {
setLoading(true);
try {
const protestDetail = await leagueStewardingService.getProtestDetailViewModel(leagueId, protestId);
setDetail(protestDetail);
if (protestDetail.initialPenaltyType) {
setPenaltyType(protestDetail.initialPenaltyType);
setPenaltyValue(protestDetail.initialPenaltyValue);
}
} catch (err) {
console.error('Failed to load protest:', err);
alert('Failed to load protest details');
router.push(`/leagues/${leagueId}/stewarding`);
} finally {
setLoading(false);
}
}
if (isAdmin) {
loadProtest();
}
}, [protestId, leagueId, isAdmin, router, leagueStewardingService]);
const handleSubmitDecision = async () => {
if (!decision || !stewardNotes.trim() || !detail) return;
@@ -295,6 +281,12 @@ export default function ProtestReviewPage() {
}
};
// 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>
@@ -311,435 +303,440 @@ export default function ProtestReviewPage() {
);
}
if (loading || !detail) {
return (
<Card>
<div className="text-center py-12">
<div className="animate-pulse text-gray-400">Loading protest details...</div>
</div>
</Card>
);
}
const protest = detail.protest;
const race = detail.race;
const protestingDriver = detail.protestingDriver;
const accusedDriver = detail.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>
<StateContainer
data={detail}
isLoading={detailLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading protest details...' },
error: { variant: 'full-screen' },
}}
>
{(protestDetail) => {
const protest = protestDetail.protest;
const race = protestDetail.race;
const protestingDriver = protestDetail.protestingDriver;
const accusedDriver = protestDetail.accusedDriver;
{/* 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" />
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>
<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" />
{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>
</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 && (
{/* 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">Stewarding Decision</h3>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Parties Involved</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'
}`}
<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"
>
<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>
<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>
{/* 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 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="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 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>
{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"
/>
{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>
)}
{/* 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>
{/* 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>
);
}