wip
This commit is contained in:
387
apps/website/components/leagues/ReviewProtestModal.tsx
Normal file
387
apps/website/components/leagues/ReviewProtestModal.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Protest } from "@gridpilot/racing/domain/entities/Protest";
|
||||
import { PenaltyType } from "@gridpilot/racing/domain/entities/Penalty";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import Card from "../ui/Card";
|
||||
import {
|
||||
AlertCircle,
|
||||
Video,
|
||||
Clock,
|
||||
Grid3x3,
|
||||
TrendingDown,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
FileText,
|
||||
AlertTriangle,
|
||||
ShieldAlert,
|
||||
Ban,
|
||||
DollarSign,
|
||||
FileWarning,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ReviewProtestModalProps {
|
||||
protest: Protest | null;
|
||||
onClose: () => void;
|
||||
onAccept: (
|
||||
protestId: string,
|
||||
penaltyType: PenaltyType,
|
||||
penaltyValue: number,
|
||||
stewardNotes: string
|
||||
) => Promise<void>;
|
||||
onReject: (protestId: string, stewardNotes: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ReviewProtestModal({
|
||||
protest,
|
||||
onClose,
|
||||
onAccept,
|
||||
onReject,
|
||||
}: ReviewProtestModalProps) {
|
||||
const [decision, setDecision] = useState<"accept" | "reject" | null>(null);
|
||||
const [penaltyType, setPenaltyType] = useState<PenaltyType>("time_penalty");
|
||||
const [penaltyValue, setPenaltyValue] = useState<number>(5);
|
||||
const [stewardNotes, setStewardNotes] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
|
||||
if (!protest) return null;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!decision || !stewardNotes.trim()) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (decision === "accept") {
|
||||
await onAccept(protest.id, penaltyType, penaltyValue, stewardNotes);
|
||||
} else {
|
||||
await onReject(protest.id, stewardNotes);
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Failed to submit decision");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setShowConfirmation(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPenaltyIcon = (type: PenaltyType) => {
|
||||
switch (type) {
|
||||
case "time_penalty":
|
||||
return Clock;
|
||||
case "grid_penalty":
|
||||
return Grid3x3;
|
||||
case "points_deduction":
|
||||
return TrendingDown;
|
||||
case "disqualification":
|
||||
return XCircle;
|
||||
case "warning":
|
||||
return AlertTriangle;
|
||||
case "license_points":
|
||||
return ShieldAlert;
|
||||
case "probation":
|
||||
return FileWarning;
|
||||
case "fine":
|
||||
return DollarSign;
|
||||
case "race_ban":
|
||||
return Ban;
|
||||
default:
|
||||
return AlertCircle;
|
||||
}
|
||||
};
|
||||
|
||||
const getPenaltyLabel = (type: PenaltyType) => {
|
||||
switch (type) {
|
||||
case "time_penalty":
|
||||
return "seconds";
|
||||
case "grid_penalty":
|
||||
return "grid positions";
|
||||
case "points_deduction":
|
||||
return "points";
|
||||
case "license_points":
|
||||
return "points";
|
||||
case "fine":
|
||||
return "points";
|
||||
case "race_ban":
|
||||
return "races";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const getPenaltyColor = (type: PenaltyType) => {
|
||||
switch (type) {
|
||||
case "time_penalty":
|
||||
return "text-blue-400 bg-blue-500/10 border-blue-500/30";
|
||||
case "grid_penalty":
|
||||
return "text-purple-400 bg-purple-500/10 border-purple-500/30";
|
||||
case "points_deduction":
|
||||
return "text-red-400 bg-red-500/10 border-red-500/30";
|
||||
case "disqualification":
|
||||
return "text-red-500 bg-red-500/10 border-red-500/30";
|
||||
case "warning":
|
||||
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/30";
|
||||
case "license_points":
|
||||
return "text-orange-400 bg-orange-500/10 border-orange-500/30";
|
||||
case "probation":
|
||||
return "text-amber-400 bg-amber-500/10 border-amber-500/30";
|
||||
case "fine":
|
||||
return "text-green-400 bg-green-500/10 border-green-500/30";
|
||||
case "race_ban":
|
||||
return "text-red-600 bg-red-600/10 border-red-600/30";
|
||||
default:
|
||||
return "text-warning-amber bg-warning-amber/10 border-warning-amber/30";
|
||||
}
|
||||
};
|
||||
|
||||
if (showConfirmation) {
|
||||
return (
|
||||
<Modal title="Confirm Decision" isOpen={true} onOpenChange={() => setShowConfirmation(false)}>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="text-center space-y-4">
|
||||
{decision === "accept" ? (
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-orange-500/20 flex items-center justify-center">
|
||||
<AlertCircle className="h-8 w-8 text-orange-400" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-gray-500/20 flex items-center justify-center">
|
||||
<XCircle className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-white">Confirm Decision</h3>
|
||||
<p className="text-gray-400 mt-2">
|
||||
{decision === "accept"
|
||||
? `Issue ${penaltyValue} ${getPenaltyLabel(penaltyType)} penalty?`
|
||||
: "Reject this protest?"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="p-4 bg-gray-800/50">
|
||||
<p className="text-sm text-gray-300">{stewardNotes}</p>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => setShowConfirmation(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Submitting..." : "Confirm Decision"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Review Protest" isOpen={true} onOpenChange={onClose}>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="h-12 w-12 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<AlertCircle className="h-6 w-6 text-orange-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold text-white">Review Protest</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Protest #{protest.id.substring(0, 8)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="p-4 bg-gray-800/50">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Filed Date</span>
|
||||
<span className="text-white font-medium">
|
||||
{new Date(protest.filedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Incident Lap</span>
|
||||
<span className="text-white font-medium">
|
||||
Lap {protest.incident.lap}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Status</span>
|
||||
<span className="px-2 py-1 rounded text-xs font-medium bg-orange-500/20 text-orange-400">
|
||||
{protest.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-400 mb-2 block">
|
||||
Description
|
||||
</label>
|
||||
<Card className="p-4 bg-gray-800/50">
|
||||
<p className="text-gray-300">{protest.incident.description}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{protest.comment && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-400 mb-2 block">
|
||||
Additional Comment
|
||||
</label>
|
||||
<Card className="p-4 bg-gray-800/50">
|
||||
<p className="text-gray-300">{protest.comment}</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{protest.proofVideoUrl && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-400 mb-2 block">
|
||||
Evidence
|
||||
</label>
|
||||
<Card className="p-4 bg-gray-800/50">
|
||||
<a
|
||||
href={protest.proofVideoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-orange-400 hover:text-orange-300 transition-colors"
|
||||
>
|
||||
<Video className="h-4 w-4" />
|
||||
<span className="text-sm">View video evidence</span>
|
||||
</a>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 pt-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-white">Stewarding Decision</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant={decision === "accept" ? "primary" : "secondary"}
|
||||
className="flex items-center justify-center gap-2"
|
||||
onClick={() => setDecision("accept")}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Accept Protest
|
||||
</Button>
|
||||
<Button
|
||||
variant={decision === "reject" ? "primary" : "secondary"}
|
||||
className="flex items-center justify-center gap-2"
|
||||
onClick={() => setDecision("reject")}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
Reject Protest
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{decision === "accept" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-400 mb-2 block">
|
||||
Penalty Type
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ type: "time_penalty" as PenaltyType, label: "Time Penalty" },
|
||||
{ type: "grid_penalty" as PenaltyType, label: "Grid Penalty" },
|
||||
{ type: "points_deduction" as PenaltyType, label: "Points Deduction" },
|
||||
{ type: "disqualification" as PenaltyType, label: "Disqualification" },
|
||||
{ type: "warning" as PenaltyType, label: "Warning" },
|
||||
{ type: "license_points" as PenaltyType, label: "License Points" },
|
||||
{ type: "probation" as PenaltyType, label: "Probation" },
|
||||
{ type: "fine" as PenaltyType, label: "Fine" },
|
||||
{ type: "race_ban" as PenaltyType, label: "Race Ban" },
|
||||
].map(({ type, label }) => {
|
||||
const Icon = getPenaltyIcon(type);
|
||||
const colorClass = getPenaltyColor(type);
|
||||
const isSelected = penaltyType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setPenaltyType(type)}
|
||||
className={`p-3 rounded-lg border transition-all ${
|
||||
isSelected
|
||||
? `${colorClass} border-2`
|
||||
: "border-charcoal-outline hover:border-gray-600 bg-iron-gray/50"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 mx-auto mb-1 ${isSelected ? '' : 'text-gray-400'}`} />
|
||||
<p className={`text-xs font-medium ${isSelected ? '' : 'text-gray-400'}`}>{label}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(penaltyType) && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-400 mb-2 block">
|
||||
Penalty Value ({getPenaltyLabel(penaltyType)})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={penaltyValue}
|
||||
onChange={(e) => setPenaltyValue(Number(e.target.value))}
|
||||
min="1"
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-400 mb-2 block">
|
||||
Steward Notes *
|
||||
</label>
|
||||
<textarea
|
||||
value={stewardNotes}
|
||||
onChange={(e) => setStewardNotes(e.target.value)}
|
||||
placeholder="Explain your decision and reasoning..."
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-orange-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-800">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
onClick={() => setShowConfirmation(true)}
|
||||
disabled={!decision || !stewardNotes.trim() || submitting}
|
||||
>
|
||||
Submit Decision
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user