website refactor
This commit is contained in:
@@ -1,280 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
|
||||
import { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import {
|
||||
Award,
|
||||
Clock,
|
||||
Flag,
|
||||
Flame,
|
||||
Globe,
|
||||
Plus,
|
||||
Search,
|
||||
Sparkles,
|
||||
Target,
|
||||
Timer,
|
||||
Trophy,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
type CategoryId =
|
||||
| 'all'
|
||||
| 'driver'
|
||||
| 'team'
|
||||
| 'nations'
|
||||
| 'trophy'
|
||||
| 'new'
|
||||
| 'popular'
|
||||
| 'openSlots'
|
||||
| 'endurance'
|
||||
| 'sprint';
|
||||
|
||||
interface Category {
|
||||
id: CategoryId;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
description: string;
|
||||
filter: (league: LeaguesViewData['leagues'][number]) => boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface LeaguesTemplateProps {
|
||||
viewData: LeaguesViewData;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CATEGORIES
|
||||
// ============================================================================
|
||||
|
||||
const CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All',
|
||||
icon: Globe,
|
||||
description: 'Browse all available leagues',
|
||||
filter: () => true,
|
||||
},
|
||||
{
|
||||
id: 'popular',
|
||||
label: 'Popular',
|
||||
icon: Flame,
|
||||
description: 'Most active leagues right now',
|
||||
filter: (league) => {
|
||||
const fillRate = (league.usedDriverSlots ?? 0) / (league.maxDrivers ?? 1);
|
||||
return fillRate > 0.7;
|
||||
},
|
||||
color: 'text-orange-400',
|
||||
},
|
||||
{
|
||||
id: 'new',
|
||||
label: 'New',
|
||||
icon: Sparkles,
|
||||
description: 'Fresh leagues looking for members',
|
||||
filter: (league) => {
|
||||
const oneWeekAgo = new Date();
|
||||
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
||||
return new Date(league.createdAt) > oneWeekAgo;
|
||||
},
|
||||
color: 'text-green-500',
|
||||
},
|
||||
{
|
||||
id: 'openSlots',
|
||||
label: 'Open Slots',
|
||||
icon: Target,
|
||||
description: 'Leagues with available spots',
|
||||
filter: (league) => {
|
||||
if (league.maxTeams && league.maxTeams > 0) {
|
||||
const usedTeams = league.usedTeamSlots ?? 0;
|
||||
return usedTeams < league.maxTeams;
|
||||
}
|
||||
const used = league.usedDriverSlots ?? 0;
|
||||
const max = league.maxDrivers ?? 0;
|
||||
return max > 0 && used < max;
|
||||
},
|
||||
color: 'text-cyan-400',
|
||||
},
|
||||
{
|
||||
id: 'driver',
|
||||
label: 'Driver',
|
||||
icon: Trophy,
|
||||
description: 'Compete as an individual',
|
||||
filter: (league) => league.scoring?.primaryChampionshipType === 'driver',
|
||||
},
|
||||
{
|
||||
id: 'team',
|
||||
label: 'Team',
|
||||
icon: Users,
|
||||
description: 'Race together as a team',
|
||||
filter: (league) => league.scoring?.primaryChampionshipType === 'team',
|
||||
},
|
||||
{
|
||||
id: 'nations',
|
||||
label: 'Nations',
|
||||
icon: Flag,
|
||||
description: 'Represent your country',
|
||||
filter: (league) => league.scoring?.primaryChampionshipType === 'nations',
|
||||
},
|
||||
{
|
||||
id: 'trophy',
|
||||
label: 'Trophy',
|
||||
icon: Award,
|
||||
description: 'Special championship events',
|
||||
filter: (league) => league.scoring?.primaryChampionshipType === 'trophy',
|
||||
},
|
||||
{
|
||||
id: 'endurance',
|
||||
label: 'Endurance',
|
||||
icon: Timer,
|
||||
description: 'Long-distance racing',
|
||||
filter: (league) =>
|
||||
league.scoring?.scoringPresetId?.includes('endurance') ??
|
||||
league.timingSummary?.includes('h Race') ??
|
||||
false,
|
||||
},
|
||||
{
|
||||
id: 'sprint',
|
||||
label: 'Sprint',
|
||||
icon: Clock,
|
||||
description: 'Quick, intense races',
|
||||
filter: (league) =>
|
||||
(league.scoring?.scoringPresetId?.includes('sprint') ?? false) &&
|
||||
!(league.scoring?.scoringPresetId?.includes('endurance') ?? false),
|
||||
},
|
||||
];
|
||||
|
||||
export function LeaguesPageClient({ viewData }: LeaguesTemplateProps) {
|
||||
const router = useRouter();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryId>('all');
|
||||
|
||||
const filteredLeagues = viewData.leagues.filter((league) => {
|
||||
const matchesSearch = !searchQuery ||
|
||||
league.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(league.description ?? '').toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const category = CATEGORIES.find(c => c.id === activeCategory);
|
||||
const matchesCategory = !category || category.filter(league);
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
return (
|
||||
<Box minHeight="screen" bg="zinc-950" color="text-zinc-200">
|
||||
<Box maxWidth="7xl" mx="auto" px={{ base: 4, sm: 6, lg: 8 }} py={12}>
|
||||
{/* Hero */}
|
||||
<Box as="header" display="flex" flexDirection={{ base: 'col', md: 'row' }} alignItems={{ base: 'start', md: 'end' }} justifyContent="between" gap={8} mb={16}>
|
||||
<Stack gap={4}>
|
||||
<Box display="flex" alignItems="center" gap={3} color="text-blue-500">
|
||||
<Trophy size={24} />
|
||||
<Text fontSize="xs" weight="bold" uppercase letterSpacing="widest">Competition Hub</Text>
|
||||
</Box>
|
||||
<Heading level={1} fontSize="5xl" weight="bold" color="text-white">
|
||||
Find Your <Text as="span" color="text-blue-500">Grid</Text>
|
||||
</Heading>
|
||||
<Text color="text-zinc-400" maxWidth="md" leading="relaxed">
|
||||
From casual sprints to epic endurance battles — discover the perfect league for your racing style.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Box display="flex" flexDirection="col" alignItems="end">
|
||||
<Text fontSize="2xl" weight="bold" color="text-white" font="mono">{viewData.leagues.length}</Text>
|
||||
<Text weight="bold" color="text-zinc-500" uppercase letterSpacing="widest" fontSize="10px">Active Leagues</Text>
|
||||
</Box>
|
||||
<Box w="px" h="8" bg="zinc-800" />
|
||||
<Button
|
||||
onClick={() => router.push(routes.league.create)}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Plus size={16} />
|
||||
Create League
|
||||
</Stack>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<Box as="section" display="flex" flexDirection="col" gap={8} mb={12}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search leagues by name, description, or game..."
|
||||
value={searchQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
|
||||
icon={<Search size={20} />}
|
||||
/>
|
||||
|
||||
<Box as="nav" display="flex" flexWrap="wrap" gap={2}>
|
||||
{CATEGORIES.map((category) => {
|
||||
const isActive = activeCategory === category.id;
|
||||
const CategoryIcon = category.icon;
|
||||
return (
|
||||
<Button
|
||||
key={category.id}
|
||||
onClick={() => setActiveCategory(category.id)}
|
||||
variant={isActive ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Box
|
||||
color={!isActive && category.color ? category.color : undefined}
|
||||
>
|
||||
<CategoryIcon size={14} />
|
||||
</Box>
|
||||
<Text>{category.label}</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Grid */}
|
||||
<Box as="main">
|
||||
{filteredLeagues.length > 0 ? (
|
||||
<Box display="grid" responsiveGridCols={{ base: 1, md: 2, lg: 3 }} gap={6}>
|
||||
{filteredLeagues.map((league) => (
|
||||
<LeagueCard
|
||||
key={league.id}
|
||||
league={league as unknown as LeagueSummaryViewModel}
|
||||
onClick={() => router.push(routes.league.detail(league.id))}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Box display="flex" flexDirection="col" alignItems="center" justifyContent="center" py={24} border borderStyle="dashed" borderColor="zinc-800" bg="zinc-900/20">
|
||||
<Box color="text-zinc-800" mb={4}>
|
||||
<Search size={48} />
|
||||
</Box>
|
||||
<Heading level={3} fontSize="xl" weight="bold" color="text-zinc-500">No Leagues Found</Heading>
|
||||
<Text color="text-zinc-600" size="sm" mt={2}>Try adjusting your search or filters</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
mt={6}
|
||||
onClick={() => { setSearchQuery(''); setActiveCategory('all'); }}
|
||||
>
|
||||
Clear All Filters
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { MembershipRole } from '@/lib/types/MembershipRole';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
useLeagueJoinRequests,
|
||||
useLeagueRosterAdmin,
|
||||
useApproveJoinRequest,
|
||||
useRejectJoinRequest,
|
||||
useUpdateMemberRole,
|
||||
useRemoveMember,
|
||||
} from "@/hooks/league/useLeagueRosterAdmin";
|
||||
import { RosterAdminTemplate } from '@/templates/RosterAdminTemplate';
|
||||
import type { JoinRequestData, RosterMemberData } from '@/lib/view-data/LeagueRosterAdminViewData';
|
||||
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO';
|
||||
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
|
||||
|
||||
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
|
||||
|
||||
export function RosterAdminPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
|
||||
// Fetch data using React-Query + DI
|
||||
const {
|
||||
data: joinRequests = [],
|
||||
isLoading: loadingJoinRequests,
|
||||
refetch: refetchJoinRequests,
|
||||
} = useLeagueJoinRequests(leagueId);
|
||||
|
||||
const {
|
||||
data: members = [],
|
||||
isLoading: loadingMembers,
|
||||
refetch: refetchMembers,
|
||||
} = useLeagueRosterAdmin(leagueId);
|
||||
|
||||
const loading = loadingJoinRequests || loadingMembers;
|
||||
|
||||
// Mutations
|
||||
const approveMutation = useApproveJoinRequest({
|
||||
onSuccess: () => refetchJoinRequests(),
|
||||
});
|
||||
|
||||
const rejectMutation = useRejectJoinRequest({
|
||||
onSuccess: () => refetchJoinRequests(),
|
||||
});
|
||||
|
||||
const updateRoleMutation = useUpdateMemberRole({
|
||||
onError: () => refetchMembers(), // Refetch on error to restore state
|
||||
});
|
||||
|
||||
const removeMemberMutation = useRemoveMember({
|
||||
onSuccess: () => refetchMembers(),
|
||||
});
|
||||
|
||||
const pendingCountLabel = useMemo(() => {
|
||||
return joinRequests.length === 1 ? '1 request' : `${joinRequests.length} requests`;
|
||||
}, [joinRequests.length]);
|
||||
|
||||
const handleApprove = async (requestId: string) => {
|
||||
await approveMutation.mutateAsync({ leagueId, requestId });
|
||||
};
|
||||
|
||||
const handleReject = async (requestId: string) => {
|
||||
await rejectMutation.mutateAsync({ leagueId, requestId });
|
||||
};
|
||||
|
||||
const handleRoleChange = async (driverId: string, newRole: MembershipRole) => {
|
||||
await updateRoleMutation.mutateAsync({ leagueId, driverId, newRole });
|
||||
};
|
||||
|
||||
const handleRemove = async (driverId: string) => {
|
||||
await removeMemberMutation.mutateAsync({ leagueId, driverId });
|
||||
};
|
||||
|
||||
const viewData = useMemo(() => ({
|
||||
leagueId,
|
||||
joinRequests: joinRequests.map((req: LeagueRosterJoinRequestDTO): JoinRequestData => ({
|
||||
id: req.id,
|
||||
driver: req.driver as { id: string; name: string },
|
||||
requestedAt: req.requestedAt,
|
||||
message: req.message || undefined,
|
||||
})),
|
||||
members: members.map((m: LeagueRosterMemberDTO): RosterMemberData => ({
|
||||
driverId: m.driverId,
|
||||
driver: m.driver as { id: string; name: string },
|
||||
role: m.role,
|
||||
joinedAt: m.joinedAt,
|
||||
})),
|
||||
}), [leagueId, joinRequests, members]);
|
||||
|
||||
return (
|
||||
<RosterAdminTemplate
|
||||
viewData={viewData}
|
||||
loading={loading}
|
||||
pendingCountLabel={pendingCountLabel}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
onRoleChange={handleRoleChange}
|
||||
onRemove={handleRemove}
|
||||
roleOptions={ROLE_OPTIONS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { RosterAdminPage } from './RosterAdminPage';
|
||||
import { RosterAdminPage } from '@/client-wrapper/RosterAdminPage';
|
||||
|
||||
export default function LeagueRosterAdminPage() {
|
||||
return <RosterAdminPage />;
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate';
|
||||
import { type RulebookSection } from '@/components/leagues/RulebookTabs';
|
||||
import type { LeagueRulebookViewData } from '@/lib/view-data/LeagueRulebookViewData';
|
||||
|
||||
interface LeagueRulebookPageClientProps {
|
||||
viewData: LeagueRulebookViewData;
|
||||
}
|
||||
|
||||
export function LeagueRulebookPageClient({ viewData }: LeagueRulebookPageClientProps) {
|
||||
const [activeSection, setActiveSection] = useState<RulebookSection>('scoring');
|
||||
|
||||
return (
|
||||
<LeagueRulebookTemplate
|
||||
viewData={viewData}
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createRaceAction,
|
||||
deleteRaceAction,
|
||||
publishScheduleAction,
|
||||
unpublishScheduleAction,
|
||||
updateRaceAction
|
||||
} from '@/app/actions/leagueScheduleActions';
|
||||
import { PageWrapper } from '@/ui/PageWrapper';
|
||||
import { ConfirmDialog } from '@/ui/ConfirmDialog';
|
||||
import {
|
||||
useLeagueAdminSchedule,
|
||||
useLeagueAdminStatus,
|
||||
useLeagueSeasons
|
||||
} from "@/hooks/league/useLeagueScheduleAdminPageData";
|
||||
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
|
||||
import { RaceScheduleCommandModel } from '@/lib/command-models/leagues/RaceScheduleCommandModel';
|
||||
import { LeagueAdminScheduleTemplate } from '@/templates/LeagueAdminScheduleTemplate';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function LeagueAdminSchedulePageClient() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId() || '';
|
||||
const router = useRouter();
|
||||
|
||||
// Form state
|
||||
const [seasonId, setSeasonId] = useState<string>('');
|
||||
const [form, setForm] = useState(() => new RaceScheduleCommandModel());
|
||||
const [editingRaceId, setEditingRaceId] = useState<string | null>(null);
|
||||
|
||||
// Action state
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [deletingRaceId, setDeletingRaceId] = useState<string | null>(null);
|
||||
const [raceToDelete, setRaceToDelete] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Check admin status using domain hook
|
||||
const { data: isAdmin, isLoading: isAdminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
|
||||
|
||||
// Load seasons using domain hook
|
||||
const { data: seasonsData, isLoading: seasonsLoading } = useLeagueSeasons(leagueId, !!isAdmin);
|
||||
|
||||
// Auto-select season
|
||||
const selectedSeasonId = seasonId || (seasonsData && seasonsData.length > 0
|
||||
? (seasonsData.find((s) => s.status === 'active') ?? seasonsData[0])?.seasonId || ''
|
||||
: '');
|
||||
|
||||
// Load schedule using domain hook
|
||||
const { data: schedule, isLoading: scheduleLoading } = useLeagueAdminSchedule(leagueId, selectedSeasonId, !!isAdmin);
|
||||
|
||||
// Handlers
|
||||
const handleSeasonChange = (newSeasonId: string) => {
|
||||
setSeasonId(newSeasonId);
|
||||
setEditingRaceId(null);
|
||||
setForm(new RaceScheduleCommandModel());
|
||||
};
|
||||
|
||||
const handlePublishToggle = async () => {
|
||||
if (!schedule || !selectedSeasonId) return;
|
||||
|
||||
setIsPublishing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = schedule.published
|
||||
? await unpublishScheduleAction(leagueId, selectedSeasonId)
|
||||
: await publishScheduleAction(leagueId, selectedSeasonId);
|
||||
|
||||
if (result.isOk()) {
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(result.getError());
|
||||
}
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddOrSave = async () => {
|
||||
if (!selectedSeasonId) return;
|
||||
|
||||
const validationErrors = form.validate();
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = !editingRaceId
|
||||
? await createRaceAction(leagueId, selectedSeasonId, form.toCommand())
|
||||
: await updateRaceAction(leagueId, selectedSeasonId, editingRaceId, form.toCommand());
|
||||
|
||||
if (result.isOk()) {
|
||||
// Reset form
|
||||
setForm(new RaceScheduleCommandModel());
|
||||
setEditingRaceId(null);
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(result.getError());
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (raceId: string) => {
|
||||
if (!schedule) return;
|
||||
const race = schedule.races.find((r) => r.id === raceId);
|
||||
if (!race) return;
|
||||
|
||||
setEditingRaceId(raceId);
|
||||
setForm(new RaceScheduleCommandModel({
|
||||
track: race.track || '',
|
||||
car: race.car || '',
|
||||
scheduledAtIso: race.scheduledAt.toISOString(),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDelete = (raceId: string) => {
|
||||
setRaceToDelete(raceId);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!selectedSeasonId || !raceToDelete) return;
|
||||
|
||||
setDeletingRaceId(raceToDelete);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await deleteRaceAction(leagueId, selectedSeasonId, raceToDelete);
|
||||
if (result.isOk()) {
|
||||
router.refresh();
|
||||
setRaceToDelete(null);
|
||||
} else {
|
||||
setError(result.getError());
|
||||
}
|
||||
} finally {
|
||||
setDeletingRaceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingRaceId(null);
|
||||
setForm(new RaceScheduleCommandModel());
|
||||
};
|
||||
|
||||
// Derived states
|
||||
const isLoading = isAdminLoading || seasonsLoading || scheduleLoading;
|
||||
|
||||
// Prepare template data
|
||||
const templateData = schedule && seasonsData && selectedSeasonId
|
||||
? {
|
||||
published: schedule.published,
|
||||
races: schedule.races.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
track: r.track || '',
|
||||
car: r.car || '',
|
||||
scheduledAt: r.scheduledAt.toISOString(),
|
||||
})),
|
||||
seasons: seasonsData.map(s => ({
|
||||
seasonId: s.seasonId,
|
||||
name: s.name,
|
||||
})),
|
||||
seasonId: selectedSeasonId,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Render admin access required if not admin
|
||||
if (!isLoading && !isAdmin) {
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Card>
|
||||
<Box p={6} textAlign="center">
|
||||
<Heading level={3}>Admin Access Required</Heading>
|
||||
<Box mt={2}>
|
||||
<Text size="sm" color="text-gray-400">Only league admins can manage the schedule.</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Template component that wraps the actual template with all props
|
||||
const TemplateWrapper = ({ data }: { data: typeof templateData }) => {
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LeagueAdminScheduleTemplate
|
||||
viewData={data}
|
||||
onSeasonChange={handleSeasonChange}
|
||||
onPublishToggle={handlePublishToggle}
|
||||
onAddOrSave={handleAddOrSave}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
track={form.track}
|
||||
car={form.car}
|
||||
scheduledAtIso={form.scheduledAtIso}
|
||||
editingRaceId={editingRaceId}
|
||||
isPublishing={isPublishing}
|
||||
isSaving={isSaving}
|
||||
isDeleting={deletingRaceId}
|
||||
error={error}
|
||||
setTrack={(val) => {
|
||||
form.track = val;
|
||||
setForm(new RaceScheduleCommandModel(form.toCommand()));
|
||||
}}
|
||||
setCar={(val) => {
|
||||
form.car = val;
|
||||
setForm(new RaceScheduleCommandModel(form.toCommand()));
|
||||
}}
|
||||
setScheduledAtIso={(val) => {
|
||||
form.scheduledAtIso = val;
|
||||
setForm(new RaceScheduleCommandModel(form.toCommand()));
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
isOpen={!!raceToDelete}
|
||||
onClose={() => setRaceToDelete(null)}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete Race"
|
||||
description="Are you sure you want to delete this race? This will remove it from the schedule and cannot be undone."
|
||||
confirmLabel="Delete Race"
|
||||
variant="danger"
|
||||
isLoading={!!deletingRaceId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
data={templateData}
|
||||
isLoading={isLoading}
|
||||
error={null}
|
||||
Template={TemplateWrapper}
|
||||
loading={{ variant: 'full-screen', message: 'Loading schedule admin...' }}
|
||||
empty={{
|
||||
title: 'No schedule data available',
|
||||
description: 'Unable to load schedule administration data',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LeagueScheduleAdminPageQuery } from '@/lib/page-queries/LeagueScheduleAdminPageQuery';
|
||||
import { LeagueAdminSchedulePageClient } from './LeagueAdminSchedulePageClient';
|
||||
import { LeagueAdminSchedulePageClient } from '@/client-wrapper/LeagueAdminSchedulePageClient';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { PenaltyHistoryList } from '@/components/leagues/PenaltyHistoryList';
|
||||
import { QuickPenaltyModal } from '@/components/leagues/QuickPenaltyModal';
|
||||
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
|
||||
import { StewardingQueuePanel } from '@/components/leagues/StewardingQueuePanel';
|
||||
import { StewardingStats } from '@/components/leagues/StewardingStats';
|
||||
import { PenaltyFAB } from '@/components/races/PenaltyFAB';
|
||||
import { useLeagueStewardingMutations } from "@/hooks/league/useLeagueStewardingMutations";
|
||||
import type { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
|
||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
|
||||
import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
interface StewardingTemplateProps {
|
||||
data: StewardingViewData;
|
||||
leagueId: string;
|
||||
currentDriverId: string;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
export function StewardingPageClient({ data, currentDriverId, onRefetch }: StewardingTemplateProps) {
|
||||
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
|
||||
const [selectedProtest, setSelectedProtest] = useState<ProtestViewModel | null>(null);
|
||||
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
|
||||
|
||||
// Mutations using domain hook
|
||||
const { acceptProtestMutation, rejectProtestMutation } = useLeagueStewardingMutations(onRefetch);
|
||||
|
||||
// Flatten protests for the specialized list components
|
||||
const allPendingProtests = useMemo(() => {
|
||||
return data.races.flatMap(r => r.pendingProtests.map(p => ({
|
||||
id: p.id,
|
||||
raceName: r.track || 'Unknown Track',
|
||||
protestingDriver: data.drivers.find(d => d.id === p.protestingDriverId)?.name || 'Unknown',
|
||||
accusedDriver: data.drivers.find(d => d.id === p.accusedDriverId)?.name || 'Unknown',
|
||||
description: p.incident.description,
|
||||
submittedAt: p.filedAt,
|
||||
status: p.status as 'pending' | 'under_review' | 'resolved' | 'rejected',
|
||||
})));
|
||||
}, [data.races, data.drivers]);
|
||||
|
||||
const allResolvedProtests = useMemo(() => {
|
||||
return data.races.flatMap(r => r.resolvedProtests.map(p => new ProtestViewModel({
|
||||
id: p.id,
|
||||
protestingDriverId: p.protestingDriverId,
|
||||
accusedDriverId: p.accusedDriverId,
|
||||
description: p.incident.description,
|
||||
submittedAt: p.filedAt,
|
||||
status: p.status,
|
||||
raceId: r.id,
|
||||
incident: p.incident,
|
||||
proofVideoUrl: p.proofVideoUrl,
|
||||
decisionNotes: p.decisionNotes,
|
||||
} as never)));
|
||||
}, [data.races]);
|
||||
|
||||
const racesMap = useMemo(() => {
|
||||
const map: Record<string, RaceViewModel> = {};
|
||||
data.races.forEach(r => {
|
||||
map[r.id] = new RaceViewModel({
|
||||
id: r.id,
|
||||
name: '',
|
||||
date: r.scheduledAt,
|
||||
track: r.track,
|
||||
} as never);
|
||||
});
|
||||
return map;
|
||||
}, [data.races]);
|
||||
|
||||
const driverMap = useMemo(() => {
|
||||
const map: Record<string, DriverViewModel> = {};
|
||||
data.drivers.forEach(d => {
|
||||
map[d.id] = new DriverViewModel({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
iracingId: '',
|
||||
country: '',
|
||||
joinedAt: '',
|
||||
avatarUrl: null,
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [data.drivers]);
|
||||
|
||||
const handleAcceptProtest = async (
|
||||
protestId: string,
|
||||
penaltyType: string,
|
||||
penaltyValue: number,
|
||||
stewardNotes: string
|
||||
) => {
|
||||
// Find the protest to get details for penalty
|
||||
let foundProtest: { raceId: string; accusedDriverId: string; incident: { description: string } } | undefined;
|
||||
data.races.forEach((raceData) => {
|
||||
const p = raceData.pendingProtests.find((pr) => pr.id === protestId) ||
|
||||
raceData.resolvedProtests.find((pr) => pr.id === protestId);
|
||||
if (p) foundProtest = {
|
||||
raceId: raceData.id,
|
||||
accusedDriverId: p.accusedDriverId,
|
||||
incident: { description: p.incident.description }
|
||||
};
|
||||
});
|
||||
|
||||
if (foundProtest) {
|
||||
acceptProtestMutation.mutate({
|
||||
protestId,
|
||||
penaltyType,
|
||||
penaltyValue,
|
||||
stewardNotes,
|
||||
raceId: foundProtest.raceId,
|
||||
accusedDriverId: foundProtest.accusedDriverId,
|
||||
reason: foundProtest.incident.description,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
|
||||
rejectProtestMutation.mutate({
|
||||
protestId,
|
||||
stewardNotes,
|
||||
});
|
||||
};
|
||||
|
||||
const handleReviewProtest = (id: string) => {
|
||||
// Find the protest in the data
|
||||
let foundProtest: ProtestViewModel | null = null;
|
||||
data.races.forEach(r => {
|
||||
const p = r.pendingProtests.find(p => p.id === id);
|
||||
if (p) {
|
||||
foundProtest = new ProtestViewModel({
|
||||
id: p.id,
|
||||
protestingDriverId: p.protestingDriverId,
|
||||
accusedDriverId: p.accusedDriverId,
|
||||
description: p.incident.description,
|
||||
submittedAt: p.filedAt,
|
||||
status: p.status,
|
||||
raceId: r.id,
|
||||
incident: p.incident,
|
||||
proofVideoUrl: p.proofVideoUrl,
|
||||
decisionNotes: p.decisionNotes,
|
||||
} as never);
|
||||
}
|
||||
});
|
||||
if (foundProtest) setSelectedProtest(foundProtest);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<StewardingStats
|
||||
totalPending={data.totalPending}
|
||||
totalResolved={data.totalResolved}
|
||||
totalPenalties={data.totalPenalties}
|
||||
/>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<Box borderBottom borderColor="border-charcoal-outline">
|
||||
<Stack direction="row" gap={4}>
|
||||
<Box
|
||||
borderBottom={activeTab === 'pending'}
|
||||
borderColor={activeTab === 'pending' ? 'border-primary-blue' : undefined}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setActiveTab('pending')}
|
||||
rounded="none"
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text weight="medium" color={activeTab === 'pending' ? 'text-primary-blue' : undefined}>Pending Protests</Text>
|
||||
{data.totalPending > 0 && (
|
||||
<Box px={2} py={0.5} fontSize="0.75rem" bg="bg-warning-amber/20" color="text-warning-amber" rounded="full">
|
||||
{data.totalPending}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Button>
|
||||
</Box>
|
||||
<Box
|
||||
borderBottom={activeTab === 'history'}
|
||||
borderColor={activeTab === 'history' ? 'border-primary-blue' : undefined}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setActiveTab('history')}
|
||||
rounded="none"
|
||||
>
|
||||
<Text weight="medium" color={activeTab === 'history' ? 'text-primary-blue' : undefined}>History</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'pending' ? (
|
||||
<StewardingQueuePanel
|
||||
protests={allPendingProtests}
|
||||
onReview={handleReviewProtest}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<Box p={6}>
|
||||
<PenaltyHistoryList
|
||||
protests={allResolvedProtests}
|
||||
races={racesMap}
|
||||
drivers={driverMap}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
|
||||
)}
|
||||
|
||||
{selectedProtest && (
|
||||
<ReviewProtestModal
|
||||
protest={selectedProtest}
|
||||
onClose={() => setSelectedProtest(null)}
|
||||
onAccept={handleAcceptProtest}
|
||||
onReject={handleRejectProtest}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showQuickPenaltyModal && (
|
||||
<QuickPenaltyModal
|
||||
drivers={data.drivers.map(d => new DriverViewModel({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
iracingId: '',
|
||||
country: '',
|
||||
joinedAt: '',
|
||||
avatarUrl: null,
|
||||
}))}
|
||||
onClose={() => setShowQuickPenaltyModal(false)}
|
||||
adminId={currentDriverId || ''}
|
||||
races={data.races.map(r => ({ id: r.id, track: r.track, scheduledAt: new Date(r.scheduledAt) }))}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LeagueStewardingPageQuery } from '@/lib/page-queries/LeagueStewardingPageQuery';
|
||||
import { StewardingPageClient } from './StewardingPageClient';
|
||||
import { StewardingPageClient } from '@/client-wrapper/StewardingPageClient';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,808 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
|
||||
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Flag,
|
||||
Gavel,
|
||||
Grid3x3,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
Send,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
TrendingDown,
|
||||
User,
|
||||
Video,
|
||||
XCircle,
|
||||
type LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
// Shared state components
|
||||
import { LoadingWrapper } from '@/ui/LoadingWrapper';
|
||||
import { StateContainer } from '@/ui/StateContainer';
|
||||
import { useLeagueAdminStatus } from "@/hooks/league/useLeagueAdminStatus";
|
||||
import { useProtestDetail } from "@/hooks/league/useProtestDetail";
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon as UIIcon } from '@/ui/Icon';
|
||||
import { Link as UILink } from '@/ui/Link';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { GridItem } from '@/ui/GridItem';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
type PenaltyUiConfig = {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
color: string;
|
||||
defaultValue?: number;
|
||||
};
|
||||
|
||||
const PENALTY_UI: Record<string, PenaltyUiConfig> = {
|
||||
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({ initialViewData }: { initialViewData: unknown }) {
|
||||
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
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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 ?? [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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: {
|
||||
requiresValue?: boolean;
|
||||
defaultUpheldReason?: string;
|
||||
defaultDismissedReason?: string;
|
||||
} = { 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 {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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: {
|
||||
requiresValue?: boolean;
|
||||
defaultUpheldReason?: string;
|
||||
defaultDismissedReason?: string;
|
||||
} = { 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 {
|
||||
// Request defense
|
||||
const result = await protestService.requestDefense({
|
||||
protestId: protestDetail.protest?.id || protestDetail.protestId,
|
||||
stewardId: currentDriverId,
|
||||
});
|
||||
if (result.isErr()) {
|
||||
throw new Error(result.getError().message);
|
||||
}
|
||||
|
||||
// Reload page to show updated status
|
||||
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 };
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading for admin check
|
||||
if (adminLoading) {
|
||||
return <LoadingWrapper variant="full-screen" message="Checking permissions..." />;
|
||||
}
|
||||
|
||||
// Show access denied if not admin
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Card>
|
||||
<Box p={12} textAlign="center">
|
||||
<Box w={16} h={16} mx="auto" mb={4} rounded="full" bg="bg-iron-gray/50" display="flex" alignItems="center" justifyContent="center">
|
||||
<UIIcon icon={AlertTriangle} size={8} color="text-warning-amber" />
|
||||
</Box>
|
||||
<Heading level={3}>Admin Access Required</Heading>
|
||||
<Box mt={2}>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
Only league admins can review protests.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StateContainer
|
||||
data={protestDetail}
|
||||
isLoading={detailLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={{
|
||||
loading: { variant: 'spinner', message: 'Loading protest details...' },
|
||||
error: { variant: 'full-screen' },
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{(pd: any) => {
|
||||
if (!pd) return null;
|
||||
|
||||
const protest = pd.protest || pd;
|
||||
const race = pd.race;
|
||||
const protestingDriver = pd.protestingDriver;
|
||||
const accusedDriver = pd.accusedDriver;
|
||||
|
||||
const statusConfig = getStatusConfig(protest.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const isPending = protest.status === 'pending';
|
||||
const submittedAt = protest.submittedAt || pd.submittedAt;
|
||||
const daysSinceFiled = Math.floor((Date.now() - new Date(submittedAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return (
|
||||
<Box minHeight="100vh">
|
||||
{/* Compact Header */}
|
||||
<Box mb={6}>
|
||||
<Stack direction="row" align="center" gap={3} mb={4}>
|
||||
<UILink href={routes.league.stewarding(leagueId)}>
|
||||
<UIIcon icon={ArrowLeft} size={5} color="text-gray-400" />
|
||||
</UILink>
|
||||
<Stack direction="row" align="center" gap={3} flexGrow={1}>
|
||||
<Heading level={1}>Protest Review</Heading>
|
||||
<Box display="flex" alignItems="center" gap={1.5} px={2.5} py={1} rounded="full" fontSize="0.75rem" weight="medium" border bg={statusConfig.bg} color={statusConfig.color} borderColor={statusConfig.borderColor}>
|
||||
<UIIcon icon={StatusIcon} size={3} />
|
||||
<Text>{statusConfig.label}</Text>
|
||||
</Box>
|
||||
{daysSinceFiled > 2 && isPending && (
|
||||
<Box display="flex" alignItems="center" gap={1} px={2} py={0.5} fontSize="0.75rem" weight="medium" bg="bg-red-500/20" color="text-red-400" rounded="full">
|
||||
<UIIcon icon={AlertTriangle} size={3} />
|
||||
<Text>{daysSinceFiled}d old</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Main Layout: Feed + Sidebar */}
|
||||
<Grid cols={12} gap={6}>
|
||||
{/* Left Sidebar - Incident Info */}
|
||||
<GridItem colSpan={12} lgSpan={3}>
|
||||
<Stack gap={4}>
|
||||
{/* Drivers Involved */}
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Parties Involved</Heading>
|
||||
|
||||
<Stack gap={3}>
|
||||
{/* Protesting Driver */}
|
||||
<UILink href={routes.driver.detail(protestingDriver?.id || '')} block>
|
||||
<Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-blue-500/50" hoverBg="bg-blue-500/5" transition cursor="pointer">
|
||||
<Box w={10} h={10} rounded="full" bg="bg-blue-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<UIIcon icon={User} size={5} color="text-blue-400" />
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth={0}>
|
||||
<Text size="xs" color="text-blue-400" weight="medium" block>Protesting</Text>
|
||||
<Text size="sm" weight="semibold" color="text-white" truncate block>{protestingDriver?.name || 'Unknown'}</Text>
|
||||
</Box>
|
||||
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
|
||||
</Box>
|
||||
</UILink>
|
||||
|
||||
{/* Accused Driver */}
|
||||
<UILink href={routes.driver.detail(accusedDriver?.id || '')} block>
|
||||
<Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-orange-500/50" hoverBg="bg-orange-500/5" transition cursor="pointer">
|
||||
<Box w={10} h={10} rounded="full" bg="bg-orange-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<UIIcon icon={User} size={5} color="text-orange-400" />
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth={0}>
|
||||
<Text size="xs" color="text-orange-400" weight="medium" block>Accused</Text>
|
||||
<Text size="sm" weight="semibold" color="text-white" truncate block>{accusedDriver?.name || 'Unknown'}</Text>
|
||||
</Box>
|
||||
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
|
||||
</Box>
|
||||
</UILink>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Race Info */}
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Race Details</Heading>
|
||||
|
||||
<UILink
|
||||
href={routes.race.detail(race?.id || '')}
|
||||
block
|
||||
mb={3}
|
||||
>
|
||||
<Box p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-primary-blue/50" hoverBg="bg-primary-blue/5" transition>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Text size="sm" weight="medium" color="text-white">{race?.name || 'Unknown Race'}</Text>
|
||||
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
|
||||
</Box>
|
||||
</Box>
|
||||
</UILink>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<UIIcon icon={MapPin} size={4} color="text-gray-500" />
|
||||
<Text size="sm" color="text-gray-300">{race?.name || 'Unknown Track'}</Text>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<UIIcon icon={Calendar} size={4} color="text-gray-500" />
|
||||
<Text size="sm" color="text-gray-300">{race?.formattedDate || (race?.scheduledAt ? new Date(race.scheduledAt).toLocaleDateString() : 'Unknown Date')}</Text>
|
||||
</Box>
|
||||
{protest.incident?.lap && (
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<UIIcon icon={Flag} size={4} color="text-gray-500" />
|
||||
<Text size="sm" color="text-gray-300">Lap {protest.incident.lap}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{protest.proofVideoUrl && (
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Evidence</Heading>
|
||||
<UILink
|
||||
href={protest.proofVideoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
block
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={2} p={3} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" color="text-primary-blue" hoverBg="bg-primary-blue/20" transition>
|
||||
<UIIcon icon={Video} size={4} />
|
||||
<Text size="sm" weight="medium" flexGrow={1}>Watch Video</Text>
|
||||
<UIIcon icon={ExternalLink} size={3} />
|
||||
</Box>
|
||||
</UILink>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Timeline</Heading>
|
||||
<Stack gap={2}>
|
||||
<Box display="flex" justifyContent="between">
|
||||
<Text size="sm" color="text-gray-500">Filed</Text>
|
||||
<Text size="sm" color="text-gray-300">{new Date(submittedAt).toLocaleDateString()}</Text>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="between">
|
||||
<Text size="sm" color="text-gray-500">Age</Text>
|
||||
<Text size="sm" color={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</Text>
|
||||
</Box>
|
||||
{protest.reviewedAt && (
|
||||
<Box display="flex" justifyContent="between">
|
||||
<Text size="sm" color="text-gray-500">Resolved</Text>
|
||||
<Text size="sm" color="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
</Stack>
|
||||
</GridItem>
|
||||
|
||||
{/* Center - Discussion Feed */}
|
||||
<GridItem colSpan={12} lgSpan={6}>
|
||||
<Stack gap={4}>
|
||||
{/* Timeline / Feed */}
|
||||
<Card>
|
||||
<Box borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/30" p={4}>
|
||||
<Heading level={2}>Discussion</Heading>
|
||||
</Box>
|
||||
|
||||
<Stack gap={0}>
|
||||
{/* Initial Protest Filing */}
|
||||
<Box p={4}>
|
||||
<Box display="flex" gap={3}>
|
||||
<Box w={10} h={10} rounded="full" bg="bg-blue-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<UIIcon icon={AlertCircle} size={5} color="text-blue-400" />
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth={0}>
|
||||
<Box display="flex" alignItems="center" gap={2} mb={1}>
|
||||
<Text weight="semibold" color="text-white" size="sm">{protestingDriver?.name || 'Unknown'}</Text>
|
||||
<Text size="xs" color="text-blue-400" weight="medium">filed protest</Text>
|
||||
<Text size="xs" color="text-gray-500">•</Text>
|
||||
<Text size="xs" color="text-gray-500">{new Date(submittedAt).toLocaleString()}</Text>
|
||||
</Box>
|
||||
|
||||
<Box bg="bg-deep-graphite" rounded="lg" p={4} border borderColor="border-charcoal-outline">
|
||||
<Text size="sm" color="text-gray-300" block mb={3}>{protest.description || pd.incident?.description}</Text>
|
||||
|
||||
{(protest.comment || pd.comment) && (
|
||||
<Box mt={3} pt={3} borderTop borderColor="border-charcoal-outline/50">
|
||||
<Text size="xs" color="text-gray-500" block mb={1}>Additional details:</Text>
|
||||
<Text size="sm" color="text-gray-400">{protest.comment || pd.comment}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Defense placeholder */}
|
||||
{protest.status === 'awaiting_defense' && (
|
||||
<Box p={4} bg="bg-purple-500/5">
|
||||
<Box display="flex" gap={3}>
|
||||
<Box w={10} h={10} rounded="full" bg="bg-purple-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<UIIcon icon={MessageCircle} size={5} color="text-purple-400" />
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text size="sm" color="text-purple-400" weight="medium" block mb={1}>Defense Requested</Text>
|
||||
<Text size="sm" color="text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Decision (if resolved) */}
|
||||
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
|
||||
<Box p={4} bg={protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}>
|
||||
<Box display="flex" gap={3}>
|
||||
<Box w={10} h={10} rounded="full" display="flex" alignItems="center" justifyContent="center" flexShrink={0} bg={protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'}>
|
||||
<UIIcon icon={Gavel} size={5} color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'} />
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth={0}>
|
||||
<Box display="flex" alignItems="center" gap={2} mb={1}>
|
||||
<Text weight="semibold" color="text-white" size="sm">Steward Decision</Text>
|
||||
<Text size="xs" weight="medium" color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}>
|
||||
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
|
||||
</Text>
|
||||
{protest.reviewedAt && (
|
||||
<>
|
||||
<Text size="xs" color="text-gray-500">•</Text>
|
||||
<Text size="xs" color="text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box rounded="lg" p={4} border bg={protest.status === 'upheld' ? 'bg-red-500/10' : 'bg-gray-500/10'} borderColor={protest.status === 'upheld' ? 'border-red-500/20' : 'border-gray-500/20'}>
|
||||
<Text size="sm" color="text-gray-300">{protest.decisionNotes}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Add Comment */}
|
||||
{isPending && (
|
||||
<Box p={4} borderTop borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
|
||||
<Box display="flex" gap={3}>
|
||||
<Box w={10} h={10} rounded="full" bg="bg-iron-gray" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<UIIcon icon={User} size={5} color="text-gray-500" />
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Box as="textarea"
|
||||
value={newComment}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewComment(e.target.value)}
|
||||
placeholder="Add a comment or request more information..."
|
||||
rows={2}
|
||||
w="full"
|
||||
px={4}
|
||||
py={3}
|
||||
bg="bg-deep-graphite"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="lg"
|
||||
color="text-white"
|
||||
fontSize="sm"
|
||||
/>
|
||||
<Box display="flex" justifyContent="end" mt={2}>
|
||||
<Button variant="secondary" disabled={!newComment.trim()}>
|
||||
<UIIcon icon={Send} size={3} mr={1} />
|
||||
Comment
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</GridItem>
|
||||
|
||||
{/* Right Sidebar - Actions */}
|
||||
<GridItem colSpan={12} lgSpan={3}>
|
||||
<Stack gap={4}>
|
||||
{isPending && (
|
||||
<>
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Actions</Heading>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={handleRequestDefense}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<UIIcon icon={MessageCircle} size={4} />
|
||||
<Text>Request Defense</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2} fullWidth>
|
||||
<UIIcon icon={Gavel} size={4} />
|
||||
<Text>Make Decision</Text>
|
||||
<Box ml="auto" transition transform={showDecisionPanel ? 'rotate(180deg)' : 'none'}>
|
||||
<UIIcon icon={ChevronDown} size={4} />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Decision Panel */}
|
||||
{showDecisionPanel && (
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Stewarding Decision</Heading>
|
||||
|
||||
{/* Decision Selection */}
|
||||
<Grid cols={2} gap={2} mb={4}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDecision('uphold')}
|
||||
p={3}
|
||||
border
|
||||
borderColor={decision === 'uphold' ? 'border-racing-red' : 'border-charcoal-outline'}
|
||||
bg={decision === 'uphold' ? 'bg-racing-red/10' : 'transparent'}
|
||||
>
|
||||
<Stack align="center" gap={1}>
|
||||
<UIIcon icon={CheckCircle} size={5} color={decision === 'uphold' ? 'text-red-400' : 'text-gray-500'} />
|
||||
<Text size="xs" weight="medium" color={decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}>Uphold</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDecision('dismiss')}
|
||||
p={3}
|
||||
border
|
||||
borderColor={decision === 'dismiss' ? 'border-gray-500' : 'border-charcoal-outline'}
|
||||
bg={decision === 'dismiss' ? 'bg-gray-500/10' : 'transparent'}
|
||||
>
|
||||
<Stack align="center" gap={1}>
|
||||
<UIIcon icon={XCircle} size={5} color={decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'} />
|
||||
<Text size="xs" weight="medium" color={decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}>Dismiss</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
{/* Penalty Selection (if upholding) */}
|
||||
{decision === 'uphold' && (
|
||||
<Box mb={4}>
|
||||
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={2}>Penalty Type</Text>
|
||||
|
||||
{penaltyTypes.length === 0 ? (
|
||||
<Text size="xs" color="text-gray-500">
|
||||
Loading penalty types...
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Grid cols={2} gap={2}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{penaltyTypes.map((penalty: any) => {
|
||||
const Icon = penalty.icon;
|
||||
const isSelected = penaltyType === penalty.type;
|
||||
return (
|
||||
<Button
|
||||
key={penalty.type}
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setPenaltyType(penalty.type);
|
||||
setPenaltyValue(penalty.defaultValue);
|
||||
}}
|
||||
p={2}
|
||||
border
|
||||
borderColor={isSelected ? undefined : 'border-charcoal-outline'}
|
||||
bg={isSelected ? undefined : 'bg-iron-gray/30'}
|
||||
color={isSelected ? penalty.color : undefined}
|
||||
title={penalty.description}
|
||||
>
|
||||
<Stack align="start" gap={0.5}>
|
||||
<UIIcon icon={Icon} size={3.5} color={isSelected ? undefined : 'text-gray-500'} />
|
||||
<Text size="xs" weight="medium" fontSize="10px" color={isSelected ? undefined : 'text-gray-500'}>
|
||||
{penalty.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
|
||||
{selectedPenalty?.requiresValue && (
|
||||
<Box mt={3}>
|
||||
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1}>
|
||||
Value ({selectedPenalty.valueLabel})
|
||||
</Text>
|
||||
<Box as="input"
|
||||
type="number"
|
||||
value={penaltyValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPenaltyValue(Number(e.target.value))}
|
||||
min="1"
|
||||
w="full"
|
||||
px={3}
|
||||
py={2}
|
||||
bg="bg-deep-graphite"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="lg"
|
||||
color="text-white"
|
||||
fontSize="sm"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Steward Notes */}
|
||||
<Box mb={4}>
|
||||
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1}>Decision Reasoning *</Text>
|
||||
<Box as="textarea"
|
||||
value={stewardNotes}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setStewardNotes(e.target.value)}
|
||||
placeholder="Explain your decision..."
|
||||
rows={4}
|
||||
w="full"
|
||||
px={3}
|
||||
py={2}
|
||||
bg="bg-deep-graphite"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="lg"
|
||||
color="text-white"
|
||||
fontSize="sm"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={handleSubmitDecision}
|
||||
disabled={!decision || !stewardNotes.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Decision'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Already Resolved Info */}
|
||||
{!isPending && (
|
||||
<Card>
|
||||
<Box p={4} textAlign="center">
|
||||
<Box py={4} color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}>
|
||||
<UIIcon icon={Gavel} size={8} mx="auto" mb={2} />
|
||||
<Text weight="semibold" block>Case Closed</Text>
|
||||
<Text size="xs" color="text-gray-500" block mt={1}>
|
||||
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LeagueProtestDetailPageQuery } from '@/lib/page-queries/LeagueProtestDetailPageQuery';
|
||||
import { ProtestDetailPageClient } from './ProtestDetailPageClient';
|
||||
import { ProtestDetailPageClient } from '@/client-wrapper/ProtestDetailPageClient';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string; protestId: string }>;
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { WalletSummaryPanel } from '@/components/leagues/WalletSummaryPanel';
|
||||
import type { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon as UIIcon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import {
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WalletTemplateProps {
|
||||
viewData: LeagueWalletViewData;
|
||||
onWithdraw?: (amount: number) => void;
|
||||
onExport?: () => void;
|
||||
mutationLoading?: boolean;
|
||||
}
|
||||
|
||||
export function LeagueWalletPageClient({ viewData, onExport }: WalletTemplateProps) {
|
||||
// Map transactions to the format expected by WalletSummaryPanel
|
||||
const transactions = viewData.transactions.map(t => ({
|
||||
id: t.id,
|
||||
type: t.type === 'withdrawal' ? 'debit' : 'credit' as 'credit' | 'debit',
|
||||
amount: parseFloat(t.formattedAmount.replace(/[^0-9.-]+/g, '')),
|
||||
description: t.description,
|
||||
date: t.formattedDate,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Container size="lg" py={8}>
|
||||
{/* Header */}
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mb={8}>
|
||||
<Box>
|
||||
<Heading level={1}>League Wallet</Heading>
|
||||
<Text color="text-gray-400">Manage your league's finances and payouts</Text>
|
||||
</Box>
|
||||
<Button variant="secondary" onClick={onExport}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<UIIcon icon={Download} size={4} />
|
||||
<Text>Export</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<WalletSummaryPanel
|
||||
balance={viewData.balance}
|
||||
currency="USD"
|
||||
transactions={transactions}
|
||||
onDeposit={() => {}} // Not implemented for leagues yet
|
||||
onWithdraw={() => {}} // Not implemented for leagues yet
|
||||
/>
|
||||
|
||||
{/* Alpha Notice */}
|
||||
<Box mt={6} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" p={4}>
|
||||
<Text size="xs" color="text-gray-400">
|
||||
<Text weight="bold" color="text-warning-amber">Alpha Note:</Text> Wallet management is demonstration-only.
|
||||
Real payment processing and bank integrations will be available when the payment system is fully implemented.
|
||||
</Text>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LeagueWalletPageQuery } from '@/lib/page-queries/LeagueWalletPageQuery';
|
||||
import { LeagueWalletPageClient } from './LeagueWalletPageClient';
|
||||
import { LeagueWalletPageClient } from '@/client-wrapper/LeagueWalletPageClient';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface Props {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { CreateLeagueWizard } from './CreateLeagueWizard';
|
||||
import { CreateLeagueWizard } from '@/client-wrapper/CreateLeagueWizard';
|
||||
import { Section } from '@/ui/Section';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { SearchParamParser } from '@/lib/routing/search-params/SearchParamParser';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LeaguesPageClient } from './LeaguesPageClient';
|
||||
import { LeaguesPageClient } from '@/client-wrapper/LeaguesPageClient';
|
||||
import { LeaguesPageQuery } from '@/lib/page-queries/LeaguesPageQuery';
|
||||
|
||||
export default async function Page() {
|
||||
|
||||
Reference in New Issue
Block a user