272 lines
9.2 KiB
TypeScript
272 lines
9.2 KiB
TypeScript
'use client';
|
|
|
|
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
|
|
import { useInject } from '@/lib/di/hooks/useInject';
|
|
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
|
|
import {
|
|
AlertCircle,
|
|
AlertTriangle,
|
|
CheckCircle,
|
|
Clock,
|
|
Gavel,
|
|
Grid3x3,
|
|
MessageCircle,
|
|
Shield,
|
|
ShieldAlert,
|
|
TrendingDown,
|
|
XCircle,
|
|
} from 'lucide-react';
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
|
|
import { useLeagueAdminStatus } from "@/hooks/league/useLeagueAdminStatus";
|
|
import { useProtestDetail } from "@/hooks/league/useProtestDetail";
|
|
import { routes } from '@/lib/routing/RouteConfig';
|
|
import { ProtestDetailTemplate } from '@/templates/ProtestDetailTemplate';
|
|
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
|
|
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
|
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
|
|
|
const PENALTY_UI: Record<string, any> = {
|
|
time_penalty: {
|
|
label: 'Time Penalty',
|
|
description: 'Add seconds to race result',
|
|
icon: Clock,
|
|
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
|
|
defaultValue: 5,
|
|
},
|
|
grid_penalty: {
|
|
label: 'Grid Penalty',
|
|
description: 'Grid positions for next race',
|
|
icon: Grid3x3,
|
|
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
|
|
defaultValue: 3,
|
|
},
|
|
points_deduction: {
|
|
label: 'Points Deduction',
|
|
description: 'Deduct championship points',
|
|
icon: TrendingDown,
|
|
color: 'text-red-400 bg-red-500/10 border-red-500/20',
|
|
defaultValue: 5,
|
|
},
|
|
disqualification: {
|
|
label: 'Disqualification',
|
|
description: 'Disqualify from race',
|
|
icon: XCircle,
|
|
color: 'text-red-500 bg-red-500/10 border-red-500/20',
|
|
defaultValue: 0,
|
|
},
|
|
warning: {
|
|
label: 'Warning',
|
|
description: 'Official warning only',
|
|
icon: AlertTriangle,
|
|
color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
|
|
defaultValue: 0,
|
|
},
|
|
license_points: {
|
|
label: 'License Points',
|
|
description: 'Safety rating penalty',
|
|
icon: ShieldAlert,
|
|
color: 'text-orange-400 bg-orange-500/10 border-orange-500/20',
|
|
defaultValue: 2,
|
|
},
|
|
};
|
|
|
|
export function ProtestDetailPageClient({ viewData: initialViewData }: Partial<ClientWrapperProps<ViewData>>) {
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const leagueId = params.id as string;
|
|
const protestId = params.protestId as string;
|
|
const currentDriverId = useEffectiveDriverId();
|
|
const protestService = useInject(PROTEST_SERVICE_TOKEN);
|
|
|
|
// Decision state
|
|
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
|
|
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
|
|
const [penaltyType, setPenaltyType] = useState<string>('time_penalty');
|
|
const [penaltyValue, setPenaltyValue] = useState<number>(5);
|
|
const [stewardNotes, setStewardNotes] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [newComment, setNewComment] = useState('');
|
|
|
|
// Check admin status using hook
|
|
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId || '');
|
|
|
|
// Load protest detail using hook
|
|
const { data: detail, isLoading: detailLoading, error, retry } = useProtestDetail(leagueId, protestId, isAdmin || false);
|
|
|
|
// Use initial data if available
|
|
const protestDetail = (detail || initialViewData) as any;
|
|
|
|
// Set initial penalty values when data loads
|
|
useEffect(() => {
|
|
if (protestDetail?.initialPenaltyType) {
|
|
setPenaltyType(protestDetail.initialPenaltyType);
|
|
setPenaltyValue(protestDetail.initialPenaltyValue);
|
|
}
|
|
}, [protestDetail]);
|
|
|
|
const penaltyTypes = useMemo(() => {
|
|
const referenceItems = protestDetail?.penaltyTypes ?? [];
|
|
return referenceItems.map((ref: any) => {
|
|
const ui = PENALTY_UI[ref.type] ?? {
|
|
icon: Gavel,
|
|
color: 'text-gray-400 bg-gray-500/10 border-gray-500/20',
|
|
};
|
|
|
|
return {
|
|
...ref,
|
|
icon: ui.icon,
|
|
color: ui.color,
|
|
};
|
|
});
|
|
}, [protestDetail?.penaltyTypes]);
|
|
|
|
const selectedPenalty = useMemo(() => {
|
|
return penaltyTypes.find((p: any) => p.type === penaltyType);
|
|
}, [penaltyTypes, penaltyType]);
|
|
|
|
const handleSubmitDecision = async () => {
|
|
if (!decision || !stewardNotes.trim() || !protestDetail || !currentDriverId) return;
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
const protest = protestDetail.protest || protestDetail;
|
|
|
|
const defaultUpheldReason = protestDetail.defaultReasons?.upheld;
|
|
const defaultDismissedReason = protestDetail.defaultReasons?.dismissed;
|
|
|
|
if (decision === 'uphold') {
|
|
const requiresValue = selectedPenalty?.requiresValue ?? true;
|
|
|
|
const commandModel = new ProtestDecisionCommandModel({
|
|
decision,
|
|
penaltyType,
|
|
penaltyValue,
|
|
stewardNotes,
|
|
});
|
|
|
|
const options: any = { requiresValue };
|
|
|
|
if (defaultUpheldReason) {
|
|
options.defaultUpheldReason = defaultUpheldReason;
|
|
}
|
|
if (defaultDismissedReason) {
|
|
options.defaultDismissedReason = defaultDismissedReason;
|
|
}
|
|
|
|
const penaltyCommand = commandModel.toApplyPenaltyCommand(
|
|
protest.raceId || protestDetail.race?.id,
|
|
protest.accusedDriverId || protestDetail.accusedDriver?.id,
|
|
currentDriverId,
|
|
protest.id || protestDetail.protestId,
|
|
options,
|
|
);
|
|
|
|
const result = await protestService.applyPenalty(penaltyCommand);
|
|
if (result.isErr()) {
|
|
throw new Error(result.getError().message);
|
|
}
|
|
} else {
|
|
const warningRef = protestDetail.penaltyTypes.find((p: any) => p.type === 'warning');
|
|
const requiresValue = warningRef?.requiresValue ?? false;
|
|
|
|
const commandModel = new ProtestDecisionCommandModel({
|
|
decision,
|
|
penaltyType: 'warning',
|
|
penaltyValue: 0,
|
|
stewardNotes,
|
|
});
|
|
|
|
const options: any = { requiresValue };
|
|
|
|
if (defaultUpheldReason) {
|
|
options.defaultUpheldReason = defaultUpheldReason;
|
|
}
|
|
if (defaultDismissedReason) {
|
|
options.defaultDismissedReason = defaultDismissedReason;
|
|
}
|
|
|
|
const penaltyCommand = commandModel.toApplyPenaltyCommand(
|
|
protest.raceId || protestDetail.race?.id,
|
|
protest.accusedDriverId || protestDetail.accusedDriver?.id,
|
|
currentDriverId,
|
|
protest.id || protestDetail.protestId,
|
|
options,
|
|
);
|
|
|
|
const result = await protestService.applyPenalty(penaltyCommand);
|
|
if (result.isErr()) {
|
|
throw new Error(result.getError().message);
|
|
}
|
|
}
|
|
|
|
router.push(routes.league.stewarding(leagueId));
|
|
} catch (err) {
|
|
alert(err instanceof Error ? err.message : 'Failed to submit decision');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleRequestDefense = async () => {
|
|
if (!protestDetail || !currentDriverId) return;
|
|
|
|
try {
|
|
const result = await protestService.requestDefense({
|
|
protestId: protestDetail.protest?.id || protestDetail.protestId,
|
|
stewardId: currentDriverId,
|
|
});
|
|
if (result.isErr()) {
|
|
throw new Error(result.getError().message);
|
|
}
|
|
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', bg: 'bg-warning-amber/20', color: 'text-warning-amber', borderColor: 'border-warning-amber/30', icon: Clock };
|
|
case 'under_review':
|
|
return { label: 'Under Review', bg: 'bg-blue-500/20', color: 'text-blue-400', borderColor: 'border-blue-500/30', icon: Shield };
|
|
case 'awaiting_defense':
|
|
return { label: 'Awaiting Defense', bg: 'bg-purple-500/20', color: 'text-purple-400', borderColor: 'border-purple-500/30', icon: MessageCircle };
|
|
case 'upheld':
|
|
return { label: 'Upheld', bg: 'bg-red-500/20', color: 'text-red-400', borderColor: 'border-red-500/30', icon: CheckCircle };
|
|
case 'dismissed':
|
|
return { label: 'Dismissed', bg: 'bg-gray-500/20', color: 'text-gray-400', borderColor: 'border-gray-500/30', icon: XCircle };
|
|
default:
|
|
return { label: status, bg: 'bg-gray-500/20', color: 'text-gray-400', borderColor: 'border-gray-500/30', icon: AlertCircle };
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ProtestDetailTemplate
|
|
viewData={{}}
|
|
protestDetail={protestDetail}
|
|
leagueId={leagueId}
|
|
showDecisionPanel={showDecisionPanel}
|
|
setShowDecisionPanel={setShowDecisionPanel}
|
|
decision={decision}
|
|
setDecision={setDecision}
|
|
penaltyType={penaltyType}
|
|
setPenaltyType={setPenaltyType}
|
|
penaltyValue={penaltyValue}
|
|
setPenaltyValue={setPenaltyValue}
|
|
stewardNotes={stewardNotes}
|
|
setStewardNotes={setStewardNotes}
|
|
submitting={submitting}
|
|
newComment={newComment}
|
|
setNewComment={setNewComment}
|
|
penaltyTypes={penaltyTypes}
|
|
selectedPenalty={selectedPenalty}
|
|
onSubmitDecision={handleSubmitDecision}
|
|
onRequestDefense={handleRequestDefense}
|
|
getStatusConfig={getStatusConfig}
|
|
/>
|
|
);
|
|
}
|