refactor page to use services

This commit is contained in:
2025-12-18 15:58:09 +01:00
parent f54fa5de5b
commit fc386db06a
45 changed files with 2254 additions and 1292 deletions

View File

@@ -4,11 +4,11 @@ import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
import { EntityMappers } from '@core/racing/application/mappers/EntityMappers';
import type { PenaltyType } from '@core/racing/domain/entities/Penalty';
import type { Protest } from '@core/racing/domain/entities/Protest';
import type { Race } from '@core/racing/domain/entities/Race';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
import { ProtestDecisionCommandModel, type PenaltyType } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
import type { DriverSummaryDTO } from '@/lib/types/generated/LeagueAdminProtestsDTO';
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
import {
AlertCircle,
AlertTriangle,
@@ -115,10 +115,10 @@ export default function ProtestReviewPage() {
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 [protest, setProtest] = useState<ProtestViewModel | null>(null);
const [race, setRace] = useState<RaceDTO | null>(null);
const [protestingDriver, setProtestingDriver] = useState<DriverSummaryDTO | null>(null);
const [accusedDriver, setAccusedDriver] = useState<DriverSummaryDTO | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
@@ -146,27 +146,18 @@ export default function ProtestReviewPage() {
async function loadProtest() {
setLoading(true);
try {
const protestRepo = getProtestRepository();
const raceRepo = getRaceRepository();
const driverRepo = getDriverRepository();
const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_URL || '');
const protestService = serviceFactory.createProtestService();
const protestEntity = await protestRepo.findById(protestId);
if (!protestEntity) {
const protestData = await protestService.getProtestById(leagueId, protestId);
if (!protestData) {
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);
setProtest(protestData.protest);
setRace(protestData.race);
setProtestingDriver(protestData.protestingDriver);
setAccusedDriver(protestData.accusedDriver);
} catch (err) {
console.error('Failed to load protest:', err);
alert('Failed to load protest details');
@@ -179,39 +170,39 @@ export default function ProtestReviewPage() {
if (isAdmin) {
loadProtest();
}
}, [protestId, leagueId, isAdmin, currentDriverId, router]);
}, [protestId, leagueId, isAdmin, 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),
timestamp: new Date(protest.submittedAt),
actor: protestingDriver,
content: protest.incident.description,
content: protest.description, // TODO: Add incident description when available
metadata: {
lap: protest.incident.lap,
comment: protest.comment
// 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
}
});
}
// TODO: Add decision event when status/decisions are available in DTO
// 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]);
@@ -221,38 +212,44 @@ export default function ProtestReviewPage() {
setSubmitting(true);
try {
const reviewUseCase = getReviewProtestUseCase();
const penaltyUseCase = getApplyPenaltyUseCase();
const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_URL || '');
const protestService = serviceFactory.createProtestService();
if (decision === 'uphold') {
await reviewUseCase.execute({
protestId: protest.id,
stewardId: currentDriverId,
decision: 'uphold',
decisionNotes: stewardNotes,
const commandModel = new ProtestDecisionCommandModel({
decision,
penaltyType,
penaltyValue,
stewardNotes,
});
const selectedPenalty = PENALTY_TYPES.find(p => p.type === penaltyType);
const penaltyValueToUse =
selectedPenalty && selectedPenalty.requiresValue ? penaltyValue : 0;
const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId,
protest.accusedDriverId,
currentDriverId,
protest.id
);
await penaltyUseCase.execute({
raceId: protest.raceId,
driverId: protest.accusedDriverId,
stewardId: currentDriverId,
type: penaltyType,
value: penaltyValueToUse,
reason: protest.incident.description,
protestId: protest.id,
notes: stewardNotes,
});
await protestService.applyPenalty(penaltyCommand);
} else {
await reviewUseCase.execute({
protestId: protest.id,
stewardId: currentDriverId,
decision: 'dismiss',
decisionNotes: stewardNotes,
// For dismiss, we might need a separate endpoint
// For now, just apply a warning penalty with 0 value or create a separate endpoint
const commandModel = new ProtestDecisionCommandModel({
decision,
penaltyType: 'warning',
penaltyValue: 0,
stewardNotes,
});
const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId,
protest.accusedDriverId,
currentDriverId,
protest.id
);
penaltyCommand.reason = 'Protest upheld'; // TODO: Make this configurable
await protestService.applyPenalty(penaltyCommand);
}
router.push(`/leagues/${leagueId}/stewarding`);
@@ -265,31 +262,17 @@ export default function ProtestReviewPage() {
const handleRequestDefense = async () => {
if (!protest) return;
try {
const requestDefenseUseCase = getRequestProtestDefenseUseCase();
const sendNotificationUseCase = getSendNotificationUseCase();
const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_URL || '');
const protestService = serviceFactory.createProtestService();
// Request defense
const result = await requestDefenseUseCase.execute({
await protestService.requestDefense({
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) {
@@ -340,10 +323,10 @@ export default function ProtestReviewPage() {
);
}
const statusConfig = getStatusConfig(protest.status);
const statusConfig = getStatusConfig('pending'); // TODO: Update when status is available
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));
const isPending = true; // TODO: Update when status is available
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.submittedAt).getTime()) / (1000 * 60 * 60 * 24));
return (
<div className="min-h-screen">
@@ -417,29 +400,30 @@ export default function ProtestReviewPage() {
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>
<span className="text-sm font-medium text-white">{race.name}</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>
<span className="text-gray-300">{race.name}</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>
<span className="text-gray-300">{new Date(race.date).toLocaleDateString()}</span>
</div>
<div className="flex items-center gap-2 text-sm">
{/* TODO: Add lap info when available */}
{/* <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> */}
</div>
</Card>
{/* Evidence */}
{protest.proofVideoUrl && (
{/* TODO: Add evidence when available */}
{/* {protest.proofVideoUrl && (
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
<a
@@ -453,7 +437,7 @@ export default function ProtestReviewPage() {
<ExternalLink className="w-3 h-3" />
</a>
</Card>
)}
)} */}
{/* Quick Stats */}
<Card className="p-4">
@@ -461,7 +445,7 @@ export default function ProtestReviewPage() {
<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>
<span className="text-gray-300">{new Date(protest.submittedAt).toLocaleDateString()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Age</span>
@@ -497,18 +481,19 @@ export default function ProtestReviewPage() {
<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>
<span className="text-xs text-gray-500">{new Date(protest.submittedAt).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 && (
<p className="text-sm text-gray-300 mb-3">{protest.description}</p>
{/* TODO: Add comment when available */}
{/* {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>