Files
gridpilot.gg/apps/website/components/leagues/ReviewProtestModal.tsx
2026-01-12 01:01:49 +01:00

445 lines
15 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { usePenaltyTypesReference } from "@/lib/hooks/usePenaltyTypesReference";
import type { PenaltyValueKindDTO } from "@/lib/types/PenaltyTypesReferenceDTO";
import { ProtestViewModel } from "../../lib/view-models/ProtestViewModel";
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";
type PenaltyType = string;
interface ReviewProtestModalProps {
protest: ProtestViewModel | 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 getPenaltyName = (type: PenaltyType) => {
switch (type) {
case "time_penalty":
return "Time Penalty";
case "grid_penalty":
return "Grid Penalty";
case "points_deduction":
return "Points Deduction";
case "disqualification":
return "Disqualification";
case "warning":
return "Warning";
case "license_points":
return "License Points";
case "probation":
return "Probation";
case "fine":
return "Fine";
case "race_ban":
return "Race Ban";
default:
return type.replaceAll("_", " ");
}
};
const getPenaltyValueLabel = (valueKind: PenaltyValueKindDTO): string => {
switch (valueKind) {
case "seconds":
return "seconds";
case "grid_positions":
return "grid positions";
case "points":
return "points";
case "races":
return "races";
case "none":
return "";
}
};
const getPenaltyDefaultValue = (type: PenaltyType, valueKind: PenaltyValueKindDTO): number => {
if (type === "license_points") return 2;
if (type === "race_ban") return 1;
switch (valueKind) {
case "seconds":
return 5;
case "grid_positions":
return 3;
case "points":
return 5;
case "races":
return 1;
case "none":
return 0;
}
};
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";
}
};
const { data: penaltyTypesReference, isLoading: penaltyTypesLoading } = usePenaltyTypesReference();
const penaltyOptions = useMemo(() => {
const refs = penaltyTypesReference?.penaltyTypes ?? [];
return refs.map((ref) => ({
type: ref.type as PenaltyType,
name: getPenaltyName(ref.type),
requiresValue: ref.requiresValue,
valueLabel: getPenaltyValueLabel(ref.valueKind),
defaultValue: getPenaltyDefaultValue(ref.type, ref.valueKind),
Icon: getPenaltyIcon(ref.type),
colorClass: getPenaltyColor(ref.type),
}));
}, [penaltyTypesReference]);
const selectedPenalty = useMemo(() => {
return penaltyOptions.find((p) => p.type === penaltyType);
}, [penaltyOptions, penaltyType]);
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"
? (selectedPenalty?.requiresValue
? `Issue ${penaltyValue} ${selectedPenalty.valueLabel} penalty?`
: `Issue ${selectedPenalty?.name ?? 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 || protest.submittedAt).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 || 'N/A'}
</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 || protest.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>
{penaltyTypesLoading ? (
<div className="text-sm text-gray-500">Loading penalty types</div>
) : (
<div className="grid grid-cols-3 gap-2">
{penaltyOptions.map(({ type, name, Icon, colorClass, defaultValue }) => {
const isSelected = penaltyType === type;
return (
<button
key={type}
onClick={() => {
setPenaltyType(type);
setPenaltyValue(defaultValue);
}}
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"}`}>{name}</p>
</button>
);
})}
</div>
)}
</div>
{selectedPenalty?.requiresValue && (
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
Penalty Value ({selectedPenalty.valueLabel})
</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>
);
}