wip league admin tools

This commit is contained in:
2025-12-28 12:04:12 +01:00
parent 5dc8c2399c
commit 6edf12fda8
401 changed files with 15365 additions and 6047 deletions

View File

@@ -68,6 +68,7 @@ export default function LeagueLayout({
];
const adminTabs = [
{ label: 'Schedule Admin', href: `/leagues/${leagueId}/schedule/admin`, exact: false },
{ label: 'Sponsorships', href: `/leagues/${leagueId}/sponsorships`, exact: false },
{ label: 'Stewarding', href: `/leagues/${leagueId}/stewarding`, exact: false },
{ label: 'Wallet', href: `/leagues/${leagueId}/wallet`, exact: false },

View File

@@ -0,0 +1,179 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import type { Mocked } from 'vitest';
import type { LeagueAdminRosterJoinRequestViewModel } from '@/lib/view-models/LeagueAdminRosterJoinRequestViewModel';
import type { LeagueAdminRosterMemberViewModel } from '@/lib/view-models/LeagueAdminRosterMemberViewModel';
import { RosterAdminPage } from './RosterAdminPage';
type RosterAdminLeagueService = {
getAdminRosterJoinRequests(leagueId: string): Promise<LeagueAdminRosterJoinRequestViewModel[]>;
getAdminRosterMembers(leagueId: string): Promise<LeagueAdminRosterMemberViewModel[]>;
approveJoinRequest(leagueId: string, joinRequestId: string): Promise<{ success: boolean }>;
rejectJoinRequest(leagueId: string, joinRequestId: string): Promise<{ success: boolean }>;
updateMemberRole(leagueId: string, driverId: string, role: string): Promise<{ success: boolean }>;
removeMember(leagueId: string, driverId: string): Promise<{ success: boolean }>;
};
let mockLeagueService: Mocked<RosterAdminLeagueService>;
vi.mock('next/navigation', () => ({
useParams: () => ({ id: 'league-1' }),
}));
vi.mock('@/lib/services/ServiceProvider', async (importOriginal) => {
const actual = (await importOriginal()) as object;
return {
...actual,
useServices: () => ({
leagueService: mockLeagueService,
}),
};
});
function makeJoinRequest(overrides: Partial<LeagueAdminRosterJoinRequestViewModel> = {}): LeagueAdminRosterJoinRequestViewModel {
return {
id: 'jr-1',
leagueId: 'league-1',
driverId: 'driver-1',
driverName: 'Driver One',
requestedAtIso: '2025-01-01T00:00:00.000Z',
message: 'Please let me in',
...overrides,
};
}
function makeMember(overrides: Partial<LeagueAdminRosterMemberViewModel> = {}): LeagueAdminRosterMemberViewModel {
return {
driverId: 'driver-10',
driverName: 'Member Ten',
role: 'member',
joinedAtIso: '2025-01-01T00:00:00.000Z',
...overrides,
};
}
describe('RosterAdminPage', () => {
beforeEach(() => {
mockLeagueService = {
getAdminRosterJoinRequests: vi.fn(),
getAdminRosterMembers: vi.fn(),
approveJoinRequest: vi.fn(),
rejectJoinRequest: vi.fn(),
updateMemberRole: vi.fn(),
removeMember: vi.fn(),
} as any;
});
it('renders join requests + members from service ViewModels', async () => {
const joinRequests: LeagueAdminRosterJoinRequestViewModel[] = [
makeJoinRequest({ id: 'jr-1', driverName: 'Driver One' }),
makeJoinRequest({ id: 'jr-2', driverName: 'Driver Two', driverId: 'driver-2' }),
];
const members: LeagueAdminRosterMemberViewModel[] = [
makeMember({ driverId: 'driver-10', driverName: 'Member Ten', role: 'member' }),
makeMember({ driverId: 'driver-11', driverName: 'Member Eleven', role: 'admin' }),
];
mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue(joinRequests);
mockLeagueService.getAdminRosterMembers.mockResolvedValue(members);
render(<RosterAdminPage />);
expect(await screen.findByText('Roster Admin')).toBeInTheDocument();
expect(await screen.findByText('Driver One')).toBeInTheDocument();
expect(screen.getByText('Driver Two')).toBeInTheDocument();
expect(await screen.findByText('Member Ten')).toBeInTheDocument();
expect(screen.getByText('Member Eleven')).toBeInTheDocument();
});
it('approves a join request and removes it from the pending list', async () => {
mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue([makeJoinRequest({ id: 'jr-1', driverName: 'Driver One' })]);
mockLeagueService.getAdminRosterMembers.mockResolvedValue([makeMember({ driverId: 'driver-10', driverName: 'Member Ten' })]);
mockLeagueService.approveJoinRequest.mockResolvedValue({ success: true } as any);
render(<RosterAdminPage />);
expect(await screen.findByText('Driver One')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('join-request-jr-1-approve'));
await waitFor(() => {
expect(mockLeagueService.approveJoinRequest).toHaveBeenCalledWith('league-1', 'jr-1');
});
await waitFor(() => {
expect(screen.queryByText('Driver One')).not.toBeInTheDocument();
});
});
it('rejects a join request and removes it from the pending list', async () => {
mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue([makeJoinRequest({ id: 'jr-2', driverName: 'Driver Two' })]);
mockLeagueService.getAdminRosterMembers.mockResolvedValue([makeMember({ driverId: 'driver-10', driverName: 'Member Ten' })]);
mockLeagueService.rejectJoinRequest.mockResolvedValue({ success: true } as any);
render(<RosterAdminPage />);
expect(await screen.findByText('Driver Two')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('join-request-jr-2-reject'));
await waitFor(() => {
expect(mockLeagueService.rejectJoinRequest).toHaveBeenCalledWith('league-1', 'jr-2');
});
await waitFor(() => {
expect(screen.queryByText('Driver Two')).not.toBeInTheDocument();
});
});
it('changes a member role via service and updates the displayed role', async () => {
mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue([]);
mockLeagueService.getAdminRosterMembers.mockResolvedValue([
makeMember({ driverId: 'driver-11', driverName: 'Member Eleven', role: 'member' }),
]);
mockLeagueService.updateMemberRole.mockResolvedValue({ success: true } as any);
render(<RosterAdminPage />);
expect(await screen.findByText('Member Eleven')).toBeInTheDocument();
const roleSelect = screen.getByLabelText('Role for Member Eleven') as HTMLSelectElement;
expect(roleSelect.value).toBe('member');
fireEvent.change(roleSelect, { target: { value: 'admin' } });
await waitFor(() => {
expect(mockLeagueService.updateMemberRole).toHaveBeenCalledWith('league-1', 'driver-11', 'admin');
});
await waitFor(() => {
expect((screen.getByLabelText('Role for Member Eleven') as HTMLSelectElement).value).toBe('admin');
});
});
it('removes a member via service and removes them from the list', async () => {
mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue([]);
mockLeagueService.getAdminRosterMembers.mockResolvedValue([
makeMember({ driverId: 'driver-12', driverName: 'Member Twelve', role: 'member' }),
]);
mockLeagueService.removeMember.mockResolvedValue({ success: true } as any);
render(<RosterAdminPage />);
expect(await screen.findByText('Member Twelve')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('member-driver-12-remove'));
await waitFor(() => {
expect(mockLeagueService.removeMember).toHaveBeenCalledWith('league-1', 'driver-12');
});
await waitFor(() => {
expect(screen.queryByText('Member Twelve')).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,180 @@
'use client';
import Card from '@/components/ui/Card';
import type { LeagueAdminRosterJoinRequestViewModel } from '@/lib/view-models/LeagueAdminRosterJoinRequestViewModel';
import type { LeagueAdminRosterMemberViewModel } from '@/lib/view-models/LeagueAdminRosterMemberViewModel';
import type { MembershipRole } from '@/lib/types/MembershipRole';
import { useServices } from '@/lib/services/ServiceProvider';
import { useParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
export function RosterAdminPage() {
const params = useParams();
const leagueId = params.id as string;
const { leagueService } = useServices();
const [loading, setLoading] = useState(true);
const [joinRequests, setJoinRequests] = useState<LeagueAdminRosterJoinRequestViewModel[]>([]);
const [members, setMembers] = useState<LeagueAdminRosterMemberViewModel[]>([]);
const loadRoster = async () => {
setLoading(true);
try {
const [requestsVm, membersVm] = await Promise.all([
leagueService.getAdminRosterJoinRequests(leagueId),
leagueService.getAdminRosterMembers(leagueId),
]);
setJoinRequests(requestsVm);
setMembers(membersVm);
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadRoster();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leagueId]);
const pendingCountLabel = useMemo(() => {
return joinRequests.length === 1 ? '1 request' : `${joinRequests.length} requests`;
}, [joinRequests.length]);
const handleApprove = async (joinRequestId: string) => {
await leagueService.approveJoinRequest(leagueId, joinRequestId);
setJoinRequests((prev) => prev.filter((r) => r.id !== joinRequestId));
};
const handleReject = async (joinRequestId: string) => {
await leagueService.rejectJoinRequest(leagueId, joinRequestId);
setJoinRequests((prev) => prev.filter((r) => r.id !== joinRequestId));
};
const handleRoleChange = async (driverId: string, newRole: MembershipRole) => {
setMembers((prev) => prev.map((m) => (m.driverId === driverId ? { ...m, role: newRole } : m)));
const result = await leagueService.updateMemberRole(leagueId, driverId, newRole);
if (!result.success) {
await loadRoster();
}
};
const handleRemove = async (driverId: string) => {
await leagueService.removeMember(leagueId, driverId);
setMembers((prev) => prev.filter((m) => m.driverId !== driverId));
};
return (
<div className="space-y-6">
<Card>
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold text-white">Roster Admin</h1>
<p className="text-sm text-gray-400">Manage join requests and member roles.</p>
</div>
<div className="border-t border-charcoal-outline pt-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-white">Pending join requests</h2>
<p className="text-xs text-gray-500">{pendingCountLabel}</p>
</div>
{loading ? (
<div className="py-4 text-sm text-gray-400">Loading</div>
) : joinRequests.length ? (
<div className="space-y-2">
{joinRequests.map((req) => (
<div
key={req.id}
className="flex items-center justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
>
<div className="min-w-0">
<p className="text-white font-medium truncate">{req.driverName}</p>
<p className="text-xs text-gray-400 truncate">{req.requestedAtIso}</p>
{req.message ? <p className="text-xs text-gray-500 truncate">{req.message}</p> : null}
</div>
<div className="flex items-center gap-2">
<button
type="button"
data-testid={`join-request-${req.id}-approve`}
onClick={() => handleApprove(req.id)}
className="px-3 py-1.5 rounded bg-primary-blue text-white"
>
Approve
</button>
<button
type="button"
data-testid={`join-request-${req.id}-reject`}
onClick={() => handleReject(req.id)}
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
>
Reject
</button>
</div>
</div>
))}
</div>
) : (
<div className="py-4 text-sm text-gray-500">No pending join requests.</div>
)}
</div>
<div className="border-t border-charcoal-outline pt-4 space-y-3">
<h2 className="text-lg font-semibold text-white">Members</h2>
{loading ? (
<div className="py-4 text-sm text-gray-400">Loading</div>
) : members.length ? (
<div className="space-y-2">
{members.map((member) => (
<div
key={member.driverId}
className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
>
<div className="min-w-0">
<p className="text-white font-medium truncate">{member.driverName}</p>
<p className="text-xs text-gray-400 truncate">{member.joinedAtIso}</p>
</div>
<div className="flex flex-col md:flex-row md:items-center gap-2">
<label className="text-xs text-gray-400" htmlFor={`role-${member.driverId}`}>
Role for {member.driverName}
</label>
<select
id={`role-${member.driverId}`}
aria-label={`Role for ${member.driverName}`}
value={member.role}
onChange={(e) => handleRoleChange(member.driverId, e.target.value as MembershipRole)}
className="bg-iron-gray text-white px-3 py-2 rounded"
>
{ROLE_OPTIONS.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
<button
type="button"
data-testid={`member-${member.driverId}-remove`}
onClick={() => handleRemove(member.driverId)}
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
>
Remove
</button>
</div>
</div>
))}
</div>
) : (
<div className="py-4 text-sm text-gray-500">No members found.</div>
)}
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,7 @@
'use client';
import { RosterAdminPage } from './RosterAdminPage';
export default function LeagueRosterAdminPage() {
return <RosterAdminPage />;
}

View File

@@ -0,0 +1,277 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import LeagueAdminSchedulePage from './page';
type SeasonSummaryViewModel = {
seasonId: string;
name: string;
status: string;
isPrimary: boolean;
isParallelActive: boolean;
};
type AdminScheduleRaceViewModel = {
id: string;
name: string;
scheduledAt: Date;
};
type AdminScheduleViewModel = {
seasonId: string;
published: boolean;
races: AdminScheduleRaceViewModel[];
};
const mockGetLeagueSeasonSummaries = vi.fn<() => Promise<SeasonSummaryViewModel[]>>();
const mockGetAdminSchedule = vi.fn<(leagueId: string, seasonId: string) => Promise<AdminScheduleViewModel>>();
const mockGetLeagueScheduleDto = vi.fn(() => {
throw new Error('LeagueAdminSchedulePage must not call getLeagueScheduleDto (DTO boundary violation)');
});
const mockPublishAdminSchedule = vi.fn<(leagueId: string, seasonId: string) => Promise<AdminScheduleViewModel>>();
const mockUnpublishAdminSchedule = vi.fn<(leagueId: string, seasonId: string) => Promise<AdminScheduleViewModel>>();
const mockCreateAdminScheduleRace = vi.fn<
(leagueId: string, seasonId: string, input: { track: string; car: string; scheduledAtIso: string }) => Promise<AdminScheduleViewModel>
>();
const mockUpdateAdminScheduleRace = vi.fn<
(
leagueId: string,
seasonId: string,
raceId: string,
input: Partial<{ track: string; car: string; scheduledAtIso: string }>,
) => Promise<AdminScheduleViewModel>
>();
const mockDeleteAdminScheduleRace = vi.fn<(leagueId: string, seasonId: string, raceId: string) => Promise<AdminScheduleViewModel>>();
const mockFetchLeagueMemberships = vi.fn<(leagueId: string) => Promise<unknown[]>>();
const mockGetMembership = vi.fn<
(leagueId: string, driverId: string) => { role: 'admin' | 'owner' | 'member' | 'steward' } | null
>();
vi.mock('next/navigation', () => ({
useParams: () => ({ id: 'league-1' }),
}));
vi.mock('@/hooks/useEffectiveDriverId', () => ({
useEffectiveDriverId: () => 'driver-1',
}));
const mockServices = {
leagueService: {
getLeagueSeasonSummaries: mockGetLeagueSeasonSummaries,
getAdminSchedule: mockGetAdminSchedule,
publishAdminSchedule: mockPublishAdminSchedule,
unpublishAdminSchedule: mockUnpublishAdminSchedule,
createAdminScheduleRace: mockCreateAdminScheduleRace,
updateAdminScheduleRace: mockUpdateAdminScheduleRace,
deleteAdminScheduleRace: mockDeleteAdminScheduleRace,
// Legacy method (should never be called by this page)
getLeagueScheduleDto: mockGetLeagueScheduleDto,
},
leagueMembershipService: {
fetchLeagueMemberships: mockFetchLeagueMemberships,
getMembership: mockGetMembership,
},
};
vi.mock('@/lib/services/ServiceProvider', () => ({
useServices: () => mockServices,
}));
function createAdminScheduleViewModel(overrides: Partial<AdminScheduleViewModel> = {}): AdminScheduleViewModel {
return {
seasonId: 'season-1',
published: false,
races: [],
...overrides,
};
}
describe('LeagueAdminSchedulePage', () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
mockGetLeagueSeasonSummaries.mockReset();
mockGetAdminSchedule.mockReset();
mockGetLeagueScheduleDto.mockClear();
mockPublishAdminSchedule.mockReset();
mockUnpublishAdminSchedule.mockReset();
mockCreateAdminScheduleRace.mockReset();
mockUpdateAdminScheduleRace.mockReset();
mockDeleteAdminScheduleRace.mockReset();
mockFetchLeagueMemberships.mockReset();
mockGetMembership.mockReset();
mockFetchLeagueMemberships.mockResolvedValue([]);
mockGetMembership.mockReturnValue({ role: 'admin' });
});
it('renders schedule using ViewModel fields (no DTO date field)', async () => {
mockGetLeagueSeasonSummaries.mockResolvedValue([
{ seasonId: 'season-1', name: 'Season 1', status: 'active', isPrimary: true, isParallelActive: false },
]);
mockGetAdminSchedule.mockResolvedValue(
createAdminScheduleViewModel({
seasonId: 'season-1',
published: true,
races: [{ id: 'race-1', name: 'Race 1', scheduledAt: new Date('2025-01-01T12:00:00.000Z') }],
}),
);
render(<LeagueAdminSchedulePage />);
expect(await screen.findByText('Schedule Admin')).toBeInTheDocument();
expect(await screen.findByText('Race 1')).toBeInTheDocument();
expect(await screen.findByText('2025-01-01T12:00:00.000Z')).toBeInTheDocument();
expect(screen.getByText(/Status:/)).toHaveTextContent('Published');
await waitFor(() => {
expect(mockGetAdminSchedule).toHaveBeenCalledWith('league-1', 'season-1');
});
expect(mockGetLeagueScheduleDto).not.toHaveBeenCalled();
});
it('publish/unpublish uses admin schedule service API and updates UI status', async () => {
mockGetLeagueSeasonSummaries.mockResolvedValue([
{ seasonId: 'season-1', name: 'Season 1', status: 'active', isPrimary: true, isParallelActive: false },
]);
mockGetAdminSchedule.mockResolvedValue(createAdminScheduleViewModel({ published: false }));
mockPublishAdminSchedule.mockResolvedValue(createAdminScheduleViewModel({ published: true }));
mockUnpublishAdminSchedule.mockResolvedValue(createAdminScheduleViewModel({ published: false }));
render(<LeagueAdminSchedulePage />);
expect(await screen.findByText(/Status:/)).toHaveTextContent('Unpublished');
await waitFor(() => {
expect(mockGetAdminSchedule).toHaveBeenCalledWith('league-1', 'season-1');
});
expect(mockGetLeagueScheduleDto).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Publish' })).toBeEnabled();
});
fireEvent.click(screen.getByRole('button', { name: 'Publish' }));
await waitFor(() => {
expect(mockPublishAdminSchedule).toHaveBeenCalledWith('league-1', 'season-1');
});
await waitFor(() => {
expect(screen.getByText(/Status:/)).toHaveTextContent('Published');
});
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Unpublish' })).toBeEnabled();
});
fireEvent.click(screen.getByRole('button', { name: 'Unpublish' }));
await waitFor(() => {
expect(mockUnpublishAdminSchedule).toHaveBeenCalledWith('league-1', 'season-1');
});
await waitFor(() => {
expect(screen.getByText(/Status:/)).toHaveTextContent('Unpublished');
});
});
it('create/update/delete uses admin schedule service API and refreshes schedule list', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
mockGetLeagueSeasonSummaries.mockResolvedValue([
{ seasonId: 'season-1', name: 'Season 1', status: 'active', isPrimary: true, isParallelActive: false },
]);
mockGetAdminSchedule.mockResolvedValueOnce(createAdminScheduleViewModel({ published: false, races: [] }));
mockCreateAdminScheduleRace.mockResolvedValueOnce(
createAdminScheduleViewModel({
races: [{ id: 'race-1', name: 'Race 1', scheduledAt: new Date('2025-01-01T12:00:00.000Z') }],
}),
);
mockUpdateAdminScheduleRace.mockResolvedValueOnce(
createAdminScheduleViewModel({
races: [{ id: 'race-1', name: 'Race 1', scheduledAt: new Date('2025-01-02T12:00:00.000Z') }],
}),
);
mockDeleteAdminScheduleRace.mockResolvedValueOnce(createAdminScheduleViewModel({ races: [] }));
render(<LeagueAdminSchedulePage />);
await screen.findByText('Schedule Admin');
await waitFor(() => {
expect(mockGetAdminSchedule).toHaveBeenCalledWith('league-1', 'season-1');
});
expect(mockGetLeagueScheduleDto).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.queryByText('Loading…')).toBeNull();
});
await screen.findByLabelText('Track');
await screen.findByLabelText('Car');
await screen.findByLabelText('Scheduled At (ISO)');
fireEvent.change(screen.getByLabelText('Track'), { target: { value: 'Laguna Seca' } });
fireEvent.change(screen.getByLabelText('Car'), { target: { value: 'MX-5' } });
fireEvent.change(screen.getByLabelText('Scheduled At (ISO)'), { target: { value: '2025-01-01T12:00:00.000Z' } });
fireEvent.click(screen.getByRole('button', { name: 'Add race' }));
await waitFor(() => {
expect(mockCreateAdminScheduleRace).toHaveBeenCalledWith('league-1', 'season-1', {
track: 'Laguna Seca',
car: 'MX-5',
scheduledAtIso: '2025-01-01T12:00:00.000Z',
});
});
expect(await screen.findByText('Race 1')).toBeInTheDocument();
expect(await screen.findByText('2025-01-01T12:00:00.000Z')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
fireEvent.change(screen.getByLabelText('Scheduled At (ISO)'), { target: { value: '2025-01-02T12:00:00.000Z' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(mockUpdateAdminScheduleRace).toHaveBeenCalledWith('league-1', 'season-1', 'race-1', {
scheduledAtIso: '2025-01-02T12:00:00.000Z',
});
});
expect(await screen.findByText('2025-01-02T12:00:00.000Z')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
await waitFor(() => {
expect(mockDeleteAdminScheduleRace).toHaveBeenCalledWith('league-1', 'season-1', 'race-1');
});
await waitFor(() => {
expect(screen.queryByText('Race 1')).toBeNull();
});
confirmSpy.mockRestore();
});
});

View File

@@ -0,0 +1,331 @@
'use client';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import type { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel';
import type { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel';
import { useServices } from '@/lib/services/ServiceProvider';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
export default function LeagueAdminSchedulePage() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const { leagueService, leagueMembershipService } = useServices();
const [isAdmin, setIsAdmin] = useState(false);
const [membershipLoading, setMembershipLoading] = useState(true);
const [seasons, setSeasons] = useState<LeagueSeasonSummaryViewModel[]>([]);
const [seasonId, setSeasonId] = useState<string>('');
const [schedule, setSchedule] = useState<LeagueAdminScheduleViewModel | null>(null);
const [loading, setLoading] = useState(false);
const [track, setTrack] = useState('');
const [car, setCar] = useState('');
const [scheduledAtIso, setScheduledAtIso] = useState('');
const [editingRaceId, setEditingRaceId] = useState<string | null>(null);
const isEditing = editingRaceId !== null;
const publishedLabel = schedule?.published ? 'Published' : 'Unpublished';
const selectedSeasonLabel = useMemo(() => {
const selected = seasons.find((s) => s.seasonId === seasonId);
return selected?.name ?? seasonId;
}, [seasons, seasonId]);
const loadSchedule = async (leagueIdToLoad: string, seasonIdToLoad: string) => {
setLoading(true);
try {
const vm = await leagueService.getAdminSchedule(leagueIdToLoad, seasonIdToLoad);
setSchedule(vm);
} finally {
setLoading(false);
}
};
useEffect(() => {
async function checkAdmin() {
setMembershipLoading(true);
try {
await leagueMembershipService.fetchLeagueMemberships(leagueId);
} finally {
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
setMembershipLoading(false);
}
}
checkAdmin();
}, [leagueId, currentDriverId, leagueMembershipService]);
useEffect(() => {
async function loadSeasons() {
const loaded = await leagueService.getLeagueSeasonSummaries(leagueId);
setSeasons(loaded);
if (loaded.length > 0) {
const active = loaded.find((s) => s.status === 'active') ?? loaded[0];
setSeasonId(active?.seasonId ?? '');
}
}
if (isAdmin) {
loadSeasons();
}
}, [leagueId, isAdmin, leagueService]);
useEffect(() => {
if (!isAdmin) return;
if (!seasonId) return;
loadSchedule(leagueId, seasonId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leagueId, seasonId, isAdmin]);
const handlePublishToggle = async () => {
if (!schedule) return;
if (schedule.published) {
const vm = await leagueService.unpublishAdminSchedule(leagueId, seasonId);
setSchedule(vm);
return;
}
const vm = await leagueService.publishAdminSchedule(leagueId, seasonId);
setSchedule(vm);
};
const handleAddOrSave = async () => {
if (!seasonId) return;
if (!scheduledAtIso) return;
if (!isEditing) {
const vm = await leagueService.createAdminScheduleRace(leagueId, seasonId, {
track,
car,
scheduledAtIso,
});
setSchedule(vm);
setTrack('');
setCar('');
setScheduledAtIso('');
return;
}
const vm = await leagueService.updateAdminScheduleRace(leagueId, seasonId, editingRaceId, {
...(track ? { track } : {}),
...(car ? { car } : {}),
...(scheduledAtIso ? { scheduledAtIso } : {}),
});
setSchedule(vm);
setEditingRaceId(null);
};
const handleEdit = (raceId: string) => {
if (!schedule) return;
const race = schedule.races.find((r) => r.id === raceId);
if (!race) return;
setEditingRaceId(raceId);
setTrack('');
setCar('');
setScheduledAtIso(race.scheduledAt.toISOString());
};
const handleDelete = async (raceId: string) => {
const confirmed = window.confirm('Delete this race?');
if (!confirmed) return;
const vm = await leagueService.deleteAdminScheduleRace(leagueId, seasonId, raceId);
setSchedule(vm);
};
if (membershipLoading) {
return (
<Card>
<div className="py-6 text-sm text-gray-400">Loading</div>
</Card>
);
}
if (!isAdmin) {
return (
<Card>
<div className="text-center py-12">
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
<p className="text-sm text-gray-400">Only league admins can manage the schedule.</p>
</div>
</Card>
);
}
return (
<div className="space-y-6">
<Card>
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold text-white">Schedule Admin</h1>
<p className="text-sm text-gray-400">Create, edit, and publish season races.</p>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm text-gray-300" htmlFor="seasonId">
Season
</label>
{seasons.length > 0 ? (
<select
id="seasonId"
value={seasonId}
onChange={(e) => setSeasonId(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
>
{seasons.map((s) => (
<option key={s.seasonId} value={s.seasonId}>
{s.name}
</option>
))}
</select>
) : (
<input
id="seasonId"
value={seasonId}
onChange={(e) => setSeasonId(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
placeholder="season-id"
/>
)}
<p className="text-xs text-gray-500">Selected: {selectedSeasonLabel}</p>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-gray-300">
Status: <span className="font-medium text-white">{publishedLabel}</span>
</p>
<button
type="button"
onClick={handlePublishToggle}
disabled={!schedule}
className="px-3 py-1.5 rounded bg-primary-blue text-white disabled:opacity-50"
>
{schedule?.published ? 'Unpublish' : 'Publish'}
</button>
</div>
<div className="border-t border-charcoal-outline pt-4 space-y-3">
<h2 className="text-lg font-semibold text-white">{isEditing ? 'Edit race' : 'Add race'}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="track" className="text-sm text-gray-300">
Track
</label>
<input
id="track"
value={track}
onChange={(e) => setTrack(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="car" className="text-sm text-gray-300">
Car
</label>
<input
id="car"
value={car}
onChange={(e) => setCar(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="scheduledAtIso" className="text-sm text-gray-300">
Scheduled At (ISO)
</label>
<input
id="scheduledAtIso"
value={scheduledAtIso}
onChange={(e) => setScheduledAtIso(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
placeholder="2025-01-01T12:00:00.000Z"
/>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleAddOrSave}
className="px-3 py-1.5 rounded bg-primary-blue text-white"
>
{isEditing ? 'Save' : 'Add race'}
</button>
{isEditing && (
<button
type="button"
onClick={() => setEditingRaceId(null)}
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
>
Cancel
</button>
)}
</div>
</div>
<div className="border-t border-charcoal-outline pt-4 space-y-3">
<h2 className="text-lg font-semibold text-white">Races</h2>
{loading ? (
<div className="py-4 text-sm text-gray-400">Loading schedule</div>
) : schedule?.races.length ? (
<div className="space-y-2">
{schedule.races.map((race) => (
<div
key={race.id}
className="flex items-center justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
>
<div className="min-w-0">
<p className="text-white font-medium truncate">{race.name}</p>
<p className="text-xs text-gray-400 truncate">{race.scheduledAt.toISOString()}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleEdit(race.id)}
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
>
Edit
</button>
<button
type="button"
onClick={() => handleDelete(race.id)}
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
>
Delete
</button>
</div>
</div>
))}
</div>
) : (
<div className="py-4 text-sm text-gray-500">No races yet.</div>
)}
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import ProtestReviewPage from './page';
// Mocks for Next.js navigation
const mockPush = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
useParams: () => ({ id: 'league-1', protestId: 'protest-1' }),
}));
// Mock effective driver id hook
vi.mock('@/hooks/useEffectiveDriverId', () => ({
useEffectiveDriverId: () => 'driver-1',
}));
const mockGetProtestDetailViewModel = vi.fn();
const mockFetchLeagueMemberships = vi.fn();
const mockGetMembership = vi.fn();
vi.mock('@/lib/services/ServiceProvider', () => ({
useServices: () => ({
leagueStewardingService: {
getProtestDetailViewModel: mockGetProtestDetailViewModel,
},
protestService: {
applyPenalty: vi.fn(),
requestDefense: vi.fn(),
},
leagueMembershipService: {
fetchLeagueMemberships: mockFetchLeagueMemberships,
getMembership: mockGetMembership,
},
}),
}));
const mockIsLeagueAdminOrHigherRole = vi.fn();
vi.mock('@/lib/utilities/LeagueRoleUtility', () => ({
LeagueRoleUtility: {
isLeagueAdminOrHigherRole: (...args: unknown[]) => mockIsLeagueAdminOrHigherRole(...args),
},
}));
describe('ProtestReviewPage', () => {
beforeEach(() => {
mockPush.mockReset();
mockGetProtestDetailViewModel.mockReset();
mockFetchLeagueMemberships.mockReset();
mockGetMembership.mockReset();
mockIsLeagueAdminOrHigherRole.mockReset();
mockFetchLeagueMemberships.mockResolvedValue(undefined);
mockGetMembership.mockReturnValue({ role: 'admin' });
mockIsLeagueAdminOrHigherRole.mockReturnValue(true);
});
it('loads protest detail via LeagueStewardingService view model method', async () => {
mockGetProtestDetailViewModel.mockResolvedValue({
protest: {
id: 'protest-1',
raceId: 'race-1',
protestingDriverId: 'driver-1',
accusedDriverId: 'driver-2',
description: 'desc',
submittedAt: '2023-10-01T10:00:00Z',
status: 'pending',
incident: { lap: 1 },
},
race: {
id: 'race-1',
name: 'Test Race',
formattedDate: '10/1/2023',
},
protestingDriver: { id: 'driver-1', name: 'Driver 1' },
accusedDriver: { id: 'driver-2', name: 'Driver 2' },
penaltyTypes: [
{
type: 'time_penalty',
label: 'Time Penalty',
description: 'Add seconds to race result',
requiresValue: true,
valueLabel: 'seconds',
defaultValue: 5,
},
],
defaultReasons: { upheld: 'Upheld reason', dismissed: 'Dismissed reason' },
initialPenaltyType: 'time_penalty',
initialPenaltyValue: 5,
});
render(<ProtestReviewPage />);
await waitFor(() => {
expect(mockGetProtestDetailViewModel).toHaveBeenCalledWith('league-1', 'protest-1');
});
expect(await screen.findByText('Protest Review')).toBeInTheDocument();
});
});

View File

@@ -5,11 +5,9 @@ import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useServices } from '@/lib/services/ServiceProvider';
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
import type { ProtestDetailViewModel } from '@/lib/view-models/ProtestDetailViewModel';
import { ProtestDriverViewModel } from '@/lib/view-models/ProtestDriverViewModel';
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
import type { PenaltyTypesReferenceDTO, PenaltyValueKindDTO } from '@/lib/types/PenaltyTypesReferenceDTO';
import {
AlertCircle,
AlertTriangle,
@@ -99,54 +97,18 @@ const PENALTY_UI: Record<string, PenaltyUiConfig> = {
},
};
function getPenaltyValueLabel(valueKind: PenaltyValueKindDTO): string {
switch (valueKind) {
case 'seconds':
return 'seconds';
case 'grid_positions':
return 'positions';
case 'points':
return 'points';
case 'races':
return 'races';
case 'none':
return '';
}
}
function getFallbackDefaultValue(valueKind: PenaltyValueKindDTO): number {
switch (valueKind) {
case 'seconds':
return 5;
case 'grid_positions':
return 3;
case 'points':
return 5;
case 'races':
return 1;
case 'none':
return 0;
}
}
export default function ProtestReviewPage() {
const params = useParams();
const router = useRouter();
const leagueId = params.id as string;
const protestId = params.protestId as string;
const currentDriverId = useEffectiveDriverId();
const { protestService, leagueMembershipService, penaltyService } = useServices();
const { leagueStewardingService, protestService, leagueMembershipService } = useServices();
const [protest, setProtest] = useState<ProtestViewModel | null>(null);
const [race, setRace] = useState<RaceViewModel | null>(null);
const [protestingDriver, setProtestingDriver] = useState<ProtestDriverViewModel | null>(null);
const [accusedDriver, setAccusedDriver] = useState<ProtestDriverViewModel | null>(null);
const [detail, setDetail] = useState<ProtestDetailViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [penaltyTypesReference, setPenaltyTypesReference] = useState<PenaltyTypesReferenceDTO | null>(null);
const [penaltyTypesLoading, setPenaltyTypesLoading] = useState(false);
// Decision state
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
@@ -156,24 +118,20 @@ export default function ProtestReviewPage() {
const [submitting, setSubmitting] = useState(false);
const penaltyTypes = useMemo(() => {
const referenceItems = penaltyTypesReference?.penaltyTypes ?? [];
const referenceItems = detail?.penaltyTypes ?? [];
return referenceItems.map((ref) => {
const ui = PENALTY_UI[ref.type] ?? {
label: ref.type.replaceAll('_', ' '),
description: '',
icon: Gavel,
color: 'text-gray-400 bg-gray-500/10 border-gray-500/20',
defaultValue: getFallbackDefaultValue(ref.valueKind),
};
return {
...ref,
...ui,
valueLabel: getPenaltyValueLabel(ref.valueKind),
defaultValue: ui.defaultValue ?? getFallbackDefaultValue(ref.valueKind),
icon: ui.icon,
color: ui.color,
};
});
}, [penaltyTypesReference]);
}, [detail?.penaltyTypes]);
const selectedPenalty = useMemo(() => {
return penaltyTypes.find((p) => p.type === penaltyType);
@@ -195,15 +153,14 @@ export default function ProtestReviewPage() {
async function loadProtest() {
setLoading(true);
try {
const protestData = await protestService.getProtestById(leagueId, protestId);
if (!protestData) {
throw new Error('Protest not found');
}
const protestDetail = await leagueStewardingService.getProtestDetailViewModel(leagueId, protestId);
setProtest(protestData.protest);
setRace(protestData.race);
setProtestingDriver(protestData.protestingDriver);
setAccusedDriver(protestData.accusedDriver);
setDetail(protestDetail);
if (protestDetail.initialPenaltyType) {
setPenaltyType(protestDetail.initialPenaltyType);
setPenaltyValue(protestDetail.initialPenaltyValue);
}
} catch (err) {
console.error('Failed to load protest:', err);
alert('Failed to load protest details');
@@ -216,43 +173,18 @@ export default function ProtestReviewPage() {
if (isAdmin) {
loadProtest();
}
}, [protestId, leagueId, isAdmin, router, protestService]);
useEffect(() => {
async function loadPenaltyTypes() {
if (!isAdmin) return;
if (penaltyTypesReference) return;
setPenaltyTypesLoading(true);
try {
const ref = await penaltyService.getPenaltyTypesReference();
setPenaltyTypesReference(ref);
const hasSelected = ref.penaltyTypes.some((p) => p.type === penaltyType);
const [first] = ref.penaltyTypes;
if (!hasSelected && first) {
setPenaltyType(first.type);
setPenaltyValue(PENALTY_UI[first.type]?.defaultValue ?? getFallbackDefaultValue(first.valueKind));
}
} catch (err) {
console.error('Failed to load penalty types reference:', err);
} finally {
setPenaltyTypesLoading(false);
}
}
loadPenaltyTypes();
}, [isAdmin, penaltyService, penaltyTypesReference, penaltyType]);
}, [protestId, leagueId, isAdmin, router, leagueStewardingService]);
const handleSubmitDecision = async () => {
if (!decision || !stewardNotes.trim() || !protest) return;
if (penaltyTypesLoading) return;
if (!decision || !stewardNotes.trim() || !detail) return;
setSubmitting(true);
try {
const defaultUpheldReason = penaltyTypesReference?.defaultReasons?.upheld;
const defaultDismissedReason = penaltyTypesReference?.defaultReasons?.dismissed;
const protest = detail.protest;
const defaultUpheldReason = detail.defaultReasons?.upheld;
const defaultDismissedReason = detail.defaultReasons?.dismissed;
if (decision === 'uphold') {
const requiresValue = selectedPenalty?.requiresValue ?? true;
@@ -287,7 +219,7 @@ export default function ProtestReviewPage() {
await protestService.applyPenalty(penaltyCommand);
} else {
const warningRef = penaltyTypesReference?.penaltyTypes.find((p) => p.type === 'warning');
const warningRef = detail.penaltyTypes.find((p) => p.type === 'warning');
const requiresValue = warningRef?.requiresValue ?? false;
const commandModel = new ProtestDecisionCommandModel({
@@ -330,12 +262,12 @@ export default function ProtestReviewPage() {
};
const handleRequestDefense = async () => {
if (!protest) return;
if (!detail) return;
try {
// Request defense
await protestService.requestDefense({
protestId: protest.id,
protestId: detail.protest.id,
stewardId: currentDriverId,
});
@@ -379,7 +311,7 @@ export default function ProtestReviewPage() {
);
}
if (loading || !protest || !race) {
if (loading || !detail) {
return (
<Card>
<div className="text-center py-12">
@@ -389,6 +321,11 @@ export default function ProtestReviewPage() {
);
}
const protest = detail.protest;
const race = detail.race;
const protestingDriver = detail.protestingDriver;
const accusedDriver = detail.accusedDriver;
const statusConfig = getStatusConfig(protest.status);
const StatusIcon = statusConfig.icon;
const isPending = protest.status === 'pending';

View File

@@ -5,7 +5,7 @@ import '@testing-library/jest-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import RaceDetailPage from './page';
import { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel';
import type { RaceDetailsViewModel } from '@/lib/view-models/RaceDetailsViewModel';
// Mocks for Next.js navigation
const mockPush = vi.fn();
@@ -40,7 +40,7 @@ vi.mock('@/components/sponsors/SponsorInsightsCard', () => ({
}));
// Mock services hook to provide raceService and leagueMembershipService
const mockGetRaceDetail = vi.fn();
const mockGetRaceDetails = vi.fn();
const mockReopenRace = vi.fn();
const mockFetchLeagueMemberships = vi.fn();
const mockGetMembership = vi.fn();
@@ -48,7 +48,7 @@ const mockGetMembership = vi.fn();
vi.mock('@/lib/services/ServiceProvider', () => ({
useServices: () => ({
raceService: {
getRaceDetail: mockGetRaceDetail,
getRaceDetails: mockGetRaceDetails,
reopenRace: mockReopenRace,
// other methods are not used in this test
},
@@ -79,8 +79,10 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
const createViewModel = (status: string) => {
return new RaceDetailViewModel({
const createViewModel = (status: string): RaceDetailsViewModel => {
const canReopenRace = status === 'completed' || status === 'cancelled';
return {
race: {
id: 'race-123',
track: 'Test Track',
@@ -88,10 +90,7 @@ const createViewModel = (status: string) => {
scheduledAt: '2023-12-31T20:00:00Z',
status,
sessionType: 'race',
strengthOfField: null,
registeredCount: 0,
maxParticipants: 32,
} as any,
},
league: {
id: 'league-1',
name: 'Test League',
@@ -100,19 +99,20 @@ const createViewModel = (status: string) => {
maxDrivers: 32,
qualifyingFormat: 'open',
},
} as any,
},
entryList: [],
registration: {
isRegistered: false,
isUserRegistered: false,
canRegister: false,
} as any,
},
userResult: null,
}, 'driver-1');
canReopenRace,
};
};
describe('RaceDetailPage - Re-open Race behavior', () => {
beforeEach(() => {
mockGetRaceDetail.mockReset();
mockGetRaceDetails.mockReset();
mockReopenRace.mockReset();
mockFetchLeagueMemberships.mockReset();
mockGetMembership.mockReset();
@@ -127,7 +127,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
const viewModel = createViewModel('completed');
// First call: initial load, second call: after re-open
mockGetRaceDetail.mockResolvedValue(viewModel);
mockGetRaceDetails.mockResolvedValue(viewModel);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
@@ -147,7 +147,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
// loadRaceData should be called again after reopening
await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled();
expect(mockGetRaceDetails).toHaveBeenCalled();
});
confirmSpy.mockRestore();
@@ -156,12 +156,12 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
it('does not render Re-open Race button for non-admin viewer', async () => {
mockIsOwnerOrAdmin.mockReturnValue(false);
const viewModel = createViewModel('completed');
mockGetRaceDetail.mockResolvedValue(viewModel);
mockGetRaceDetails.mockResolvedValue(viewModel);
renderWithQueryClient(<RaceDetailPage />);
await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled();
expect(mockGetRaceDetails).toHaveBeenCalled();
});
expect(screen.queryByText('Re-open Race')).toBeNull();
@@ -170,12 +170,12 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
it('does not render Re-open Race button when race is not completed or cancelled even for admin', async () => {
mockIsOwnerOrAdmin.mockReturnValue(true);
const viewModel = createViewModel('scheduled');
mockGetRaceDetail.mockResolvedValue(viewModel);
mockGetRaceDetails.mockResolvedValue(viewModel);
renderWithQueryClient(<RaceDetailPage />);
await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled();
expect(mockGetRaceDetails).toHaveBeenCalled();
});
expect(screen.queryByText('Re-open Race')).toBeNull();

View File

@@ -278,7 +278,7 @@ export default function RaceDetailPage() {
const entryList: RaceDetailEntryViewModel[] = viewModel.entryList;
const registration = viewModel.registration;
const userResult: RaceDetailUserResultViewModel | null = viewModel.userResult;
const raceSOF = null; // TODO: Add strengthOfField to RaceDetailRaceDTO
const raceSOF = null; // TODO: Add strength of field to race details response
const config = statusConfig[race.status as keyof typeof statusConfig];
const StatusIcon = config.icon;
@@ -636,7 +636,7 @@ export default function RaceDetailPage() {
{raceSOF ?? '—'}
</p>
</div>
{/* TODO: Add registeredCount and maxParticipants to RaceDetailRaceDTO */}
{/* TODO: Add registered count and max participants to race details response */}
{/* {race.registeredCount !== undefined && (
<div className="p-4 bg-deep-graphite rounded-lg">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Registered</p>