762 lines
31 KiB
TypeScript
762 lines
31 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useMemo } from 'react';
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import Card from '@/components/ui/Card';
|
|
import Button from '@/components/ui/Button';
|
|
import {
|
|
getProtestRepository,
|
|
getRaceRepository,
|
|
getDriverRepository,
|
|
getLeagueMembershipRepository,
|
|
getReviewProtestUseCase,
|
|
getApplyPenaltyUseCase,
|
|
getRequestProtestDefenseUseCase,
|
|
getSendNotificationUseCase
|
|
} from '@/lib/di-container';
|
|
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
|
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
|
import type { Protest } from '@gridpilot/racing/domain/entities/Protest';
|
|
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
|
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
|
import type { PenaltyType } from '@gridpilot/racing/domain/entities/Penalty';
|
|
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
|
import {
|
|
AlertCircle,
|
|
Video,
|
|
Clock,
|
|
Grid3x3,
|
|
TrendingDown,
|
|
XCircle,
|
|
CheckCircle,
|
|
ArrowLeft,
|
|
Flag,
|
|
AlertTriangle,
|
|
ShieldAlert,
|
|
Ban,
|
|
DollarSign,
|
|
FileWarning,
|
|
User,
|
|
Calendar,
|
|
MapPin,
|
|
MessageCircle,
|
|
Shield,
|
|
Gavel,
|
|
Send,
|
|
ChevronDown,
|
|
ExternalLink
|
|
} from 'lucide-react';
|
|
|
|
// Timeline event types
|
|
interface TimelineEvent {
|
|
id: string;
|
|
type: 'protest_filed' | 'defense_requested' | 'defense_submitted' | 'steward_comment' | 'decision' | 'penalty_applied';
|
|
timestamp: Date;
|
|
actor: DriverDTO | null;
|
|
content: string;
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
const PENALTY_TYPES = [
|
|
{
|
|
type: 'time_penalty' as PenaltyType,
|
|
label: 'Time Penalty',
|
|
description: 'Add seconds to race result',
|
|
icon: Clock,
|
|
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
|
|
requiresValue: true,
|
|
valueLabel: 'seconds',
|
|
defaultValue: 5
|
|
},
|
|
{
|
|
type: 'grid_penalty' as PenaltyType,
|
|
label: 'Grid Penalty',
|
|
description: 'Grid positions for next race',
|
|
icon: Grid3x3,
|
|
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
|
|
requiresValue: true,
|
|
valueLabel: 'positions',
|
|
defaultValue: 3
|
|
},
|
|
{
|
|
type: 'points_deduction' as PenaltyType,
|
|
label: 'Points Deduction',
|
|
description: 'Deduct championship points',
|
|
icon: TrendingDown,
|
|
color: 'text-red-400 bg-red-500/10 border-red-500/20',
|
|
requiresValue: true,
|
|
valueLabel: 'points',
|
|
defaultValue: 5
|
|
},
|
|
{
|
|
type: 'disqualification' as PenaltyType,
|
|
label: 'Disqualification',
|
|
description: 'Disqualify from race',
|
|
icon: XCircle,
|
|
color: 'text-red-500 bg-red-500/10 border-red-500/20',
|
|
requiresValue: false,
|
|
valueLabel: '',
|
|
defaultValue: 0
|
|
},
|
|
{
|
|
type: 'warning' as PenaltyType,
|
|
label: 'Warning',
|
|
description: 'Official warning only',
|
|
icon: AlertTriangle,
|
|
color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
|
|
requiresValue: false,
|
|
valueLabel: '',
|
|
defaultValue: 0
|
|
},
|
|
{
|
|
type: 'license_points' as PenaltyType,
|
|
label: 'License Points',
|
|
description: 'Safety rating penalty',
|
|
icon: ShieldAlert,
|
|
color: 'text-orange-400 bg-orange-500/10 border-orange-500/20',
|
|
requiresValue: true,
|
|
valueLabel: 'points',
|
|
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 [protest, setProtest] = useState<Protest | null>(null);
|
|
const [race, setRace] = useState<Race | null>(null);
|
|
const [protestingDriver, setProtestingDriver] = useState<DriverDTO | null>(null);
|
|
const [accusedDriver, setAccusedDriver] = useState<DriverDTO | 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);
|
|
const [penaltyType, setPenaltyType] = useState<PenaltyType>('time_penalty');
|
|
const [penaltyValue, setPenaltyValue] = useState<number>(5);
|
|
const [stewardNotes, setStewardNotes] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
// Comment state
|
|
const [newComment, setNewComment] = useState('');
|
|
|
|
useEffect(() => {
|
|
async function checkAdmin() {
|
|
const membershipRepo = getLeagueMembershipRepository();
|
|
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
|
|
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
|
|
}
|
|
checkAdmin();
|
|
}, [leagueId, currentDriverId]);
|
|
|
|
useEffect(() => {
|
|
async function loadProtest() {
|
|
setLoading(true);
|
|
try {
|
|
const protestRepo = getProtestRepository();
|
|
const raceRepo = getRaceRepository();
|
|
const driverRepo = getDriverRepository();
|
|
|
|
const protestEntity = await protestRepo.findById(protestId);
|
|
if (!protestEntity) {
|
|
throw new Error('Protest not found');
|
|
}
|
|
setProtest(protestEntity);
|
|
|
|
const raceEntity = await raceRepo.findById(protestEntity.raceId);
|
|
if (!raceEntity) {
|
|
throw new Error('Race not found');
|
|
}
|
|
setRace(raceEntity);
|
|
|
|
const protestingDriverEntity = await driverRepo.findById(protestEntity.protestingDriverId);
|
|
const accusedDriverEntity = await driverRepo.findById(protestEntity.accusedDriverId);
|
|
|
|
setProtestingDriver(protestingDriverEntity ? EntityMappers.toDriverDTO(protestingDriverEntity) : null);
|
|
setAccusedDriver(accusedDriverEntity ? EntityMappers.toDriverDTO(accusedDriverEntity) : null);
|
|
} 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, currentDriverId, router]);
|
|
|
|
// Build timeline from protest data
|
|
const timeline = useMemo((): TimelineEvent[] => {
|
|
if (!protest) return [];
|
|
|
|
const events: TimelineEvent[] = [
|
|
{
|
|
id: 'filed',
|
|
type: 'protest_filed',
|
|
timestamp: new Date(protest.filedAt),
|
|
actor: protestingDriver,
|
|
content: protest.incident.description,
|
|
metadata: {
|
|
lap: protest.incident.lap,
|
|
comment: protest.comment
|
|
}
|
|
}
|
|
];
|
|
|
|
// Add decision event if resolved
|
|
if (protest.status === 'upheld' || protest.status === 'dismissed') {
|
|
events.push({
|
|
id: 'decision',
|
|
type: 'decision',
|
|
timestamp: protest.reviewedAt ? new Date(protest.reviewedAt) : new Date(),
|
|
actor: null, // Would need to load steward driver
|
|
content: protest.decisionNotes || (protest.status === 'upheld' ? 'Protest upheld' : 'Protest dismissed'),
|
|
metadata: {
|
|
decision: protest.status
|
|
}
|
|
});
|
|
}
|
|
|
|
return events.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
}, [protest, protestingDriver]);
|
|
|
|
const handleSubmitDecision = async () => {
|
|
if (!decision || !stewardNotes.trim() || !protest) return;
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
const reviewUseCase = getReviewProtestUseCase();
|
|
const penaltyUseCase = getApplyPenaltyUseCase();
|
|
|
|
if (decision === 'uphold') {
|
|
await reviewUseCase.execute({
|
|
protestId: protest.id,
|
|
stewardId: currentDriverId,
|
|
decision: 'uphold',
|
|
decisionNotes: stewardNotes,
|
|
});
|
|
|
|
const selectedPenalty = PENALTY_TYPES.find(p => p.type === penaltyType);
|
|
const penaltyValueToUse =
|
|
selectedPenalty && selectedPenalty.requiresValue ? penaltyValue : 0;
|
|
|
|
await penaltyUseCase.execute({
|
|
raceId: protest.raceId,
|
|
driverId: protest.accusedDriverId,
|
|
stewardId: currentDriverId,
|
|
type: penaltyType,
|
|
value: penaltyValueToUse,
|
|
reason: protest.incident.description,
|
|
protestId: protest.id,
|
|
notes: stewardNotes,
|
|
});
|
|
} else {
|
|
await reviewUseCase.execute({
|
|
protestId: protest.id,
|
|
stewardId: currentDriverId,
|
|
decision: 'dismiss',
|
|
decisionNotes: stewardNotes,
|
|
});
|
|
}
|
|
|
|
router.push(`/leagues/${leagueId}/stewarding`);
|
|
} catch (err) {
|
|
alert(err instanceof Error ? err.message : 'Failed to submit decision');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleRequestDefense = async () => {
|
|
if (!protest) return;
|
|
|
|
try {
|
|
const requestDefenseUseCase = getRequestProtestDefenseUseCase();
|
|
const sendNotificationUseCase = getSendNotificationUseCase();
|
|
|
|
// Request defense
|
|
const result = await requestDefenseUseCase.execute({
|
|
protestId: protest.id,
|
|
stewardId: currentDriverId,
|
|
});
|
|
|
|
// Send notification to accused driver
|
|
await sendNotificationUseCase.execute({
|
|
recipientId: result.accusedDriverId,
|
|
type: 'protest_filed',
|
|
title: 'Defense Requested',
|
|
body: `A steward has requested your defense for a protest filed against you.`,
|
|
actionUrl: `/leagues/${leagueId}/stewarding/protests/${protest.id}`,
|
|
data: {
|
|
protestId: protest.id,
|
|
raceId: protest.raceId,
|
|
leagueId,
|
|
},
|
|
});
|
|
|
|
// 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 };
|
|
}
|
|
};
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
if (loading || !protest || !race) {
|
|
return (
|
|
<Card>
|
|
<div className="text-center py-12">
|
|
<div className="animate-pulse text-gray-400">Loading protest details...</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const statusConfig = getStatusConfig(protest.status);
|
|
const StatusIcon = statusConfig.icon;
|
|
const isPending = protest.status === 'pending' || protest.status === 'under_review' || protest.status === 'awaiting_defense';
|
|
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).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.track}</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.track}</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.scheduledAt.toLocaleDateString()}</span>
|
|
</div>
|
|
<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>
|
|
|
|
{/* Evidence */}
|
|
{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.filedAt).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.filedAt).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.incident.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>
|
|
<div className="grid grid-cols-2 gap-1.5">
|
|
{PENALTY_TYPES.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>
|
|
|
|
{PENALTY_TYPES.find(p => p.type === penaltyType)?.requiresValue && (
|
|
<div className="mt-3">
|
|
<label className="text-xs font-medium text-gray-400 mb-1 block">
|
|
Value ({PENALTY_TYPES.find(p => p.type === penaltyType)?.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>
|
|
);
|
|
} |