wip league admin tools
This commit is contained in:
277
apps/website/app/leagues/[id]/schedule/admin/page.test.tsx
Normal file
277
apps/website/app/leagues/[id]/schedule/admin/page.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
331
apps/website/app/leagues/[id]/schedule/admin/page.tsx
Normal file
331
apps/website/app/leagues/[id]/schedule/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user