470 lines
16 KiB
TypeScript
470 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import { usePenaltyTypesReference } from "@/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 { Stack } from "@/ui/Stack";
|
|
import { Text } from "@/ui/Text";
|
|
import { Heading } from "@/ui/Heading";
|
|
import { Icon } from "@/ui/Icon";
|
|
import { TextArea } from "@/ui/TextArea";
|
|
import { Input } from "@/ui/Input";
|
|
import { Grid } from "@/ui/Grid";
|
|
import {
|
|
AlertCircle,
|
|
Video,
|
|
Clock,
|
|
Grid3x3,
|
|
TrendingDown,
|
|
CheckCircle,
|
|
XCircle,
|
|
AlertTriangle,
|
|
ShieldAlert,
|
|
Ban,
|
|
DollarSign,
|
|
FileWarning,
|
|
type LucideIcon,
|
|
} 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);
|
|
|
|
const { data: penaltyTypesReferenceResult, isLoading: penaltyTypesLoading } = usePenaltyTypesReference();
|
|
|
|
const penaltyOptions = useMemo(() => {
|
|
const refs = penaltyTypesReferenceResult?.isOk() ? penaltyTypesReferenceResult.unwrap().penaltyTypes : [];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return refs.map((ref: any) => ({
|
|
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),
|
|
}));
|
|
}, [penaltyTypesReferenceResult]);
|
|
|
|
const selectedPenalty = useMemo(() => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return penaltyOptions.find((p: any) => p.type === penaltyType);
|
|
}, [penaltyOptions, penaltyType]);
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
function 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;
|
|
}
|
|
}
|
|
|
|
function 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("_", " ");
|
|
}
|
|
}
|
|
|
|
function 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 "";
|
|
}
|
|
}
|
|
|
|
function 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;
|
|
}
|
|
}
|
|
|
|
function 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)}>
|
|
<Stack gap={6} p={6}>
|
|
<Stack align="center">
|
|
<Stack gap={4}>
|
|
{decision === "accept" ? (
|
|
<Stack direction="row" justify="center">
|
|
<Stack h="16" w="16" rounded="full" bg="bg-orange-500/20" align="center" justify="center">
|
|
<Icon icon={AlertCircle} size={8} color="text-orange-400" />
|
|
</Stack>
|
|
</Stack>
|
|
) : (
|
|
<Stack direction="row" justify="center">
|
|
<Stack h="16" w="16" rounded="full" bg="bg-gray-500/20" align="center" justify="center">
|
|
<Icon icon={XCircle} size={8} color="text-gray-400" />
|
|
</Stack>
|
|
</Stack>
|
|
)}
|
|
<Stack align="center">
|
|
<Heading level={3} weight="bold" color="text-white">Confirm Decision</Heading>
|
|
<Text color="text-gray-400" mt={2} block textAlign="center">
|
|
{decision === "accept"
|
|
? (selectedPenalty?.requiresValue
|
|
? `Issue ${penaltyValue} ${selectedPenalty.valueLabel} penalty?`
|
|
: `Issue ${selectedPenalty?.name ?? penaltyType} penalty?`)
|
|
: "Reject this protest?"}
|
|
</Text>
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
|
|
<Card p={4} className="bg-gray-800/50">
|
|
<Text size="sm" color="text-gray-300" block>{stewardNotes}</Text>
|
|
</Card>
|
|
|
|
<Stack direction="row" gap={3}>
|
|
<Button
|
|
variant="secondary"
|
|
fullWidth
|
|
onClick={() => setShowConfirmation(false)}
|
|
disabled={submitting}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
fullWidth
|
|
onClick={handleSubmit}
|
|
disabled={submitting}
|
|
>
|
|
{submitting ? "Submitting..." : "Confirm Decision"}
|
|
</Button>
|
|
</Stack>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Modal title="Review Protest" isOpen={true} onOpenChange={onClose}>
|
|
<Stack gap={6} p={6}>
|
|
<Stack direction="row" align="start" gap={4}>
|
|
<Stack h="12" w="12" rounded="full" bg="bg-orange-500/20" align="center" justify="center" flexShrink={0}>
|
|
<Icon icon={AlertCircle} size={6} color="text-orange-400" />
|
|
</Stack>
|
|
<Stack flexGrow={1}>
|
|
<Heading level={2} weight="bold" color="text-white">Review Protest</Heading>
|
|
<Text color="text-gray-400" mt={1} block>
|
|
Protest #{protest.id.substring(0, 8)}
|
|
</Text>
|
|
</Stack>
|
|
</Stack>
|
|
|
|
<Stack gap={4}>
|
|
<Card p={4} className="bg-gray-800/50">
|
|
<Stack gap={3}>
|
|
<Stack direction="row" align="center" justify="between">
|
|
<Text size="sm" color="text-gray-400">Filed Date</Text>
|
|
<Text size="sm" color="text-white" weight="medium">
|
|
{new Date(protest.filedAt || protest.submittedAt).toLocaleString()}
|
|
</Text>
|
|
</Stack>
|
|
<Stack direction="row" align="center" justify="between">
|
|
<Text size="sm" color="text-gray-400">Incident Lap</Text>
|
|
<Text size="sm" color="text-white" weight="medium">
|
|
Lap {protest.incident?.lap || 'N/A'}
|
|
</Text>
|
|
</Stack>
|
|
<Stack direction="row" align="center" justify="between">
|
|
<Text size="sm" color="text-gray-400">Status</Text>
|
|
<Stack as="span" px={2} py={1} rounded="sm" bg="bg-orange-500/20">
|
|
<Text size="xs" weight="medium" color="text-orange-400">
|
|
{protest.status}
|
|
</Text>
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
</Card>
|
|
|
|
<Stack>
|
|
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
|
|
Description
|
|
</Text>
|
|
<Card p={4} className="bg-gray-800/50">
|
|
<Text color="text-gray-300" block>{protest.incident?.description || protest.description}</Text>
|
|
</Card>
|
|
</Stack>
|
|
|
|
{protest.comment && (
|
|
<Stack>
|
|
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
|
|
Additional Comment
|
|
</Text>
|
|
<Card p={4} className="bg-gray-800/50">
|
|
<Text color="text-gray-300" block>{protest.comment}</Text>
|
|
</Card>
|
|
</Stack>
|
|
)}
|
|
|
|
{protest.proofVideoUrl && (
|
|
<Stack>
|
|
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
|
|
Evidence
|
|
</Text>
|
|
<Card p={4} className="bg-gray-800/50">
|
|
<Stack
|
|
as="a"
|
|
{...({
|
|
href: protest.proofVideoUrl,
|
|
target: "_blank",
|
|
rel: "noopener noreferrer"
|
|
} as any)}
|
|
direction="row"
|
|
align="center"
|
|
gap={2}
|
|
color="text-orange-400"
|
|
className="transition-all hover:text-orange-300"
|
|
>
|
|
<Icon icon={Video} size={4} />
|
|
<Text size="sm">View video evidence</Text>
|
|
</Stack>
|
|
</Card>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
<Stack borderTop borderColor="border-gray-800" pt={6}>
|
|
<Stack gap={4}>
|
|
<Heading level={3} weight="semibold" color="text-white">Stewarding Decision</Heading>
|
|
|
|
<Grid cols={2} gap={3}>
|
|
<Button
|
|
variant={decision === "accept" ? "primary" : "secondary"}
|
|
fullWidth
|
|
onClick={() => setDecision("accept")}
|
|
>
|
|
<Stack direction="row" align="center" gap={2} center>
|
|
<Icon icon={CheckCircle} size={4} />
|
|
Accept Protest
|
|
</Stack>
|
|
</Button>
|
|
<Button
|
|
variant={decision === "reject" ? "primary" : "secondary"}
|
|
fullWidth
|
|
onClick={() => setDecision("reject")}
|
|
>
|
|
<Stack direction="row" align="center" gap={2} center>
|
|
<Icon icon={XCircle} size={4} />
|
|
Reject Protest
|
|
</Stack>
|
|
</Button>
|
|
</Grid>
|
|
|
|
{decision === "accept" && (
|
|
<Stack gap={4}>
|
|
<Stack>
|
|
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
|
|
Penalty Type
|
|
</Text>
|
|
|
|
{penaltyTypesLoading ? (
|
|
<Text size="sm" color="text-gray-500">Loading penalty types…</Text>
|
|
) : (
|
|
<Grid cols={3} gap={2}>
|
|
{penaltyOptions.map(({ type, name, Icon: PenaltyIcon, colorClass, defaultValue }: { type: string; name: string; Icon: LucideIcon; colorClass: string; defaultValue: number }) => {
|
|
const isSelected = penaltyType === type;
|
|
return (
|
|
<Stack
|
|
key={type}
|
|
as="button"
|
|
onClick={() => {
|
|
setPenaltyType(type);
|
|
setPenaltyValue(defaultValue);
|
|
}}
|
|
p={3}
|
|
rounded="lg"
|
|
border
|
|
{...({ borderWidth: isSelected ? "2px" : "1px" } as any)}
|
|
className={`transition-all ${isSelected ? colorClass : "bg-iron-gray/50 border-charcoal-outline hover:border-gray-600"}`}
|
|
>
|
|
<Icon icon={PenaltyIcon} size={5} className="mx-auto mb-1" color={isSelected ? undefined : "text-gray-400"} />
|
|
<Text size="xs" weight="medium" color={isSelected ? undefined : "text-gray-400"} block textAlign="center">{name}</Text>
|
|
</Stack>
|
|
);
|
|
})}
|
|
</Grid>
|
|
)}
|
|
</Stack>
|
|
|
|
{selectedPenalty?.requiresValue && (
|
|
<Stack>
|
|
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
|
|
Penalty Value ({selectedPenalty.valueLabel})
|
|
</Text>
|
|
<Input
|
|
type="number"
|
|
value={penaltyValue}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPenaltyValue(Number(e.target.value))}
|
|
min={1}
|
|
/>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
)}
|
|
|
|
<Stack>
|
|
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
|
|
Steward Notes *
|
|
</Text>
|
|
<TextArea
|
|
value={stewardNotes}
|
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setStewardNotes(e.target.value)}
|
|
placeholder="Explain your decision and reasoning..."
|
|
rows={4}
|
|
/>
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
|
|
<Stack direction="row" gap={3} pt={4} borderTop borderColor="border-gray-800">
|
|
<Button
|
|
variant="secondary"
|
|
fullWidth
|
|
onClick={onClose}
|
|
disabled={submitting}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
fullWidth
|
|
onClick={() => setShowConfirmation(true)}
|
|
disabled={!decision || !stewardNotes.trim() || submitting}
|
|
>
|
|
Submit Decision
|
|
</Button>
|
|
</Stack>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|