error and load state
This commit is contained in:
@@ -1,32 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LeaguesTemplate } from '@/templates/LeaguesTemplate';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { Trophy } from 'lucide-react';
|
||||
|
||||
export default function LeaguesInteractive() {
|
||||
const router = useRouter();
|
||||
const [realLeagues, setRealLeagues] = useState<LeagueSummaryViewModel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { leagueService } = useServices();
|
||||
|
||||
const loadLeagues = useCallback(async () => {
|
||||
try {
|
||||
const leagues = await leagueService.getAllLeagues();
|
||||
setRealLeagues(leagues);
|
||||
} catch (error) {
|
||||
console.error('Failed to load leagues:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [leagueService]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadLeagues();
|
||||
}, [loadLeagues]);
|
||||
const { data: realLeagues = [], isLoading: loading, error, retry } = useDataFetching({
|
||||
queryKey: ['allLeagues'],
|
||||
queryFn: () => leagueService.getAllLeagues(),
|
||||
});
|
||||
|
||||
const handleLeagueClick = (leagueId: string) => {
|
||||
router.push(`/leagues/${leagueId}`);
|
||||
@@ -37,11 +29,30 @@ export default function LeaguesInteractive() {
|
||||
};
|
||||
|
||||
return (
|
||||
<LeaguesTemplate
|
||||
leagues={realLeagues}
|
||||
loading={loading}
|
||||
onLeagueClick={handleLeagueClick}
|
||||
onCreateLeagueClick={handleCreateLeagueClick}
|
||||
/>
|
||||
<StateContainer
|
||||
data={realLeagues}
|
||||
isLoading={loading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={{
|
||||
loading: { variant: 'spinner', message: 'Loading leagues...' },
|
||||
error: { variant: 'full-screen' },
|
||||
empty: {
|
||||
icon: Trophy,
|
||||
title: 'No leagues yet',
|
||||
description: 'Create your first league to start organizing races and events.',
|
||||
action: { label: 'Create League', onClick: handleCreateLeagueClick }
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(leaguesData) => (
|
||||
<LeaguesTemplate
|
||||
leagues={leaguesData}
|
||||
loading={false}
|
||||
onLeagueClick={handleLeagueClick}
|
||||
onCreateLeagueClick={handleCreateLeagueClick}
|
||||
/>
|
||||
)}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useSponsorMode } from '@/components/sponsors/SponsorInsightsCard';
|
||||
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
|
||||
import EndRaceModal from '@/components/leagues/EndRaceModal';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { Trophy } from 'lucide-react';
|
||||
|
||||
export default function LeagueDetailInteractive() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
@@ -16,39 +20,18 @@ export default function LeagueDetailInteractive() {
|
||||
const isSponsor = useSponsorMode();
|
||||
const { leagueService, leagueMembershipService, raceService } = useServices();
|
||||
|
||||
const [viewModel, setViewModel] = useState<LeagueDetailPageViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [endRaceModalRaceId, setEndRaceModalRaceId] = useState<string | null>(null);
|
||||
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
|
||||
|
||||
const loadLeagueData = async () => {
|
||||
try {
|
||||
const viewModelData = await leagueService.getLeagueDetailPageData(leagueId);
|
||||
|
||||
if (!viewModelData) {
|
||||
setError('League not found');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setViewModel(viewModelData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load league');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadLeagueData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leagueId]);
|
||||
const { data: viewModel, isLoading, error, retry } = useDataFetching({
|
||||
queryKey: ['leagueDetailPage', leagueId],
|
||||
queryFn: () => leagueService.getLeagueDetailPageData(leagueId),
|
||||
});
|
||||
|
||||
const handleMembershipChange = () => {
|
||||
loadLeagueData();
|
||||
retry();
|
||||
};
|
||||
|
||||
const handleEndRaceModalOpen = (raceId: string) => {
|
||||
@@ -68,7 +51,7 @@ export default function LeagueDetailInteractive() {
|
||||
|
||||
try {
|
||||
await raceService.completeRace(endRaceModalRaceId);
|
||||
await loadLeagueData();
|
||||
await retry();
|
||||
setEndRaceModalRaceId(null);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to complete race');
|
||||
@@ -79,46 +62,51 @@ export default function LeagueDetailInteractive() {
|
||||
setEndRaceModalRaceId(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center text-gray-400">Loading league...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !viewModel) {
|
||||
return (
|
||||
<div className="text-center text-warning-amber">
|
||||
{error || 'League not found'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LeagueDetailTemplate
|
||||
viewModel={viewModel}
|
||||
leagueId={leagueId}
|
||||
isSponsor={isSponsor}
|
||||
membership={membership}
|
||||
currentDriverId={currentDriverId}
|
||||
onMembershipChange={handleMembershipChange}
|
||||
onEndRaceModalOpen={handleEndRaceModalOpen}
|
||||
onLiveRaceClick={handleLiveRaceClick}
|
||||
onBackToLeagues={handleBackToLeagues}
|
||||
>
|
||||
{/* End Race Modal */}
|
||||
{endRaceModalRaceId && viewModel && (() => {
|
||||
const race = viewModel.runningRaces.find(r => r.id === endRaceModalRaceId);
|
||||
return race ? (
|
||||
<EndRaceModal
|
||||
raceId={race.id}
|
||||
raceName={race.name}
|
||||
onConfirm={handleEndRaceConfirm}
|
||||
onCancel={handleEndRaceCancel}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</LeagueDetailTemplate>
|
||||
</>
|
||||
<StateContainer
|
||||
data={viewModel}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={{
|
||||
loading: { variant: 'skeleton', message: 'Loading league...' },
|
||||
error: { variant: 'full-screen' },
|
||||
empty: {
|
||||
icon: Trophy,
|
||||
title: 'League not found',
|
||||
description: 'The league may have been deleted or you may not have access',
|
||||
action: { label: 'Back to Leagues', onClick: handleBackToLeagues }
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(leagueData) => (
|
||||
<>
|
||||
<LeagueDetailTemplate
|
||||
viewModel={leagueData}
|
||||
leagueId={leagueId}
|
||||
isSponsor={isSponsor}
|
||||
membership={membership}
|
||||
currentDriverId={currentDriverId}
|
||||
onMembershipChange={handleMembershipChange}
|
||||
onEndRaceModalOpen={handleEndRaceModalOpen}
|
||||
onLiveRaceClick={handleLiveRaceClick}
|
||||
onBackToLeagues={handleBackToLeagues}
|
||||
>
|
||||
{/* End Race Modal */}
|
||||
{endRaceModalRaceId && leagueData && (() => {
|
||||
const race = leagueData.runningRaces.find(r => r.id === endRaceModalRaceId);
|
||||
return race ? (
|
||||
<EndRaceModal
|
||||
raceId={race.id}
|
||||
raceName={race.name}
|
||||
onConfirm={handleEndRaceConfirm}
|
||||
onCancel={handleEndRaceCancel}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</LeagueDetailTemplate>
|
||||
</>
|
||||
)}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,47 +9,34 @@ import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel';
|
||||
import { AlertTriangle, Settings } from 'lucide-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||
|
||||
export default function LeagueSettingsPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { leagueMembershipService, leagueSettingsService } = useServices();
|
||||
|
||||
const [settings, setSettings] = useState<LeagueSettingsViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAdmin() {
|
||||
// Check admin status
|
||||
const { data: isAdmin, isLoading: adminLoading } = useDataFetching({
|
||||
queryKey: ['leagueMembership', leagueId, currentDriverId],
|
||||
queryFn: async () => {
|
||||
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
|
||||
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
}
|
||||
checkAdmin();
|
||||
}, [leagueId, currentDriverId, leagueMembershipService]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSettings() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const settingsData = await leagueSettingsService.getLeagueSettings(leagueId);
|
||||
if (settingsData) {
|
||||
setSettings(settingsData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load league settings:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
loadSettings();
|
||||
}
|
||||
}, [leagueId, isAdmin, leagueSettingsService]);
|
||||
return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
|
||||
},
|
||||
});
|
||||
|
||||
// Load settings (only if admin)
|
||||
const { data: settings, isLoading: settingsLoading, error, retry } = useDataFetching({
|
||||
queryKey: ['leagueSettings', leagueId],
|
||||
queryFn: () => leagueSettingsService.getLeagueSettings(leagueId),
|
||||
enabled: !!isAdmin,
|
||||
});
|
||||
|
||||
const handleTransferOwnership = async (newOwnerId: string) => {
|
||||
try {
|
||||
@@ -60,6 +47,12 @@ export default function LeagueSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 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>
|
||||
@@ -76,49 +69,47 @@ export default function LeagueSettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="py-6 text-sm text-gray-400">Loading configuration…</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="py-6 text-sm text-gray-500">
|
||||
Unable to load league configuration for this demo league.
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
|
||||
<Settings className="w-6 h-6 text-primary-blue" />
|
||||
<StateContainer
|
||||
data={settings}
|
||||
isLoading={settingsLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={{
|
||||
loading: { variant: 'spinner', message: 'Loading settings...' },
|
||||
error: { variant: 'full-screen' },
|
||||
empty: {
|
||||
icon: Settings,
|
||||
title: 'No settings available',
|
||||
description: 'Unable to load league configuration.',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(settingsData) => (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
|
||||
<Settings className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">League Settings</h1>
|
||||
<p className="text-sm text-gray-400">Manage your league configuration</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* READONLY INFORMATION SECTION - Compact */}
|
||||
<div className="space-y-4">
|
||||
<ReadonlyLeagueInfo league={settingsData.league} configForm={settingsData.config} />
|
||||
|
||||
<LeagueOwnershipTransfer
|
||||
settings={settingsData}
|
||||
currentDriverId={currentDriverId}
|
||||
onTransferOwnership={handleTransferOwnership}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">League Settings</h1>
|
||||
<p className="text-sm text-gray-400">Manage your league configuration</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* READONLY INFORMATION SECTION - Compact */}
|
||||
<div className="space-y-4">
|
||||
<ReadonlyLeagueInfo league={settings.league} configForm={settings.config} />
|
||||
|
||||
<LeagueOwnershipTransfer
|
||||
settings={settings}
|
||||
currentDriverId={currentDriverId}
|
||||
onTransferOwnership={handleTransferOwnership}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||
|
||||
export default function LeagueStewardingPage() {
|
||||
const params = useParams();
|
||||
@@ -30,46 +35,34 @@ export default function LeagueStewardingPage() {
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { leagueStewardingService, leagueMembershipService } = useServices();
|
||||
|
||||
const [stewardingData, setStewardingData] = useState<LeagueStewardingViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
|
||||
const [selectedProtest, setSelectedProtest] = useState<any | null>(null);
|
||||
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(new Set());
|
||||
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAdmin() {
|
||||
// Check admin status
|
||||
const { data: isAdmin, isLoading: adminLoading } = useDataFetching({
|
||||
queryKey: ['leagueMembership', leagueId, currentDriverId],
|
||||
queryFn: async () => {
|
||||
const membership = await leagueMembershipService.getMembership(leagueId, currentDriverId);
|
||||
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
}
|
||||
checkAdmin();
|
||||
}, [leagueId, currentDriverId, leagueMembershipService]);
|
||||
return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await leagueStewardingService.getLeagueStewardingData(leagueId);
|
||||
setStewardingData(data);
|
||||
|
||||
// Auto-expand races with pending protests
|
||||
const racesWithPending = new Set<string>();
|
||||
data.pendingRaces.forEach(race => {
|
||||
racesWithPending.add(race.race.id);
|
||||
});
|
||||
setExpandedRaces(racesWithPending);
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
loadData();
|
||||
}
|
||||
}, [leagueId, isAdmin, leagueStewardingService]);
|
||||
// Load stewarding data (only if admin)
|
||||
const { data: stewardingData, isLoading: dataLoading, error, retry } = useDataFetching({
|
||||
queryKey: ['leagueStewarding', leagueId],
|
||||
queryFn: () => leagueStewardingService.getLeagueStewardingData(leagueId),
|
||||
enabled: !!isAdmin,
|
||||
onSuccess: (data) => {
|
||||
// Auto-expand races with pending protests
|
||||
const racesWithPending = new Set<string>();
|
||||
data.pendingRaces.forEach(race => {
|
||||
racesWithPending.add(race.race.id);
|
||||
});
|
||||
setExpandedRaces(racesWithPending);
|
||||
},
|
||||
});
|
||||
|
||||
// Filter races based on active tab
|
||||
const filteredRaces = useMemo(() => {
|
||||
@@ -109,6 +102,9 @@ export default function LeagueStewardingPage() {
|
||||
notes: stewardNotes,
|
||||
});
|
||||
}
|
||||
|
||||
// Retry to refresh data
|
||||
await retry();
|
||||
};
|
||||
|
||||
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
|
||||
@@ -118,9 +114,11 @@ export default function LeagueStewardingPage() {
|
||||
decision: 'dismiss',
|
||||
decisionNotes: stewardNotes,
|
||||
});
|
||||
|
||||
// Retry to refresh data
|
||||
await retry();
|
||||
};
|
||||
|
||||
|
||||
const toggleRaceExpanded = (raceId: string) => {
|
||||
setExpandedRaces(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -149,6 +147,12 @@ export default function LeagueStewardingPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 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>
|
||||
@@ -166,248 +170,260 @@ export default function LeagueStewardingPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Quick overview of protests and penalties across all races
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats summary */}
|
||||
{!loading && stewardingData && (
|
||||
<StewardingStats
|
||||
totalPending={stewardingData.totalPending}
|
||||
totalResolved={stewardingData.totalResolved}
|
||||
totalPenalties={stewardingData.totalPenalties}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tab navigation */}
|
||||
<div className="border-b border-charcoal-outline mb-6">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('pending')}
|
||||
className={`pb-3 px-1 font-medium transition-colors ${
|
||||
activeTab === 'pending'
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Pending Protests
|
||||
{stewardingData && stewardingData.totalPending > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
|
||||
{stewardingData.totalPending}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={`pb-3 px-1 font-medium transition-colors ${
|
||||
activeTab === 'history'
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
History
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="animate-pulse">Loading stewarding data...</div>
|
||||
</div>
|
||||
) : filteredRaces.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
|
||||
<Flag className="w-8 h-8 text-performance-green" />
|
||||
<StateContainer
|
||||
data={stewardingData}
|
||||
isLoading={dataLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={{
|
||||
loading: { variant: 'spinner', message: 'Loading stewarding data...' },
|
||||
error: { variant: 'full-screen' },
|
||||
empty: {
|
||||
icon: Flag,
|
||||
title: 'No stewarding data',
|
||||
description: 'There are no protests or penalties to review.',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(data) => (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Quick overview of protests and penalties across all races
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-semibold text-lg text-white mb-2">
|
||||
{activeTab === 'pending' ? 'All Clear!' : 'No History Yet'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{activeTab === 'pending'
|
||||
? 'No pending protests to review'
|
||||
: 'No resolved protests or penalties'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => {
|
||||
const isExpanded = expandedRaces.has(race.id);
|
||||
const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests;
|
||||
|
||||
return (
|
||||
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
|
||||
{/* Race Header */}
|
||||
<button
|
||||
onClick={() => toggleRaceExpanded(race.id)}
|
||||
className="w-full px-4 py-3 bg-iron-gray/30 hover:bg-iron-gray/50 transition-colors flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-gray-400" />
|
||||
<span className="font-medium text-white">{race.track}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{race.scheduledAt.toLocaleDateString()}</span>
|
||||
</div>
|
||||
{activeTab === 'pending' && pendingProtests.length > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
|
||||
{pendingProtests.length} pending
|
||||
</span>
|
||||
)}
|
||||
{activeTab === 'history' && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
|
||||
{resolvedProtests.length} protests, {penalties.length} penalties
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 space-y-3 bg-deep-graphite/50">
|
||||
{displayProtests.length === 0 && penalties.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
|
||||
) : (
|
||||
<>
|
||||
{displayProtests.map((protest) => {
|
||||
const protester = stewardingData!.driverMap[protest.protestingDriverId];
|
||||
const accused = stewardingData!.driverMap[protest.accusedDriverId];
|
||||
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
const isUrgent = daysSinceFiled > 2 && (protest.status === 'pending' || protest.status === 'under_review');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={protest.id}
|
||||
className={`rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
|
||||
<span className="font-medium text-white">
|
||||
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
|
||||
</span>
|
||||
{getStatusBadge(protest.status)}
|
||||
{isUrgent && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{daysSinceFiled}d old
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
|
||||
<span>Lap {protest.incident.lap}</span>
|
||||
<span>•</span>
|
||||
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span>
|
||||
{protest.proofVideoUrl && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1 text-primary-blue">
|
||||
<Video className="w-3 h-3" />
|
||||
Video
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-300 line-clamp-2">
|
||||
{protest.incident.description}
|
||||
</p>
|
||||
{protest.decisionNotes && (
|
||||
<div className="mt-2 p-2 rounded bg-iron-gray/50 border border-charcoal-outline/50">
|
||||
<p className="text-xs text-gray-400">
|
||||
<span className="font-medium">Steward:</span> {protest.decisionNotes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(protest.status === 'pending' || protest.status === 'under_review') && (
|
||||
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
|
||||
<Button variant="primary">
|
||||
Review
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Stats summary */}
|
||||
<StewardingStats
|
||||
totalPending={data.totalPending}
|
||||
totalResolved={data.totalResolved}
|
||||
totalPenalties={data.totalPenalties}
|
||||
/>
|
||||
|
||||
{activeTab === 'history' && penalties.map((penalty) => {
|
||||
const driver = stewardingData!.driverMap[penalty.driverId];
|
||||
return (
|
||||
<div
|
||||
key={penalty.id}
|
||||
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Gavel className="w-4 h-4 text-red-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
||||
{penalty.type.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{penalty.reason}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-lg font-bold text-red-400">
|
||||
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
|
||||
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
|
||||
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
|
||||
{penalty.type === 'disqualification' && 'DSQ'}
|
||||
{penalty.type === 'warning' && 'Warning'}
|
||||
{penalty.type === 'license_points' && `${penalty.value} LP`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Tab navigation */}
|
||||
<div className="border-b border-charcoal-outline mb-6">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('pending')}
|
||||
className={`pb-3 px-1 font-medium transition-colors ${
|
||||
activeTab === 'pending'
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Pending Protests
|
||||
{data.totalPending > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
|
||||
{data.totalPending}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={`pb-3 px-1 font-medium transition-colors ${
|
||||
activeTab === 'history'
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
History
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{filteredRaces.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
|
||||
<Flag className="w-8 h-8 text-performance-green" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<p className="font-semibold text-lg text-white mb-2">
|
||||
{activeTab === 'pending' ? 'All Clear!' : 'No History Yet'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{activeTab === 'pending'
|
||||
? 'No pending protests to review'
|
||||
: 'No resolved protests or penalties'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => {
|
||||
const isExpanded = expandedRaces.has(race.id);
|
||||
const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests;
|
||||
|
||||
return (
|
||||
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
|
||||
{/* Race Header */}
|
||||
<button
|
||||
onClick={() => toggleRaceExpanded(race.id)}
|
||||
className="w-full px-4 py-3 bg-iron-gray/30 hover:bg-iron-gray/50 transition-colors flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-gray-400" />
|
||||
<span className="font-medium text-white">{race.track}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{race.scheduledAt.toLocaleDateString()}</span>
|
||||
</div>
|
||||
{activeTab === 'pending' && pendingProtests.length > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
|
||||
{pendingProtests.length} pending
|
||||
</span>
|
||||
)}
|
||||
{activeTab === 'history' && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
|
||||
{resolvedProtests.length} protests, {penalties.length} penalties
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
|
||||
)}
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 space-y-3 bg-deep-graphite/50">
|
||||
{displayProtests.length === 0 && penalties.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
|
||||
) : (
|
||||
<>
|
||||
{displayProtests.map((protest) => {
|
||||
const protester = data.driverMap[protest.protestingDriverId];
|
||||
const accused = data.driverMap[protest.accusedDriverId];
|
||||
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
const isUrgent = daysSinceFiled > 2 && (protest.status === 'pending' || protest.status === 'under_review');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={protest.id}
|
||||
className={`rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
|
||||
<span className="font-medium text-white">
|
||||
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
|
||||
</span>
|
||||
{getStatusBadge(protest.status)}
|
||||
{isUrgent && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{daysSinceFiled}d old
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
|
||||
<span>Lap {protest.incident.lap}</span>
|
||||
<span>•</span>
|
||||
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span>
|
||||
{protest.proofVideoUrl && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1 text-primary-blue">
|
||||
<Video className="w-3 h-3" />
|
||||
Video
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-300 line-clamp-2">
|
||||
{protest.incident.description}
|
||||
</p>
|
||||
{protest.decisionNotes && (
|
||||
<div className="mt-2 p-2 rounded bg-iron-gray/50 border border-charcoal-outline/50">
|
||||
<p className="text-xs text-gray-400">
|
||||
<span className="font-medium">Steward:</span> {protest.decisionNotes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(protest.status === 'pending' || protest.status === 'under_review') && (
|
||||
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
|
||||
<Button variant="primary">
|
||||
Review
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedProtest && (
|
||||
<ReviewProtestModal
|
||||
protest={selectedProtest}
|
||||
onClose={() => setSelectedProtest(null)}
|
||||
onAccept={handleAcceptProtest}
|
||||
onReject={handleRejectProtest}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'history' && penalties.map((penalty) => {
|
||||
const driver = data.driverMap[penalty.driverId];
|
||||
return (
|
||||
<div
|
||||
key={penalty.id}
|
||||
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Gavel className="w-4 h-4 text-red-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
||||
{penalty.type.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{penalty.reason}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-lg font-bold text-red-400">
|
||||
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
|
||||
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
|
||||
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
|
||||
{penalty.type === 'disqualification' && 'DSQ'}
|
||||
{penalty.type === 'warning' && 'Warning'}
|
||||
{penalty.type === 'license_points' && `${penalty.value} LP`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{showQuickPenaltyModal && stewardingData && (
|
||||
<QuickPenaltyModal
|
||||
drivers={stewardingData.allDrivers}
|
||||
onClose={() => setShowQuickPenaltyModal(false)}
|
||||
adminId={currentDriverId}
|
||||
races={stewardingData.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))}
|
||||
/>
|
||||
{activeTab === 'history' && (
|
||||
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
|
||||
)}
|
||||
|
||||
{selectedProtest && (
|
||||
<ReviewProtestModal
|
||||
protest={selectedProtest}
|
||||
onClose={() => setSelectedProtest(null)}
|
||||
onAccept={handleAcceptProtest}
|
||||
onReject={handleRejectProtest}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showQuickPenaltyModal && stewardingData && (
|
||||
<QuickPenaltyModal
|
||||
drivers={stewardingData.allDrivers}
|
||||
onClose={() => setShowQuickPenaltyModal(false)}
|
||||
adminId={currentDriverId}
|
||||
races={stewardingData.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||
|
||||
// Timeline event types
|
||||
interface TimelineEvent {
|
||||
@@ -105,10 +110,6 @@ export default function ProtestReviewPage() {
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { leagueStewardingService, protestService, leagueMembershipService } = useServices();
|
||||
|
||||
const [detail, setDetail] = useState<ProtestDetailViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
// Decision state
|
||||
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
|
||||
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
|
||||
@@ -116,6 +117,30 @@ export default function ProtestReviewPage() {
|
||||
const [penaltyValue, setPenaltyValue] = useState<number>(5);
|
||||
const [stewardNotes, setStewardNotes] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
|
||||
// Check admin status
|
||||
const { data: isAdmin, isLoading: adminLoading } = useDataFetching({
|
||||
queryKey: ['leagueMembership', leagueId, currentDriverId],
|
||||
queryFn: async () => {
|
||||
await leagueMembershipService.fetchLeagueMemberships(leagueId);
|
||||
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
|
||||
return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
|
||||
},
|
||||
});
|
||||
|
||||
// Load protest detail
|
||||
const { data: detail, isLoading: detailLoading, error, retry } = useDataFetching({
|
||||
queryKey: ['protestDetail', leagueId, protestId],
|
||||
queryFn: () => leagueStewardingService.getProtestDetailViewModel(leagueId, protestId),
|
||||
enabled: !!isAdmin,
|
||||
onSuccess: (protestDetail) => {
|
||||
if (protestDetail.initialPenaltyType) {
|
||||
setPenaltyType(protestDetail.initialPenaltyType);
|
||||
setPenaltyValue(protestDetail.initialPenaltyValue);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const penaltyTypes = useMemo(() => {
|
||||
const referenceItems = detail?.penaltyTypes ?? [];
|
||||
@@ -136,45 +161,6 @@ export default function ProtestReviewPage() {
|
||||
const selectedPenalty = useMemo(() => {
|
||||
return penaltyTypes.find((p) => p.type === penaltyType);
|
||||
}, [penaltyTypes, penaltyType]);
|
||||
|
||||
// Comment state
|
||||
const [newComment, setNewComment] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAdmin() {
|
||||
await leagueMembershipService.fetchLeagueMemberships(leagueId);
|
||||
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
|
||||
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
}
|
||||
checkAdmin();
|
||||
}, [leagueId, currentDriverId, leagueMembershipService]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadProtest() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const protestDetail = await leagueStewardingService.getProtestDetailViewModel(leagueId, protestId);
|
||||
|
||||
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');
|
||||
router.push(`/leagues/${leagueId}/stewarding`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
loadProtest();
|
||||
}
|
||||
}, [protestId, leagueId, isAdmin, router, leagueStewardingService]);
|
||||
|
||||
|
||||
const handleSubmitDecision = async () => {
|
||||
if (!decision || !stewardNotes.trim() || !detail) return;
|
||||
@@ -295,6 +281,12 @@ export default function ProtestReviewPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 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>
|
||||
@@ -311,435 +303,440 @@ export default function ProtestReviewPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || !detail) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-pulse text-gray-400">Loading protest details...</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.submittedAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Compact Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link href={`/leagues/${leagueId}/stewarding`} className="text-gray-400 hover:text-white transition-colors">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-white">Protest Review</h1>
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${statusConfig.color}`}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{statusConfig.label}
|
||||
</div>
|
||||
{daysSinceFiled > 2 && isPending && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{daysSinceFiled}d old
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StateContainer
|
||||
data={detail}
|
||||
isLoading={detailLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={{
|
||||
loading: { variant: 'spinner', message: 'Loading protest details...' },
|
||||
error: { variant: 'full-screen' },
|
||||
}}
|
||||
>
|
||||
{(protestDetail) => {
|
||||
const protest = protestDetail.protest;
|
||||
const race = protestDetail.race;
|
||||
const protestingDriver = protestDetail.protestingDriver;
|
||||
const accusedDriver = protestDetail.accusedDriver;
|
||||
|
||||
{/* Main Layout: Feed + Sidebar */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* Left Sidebar - Incident Info */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{/* Drivers Involved */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Parties Involved</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Protesting Driver */}
|
||||
<Link href={`/drivers/${protestingDriver?.id || ''}`} className="block">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors cursor-pointer">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-blue-400" />
|
||||
const statusConfig = getStatusConfig(protest.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const isPending = protest.status === 'pending';
|
||||
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.submittedAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Compact Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link href={`/leagues/${leagueId}/stewarding`} className="text-gray-400 hover:text-white transition-colors">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-white">Protest Review</h1>
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${statusConfig.color}`}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{statusConfig.label}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-blue-400 font-medium">Protesting</p>
|
||||
<p className="text-sm font-semibold text-white truncate">{protestingDriver?.name || 'Unknown'}</p>
|
||||
</div>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
{daysSinceFiled > 2 && isPending && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{daysSinceFiled}d old
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Accused Driver */}
|
||||
<Link href={`/drivers/${accusedDriver?.id || ''}`} className="block">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-orange-500/50 hover:bg-orange-500/5 transition-colors cursor-pointer">
|
||||
<div className="w-10 h-10 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-orange-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-orange-400 font-medium">Accused</p>
|
||||
<p className="text-sm font-semibold text-white truncate">{accusedDriver?.name || 'Unknown'}</p>
|
||||
</div>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Race Info */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Race Details</h3>
|
||||
|
||||
<Link
|
||||
href={`/races/${race.id}`}
|
||||
className="block mb-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/50 hover:bg-primary-blue/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-white">{race.name}</span>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MapPin className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">{race.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">{race.formattedDate}</span>
|
||||
</div>
|
||||
{protest.incident?.lap && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Flag className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">Lap {protest.incident.lap}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{protest.proofVideoUrl && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
|
||||
<a
|
||||
href={protest.proofVideoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 p-3 rounded-lg bg-primary-blue/10 border border-primary-blue/20 text-primary-blue hover:bg-primary-blue/20 transition-colors"
|
||||
>
|
||||
<Video className="w-4 h-4" />
|
||||
<span className="text-sm font-medium flex-1">Watch Video</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Timeline</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Filed</span>
|
||||
<span className="text-gray-300">{new Date(protest.submittedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Age</span>
|
||||
<span className={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</span>
|
||||
</div>
|
||||
{protest.reviewedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Resolved</span>
|
||||
<span className="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Center - Discussion Feed */}
|
||||
<div className="lg:col-span-6 space-y-4">
|
||||
{/* Timeline / Feed */}
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<div className="p-4 border-b border-charcoal-outline bg-iron-gray/30">
|
||||
<h2 className="text-sm font-semibold text-white">Discussion</h2>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
{/* Initial Protest Filing */}
|
||||
<div className="p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<AlertCircle className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-white text-sm">{protestingDriver?.name || 'Unknown'}</span>
|
||||
<span className="text-xs text-blue-400 font-medium">filed protest</span>
|
||||
<span className="text-xs text-gray-500">•</span>
|
||||
<span className="text-xs text-gray-500">{new Date(protest.submittedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-sm text-gray-300 mb-3">{protest.description}</p>
|
||||
|
||||
{protest.comment && (
|
||||
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||
<p className="text-xs text-gray-500 mb-1">Additional details:</p>
|
||||
<p className="text-sm text-gray-400">{protest.comment}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Defense placeholder - will be populated when defense system is implemented */}
|
||||
{protest.status === 'awaiting_defense' && (
|
||||
<div className="p-4 bg-purple-500/5">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<MessageCircle className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-purple-400 font-medium mb-1">Defense Requested</p>
|
||||
<p className="text-sm text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decision (if resolved) */}
|
||||
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
|
||||
<div className={`p-4 ${protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}`}>
|
||||
<div className="flex gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'
|
||||
}`}>
|
||||
<Gavel className={`w-5 h-5 ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-white text-sm">Steward Decision</span>
|
||||
<span className={`text-xs font-medium ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`}>
|
||||
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
|
||||
</span>
|
||||
{protest.reviewedAt && (
|
||||
<>
|
||||
<span className="text-xs text-gray-500">•</span>
|
||||
<span className="text-xs text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`rounded-lg p-4 border ${
|
||||
protest.status === 'upheld'
|
||||
? 'bg-red-500/10 border-red-500/20'
|
||||
: 'bg-gray-500/10 border-gray-500/20'
|
||||
}`}>
|
||||
<p className="text-sm text-gray-300">{protest.decisionNotes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Comment (future feature) */}
|
||||
{isPending && (
|
||||
<div className="p-4 border-t border-charcoal-outline bg-iron-gray/20">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-gray-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Add a comment or request more information..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue text-sm resize-none"
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button variant="secondary" disabled={!newComment.trim()}>
|
||||
<Send className="w-3 h-3 mr-1" />
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Actions */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{isPending && (
|
||||
<>
|
||||
{/* Quick Actions */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Actions</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full justify-start"
|
||||
onClick={handleRequestDefense}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 mr-2" />
|
||||
Request Defense
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
|
||||
>
|
||||
<Gavel className="w-4 h-4 mr-2" />
|
||||
Make Decision
|
||||
<ChevronDown className={`w-4 h-4 ml-auto transition-transform ${showDecisionPanel ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Decision Panel */}
|
||||
{showDecisionPanel && (
|
||||
{/* Main Layout: Feed + Sidebar */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* Left Sidebar - Incident Info */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{/* Drivers Involved */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Stewarding Decision</h3>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Parties Involved</h3>
|
||||
|
||||
{/* Decision Selection */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setDecision('uphold')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
decision === 'uphold'
|
||||
? 'border-red-500 bg-red-500/10'
|
||||
: 'border-charcoal-outline hover:border-gray-600'
|
||||
}`}
|
||||
<div className="space-y-3">
|
||||
{/* Protesting Driver */}
|
||||
<Link href={`/drivers/${protestingDriver?.id || ''}`} className="block">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors cursor-pointer">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-blue-400 font-medium">Protesting</p>
|
||||
<p className="text-sm font-semibold text-white truncate">{protestingDriver?.name || 'Unknown'}</p>
|
||||
</div>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Accused Driver */}
|
||||
<Link href={`/drivers/${accusedDriver?.id || ''}`} className="block">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-orange-500/50 hover:bg-orange-500/5 transition-colors cursor-pointer">
|
||||
<div className="w-10 h-10 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-orange-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-orange-400 font-medium">Accused</p>
|
||||
<p className="text-sm font-semibold text-white truncate">{accusedDriver?.name || 'Unknown'}</p>
|
||||
</div>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Race Info */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Race Details</h3>
|
||||
|
||||
<Link
|
||||
href={`/races/${race.id}`}
|
||||
className="block mb-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/50 hover:bg-primary-blue/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-white">{race.name}</span>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MapPin className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">{race.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">{race.formattedDate}</span>
|
||||
</div>
|
||||
{protest.incident?.lap && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Flag className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">Lap {protest.incident.lap}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{protest.proofVideoUrl && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
|
||||
<a
|
||||
href={protest.proofVideoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 p-3 rounded-lg bg-primary-blue/10 border border-primary-blue/20 text-primary-blue hover:bg-primary-blue/20 transition-colors"
|
||||
>
|
||||
<CheckCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'uphold' ? 'text-red-400' : 'text-gray-500'}`} />
|
||||
<p className={`text-xs font-medium ${decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}`}>Uphold</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDecision('dismiss')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
decision === 'dismiss'
|
||||
? 'border-gray-500 bg-gray-500/10'
|
||||
: 'border-charcoal-outline hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<XCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'}`} />
|
||||
<p className={`text-xs font-medium ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}`}>Dismiss</p>
|
||||
</button>
|
||||
<Video className="w-4 h-4" />
|
||||
<span className="text-sm font-medium flex-1">Watch Video</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Timeline</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Filed</span>
|
||||
<span className="text-gray-300">{new Date(protest.submittedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Age</span>
|
||||
<span className={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</span>
|
||||
</div>
|
||||
{protest.reviewedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Resolved</span>
|
||||
<span className="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Center - Discussion Feed */}
|
||||
<div className="lg:col-span-6 space-y-4">
|
||||
{/* Timeline / Feed */}
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<div className="p-4 border-b border-charcoal-outline bg-iron-gray/30">
|
||||
<h2 className="text-sm font-semibold text-white">Discussion</h2>
|
||||
</div>
|
||||
|
||||
{/* Penalty Selection (if upholding) */}
|
||||
{decision === 'uphold' && (
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label>
|
||||
|
||||
{penaltyTypes.length === 0 ? (
|
||||
<div className="text-xs text-gray-500">
|
||||
Loading penalty types...
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
{/* Initial Protest Filing */}
|
||||
<div className="p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<AlertCircle className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{penaltyTypes.map((penalty) => {
|
||||
const Icon = penalty.icon;
|
||||
const isSelected = penaltyType === penalty.type;
|
||||
return (
|
||||
<button
|
||||
key={penalty.type}
|
||||
onClick={() => {
|
||||
setPenaltyType(penalty.type);
|
||||
setPenaltyValue(penalty.defaultValue);
|
||||
}}
|
||||
className={`p-2 rounded-lg border transition-all text-left ${
|
||||
isSelected
|
||||
? `${penalty.color} border`
|
||||
: 'border-charcoal-outline hover:border-gray-600 bg-iron-gray/30'
|
||||
}`}
|
||||
title={penalty.description}
|
||||
>
|
||||
<Icon className={`h-3.5 w-3.5 mb-0.5 ${isSelected ? '' : 'text-gray-500'}`} />
|
||||
<p className={`text-[10px] font-medium leading-tight ${isSelected ? '' : 'text-gray-500'}`}>
|
||||
{penalty.label}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-white text-sm">{protestingDriver?.name || 'Unknown'}</span>
|
||||
<span className="text-xs text-blue-400 font-medium">filed protest</span>
|
||||
<span className="text-xs text-gray-500">•</span>
|
||||
<span className="text-xs text-gray-500">{new Date(protest.submittedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-sm text-gray-300 mb-3">{protest.description}</p>
|
||||
|
||||
{selectedPenalty?.requiresValue && (
|
||||
<div className="mt-3">
|
||||
<label className="text-xs font-medium text-gray-400 mb-1 block">
|
||||
Value ({selectedPenalty.valueLabel})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={penaltyValue}
|
||||
onChange={(e) => setPenaltyValue(Number(e.target.value))}
|
||||
min="1"
|
||||
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:border-primary-blue"
|
||||
/>
|
||||
{protest.comment && (
|
||||
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||
<p className="text-xs text-gray-500 mb-1">Additional details:</p>
|
||||
<p className="text-sm text-gray-400">{protest.comment}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Defense placeholder - will be populated when defense system is implemented */}
|
||||
{protest.status === 'awaiting_defense' && (
|
||||
<div className="p-4 bg-purple-500/5">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<MessageCircle className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-purple-400 font-medium mb-1">Defense Requested</p>
|
||||
<p className="text-sm text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decision (if resolved) */}
|
||||
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
|
||||
<div className={`p-4 ${protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}`}>
|
||||
<div className="flex gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'
|
||||
}`}>
|
||||
<Gavel className={`w-5 h-5 ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-white text-sm">Steward Decision</span>
|
||||
<span className={`text-xs font-medium ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`}>
|
||||
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
|
||||
</span>
|
||||
{protest.reviewedAt && (
|
||||
<>
|
||||
<span className="text-xs text-gray-500">•</span>
|
||||
<span className="text-xs text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={`rounded-lg p-4 border ${
|
||||
protest.status === 'upheld'
|
||||
? 'bg-red-500/10 border-red-500/20'
|
||||
: 'bg-gray-500/10 border-gray-500/20'
|
||||
}`}>
|
||||
<p className="text-sm text-gray-300">{protest.decisionNotes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Comment (future feature) */}
|
||||
{isPending && (
|
||||
<div className="p-4 border-t border-charcoal-outline bg-iron-gray/20">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-gray-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Add a comment or request more information..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue text-sm resize-none"
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button variant="secondary" disabled={!newComment.trim()}>
|
||||
<Send className="w-3 h-3 mr-1" />
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steward Notes */}
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-medium text-gray-400 mb-1 block">Decision Reasoning *</label>
|
||||
<textarea
|
||||
value={stewardNotes}
|
||||
onChange={(e) => setStewardNotes(e.target.value)}
|
||||
placeholder="Explain your decision..."
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 text-sm focus:outline-none focus:border-primary-blue resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleSubmitDecision}
|
||||
disabled={!decision || !stewardNotes.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Decision'}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Already Resolved Info */}
|
||||
{!isPending && (
|
||||
<Card className="p-4">
|
||||
<div className={`text-center py-4 ${
|
||||
protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'
|
||||
}`}>
|
||||
<Gavel className="w-8 h-8 mx-auto mb-2" />
|
||||
<p className="font-semibold">Case Closed</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Actions */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{isPending && (
|
||||
<>
|
||||
{/* Quick Actions */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Actions</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full justify-start"
|
||||
onClick={handleRequestDefense}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 mr-2" />
|
||||
Request Defense
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
|
||||
>
|
||||
<Gavel className="w-4 h-4 mr-2" />
|
||||
Make Decision
|
||||
<ChevronDown className={`w-4 h-4 ml-auto transition-transform ${showDecisionPanel ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Decision Panel */}
|
||||
{showDecisionPanel && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Stewarding Decision</h3>
|
||||
|
||||
{/* Decision Selection */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setDecision('uphold')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
decision === 'uphold'
|
||||
? 'border-red-500 bg-red-500/10'
|
||||
: 'border-charcoal-outline hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'uphold' ? 'text-red-400' : 'text-gray-500'}`} />
|
||||
<p className={`text-xs font-medium ${decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}`}>Uphold</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDecision('dismiss')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
decision === 'dismiss'
|
||||
? 'border-gray-500 bg-gray-500/10'
|
||||
: 'border-charcoal-outline hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<XCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'}`} />
|
||||
<p className={`text-xs font-medium ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}`}>Dismiss</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Penalty Selection (if upholding) */}
|
||||
{decision === 'uphold' && (
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label>
|
||||
|
||||
{penaltyTypes.length === 0 ? (
|
||||
<div className="text-xs text-gray-500">
|
||||
Loading penalty types...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{penaltyTypes.map((penalty) => {
|
||||
const Icon = penalty.icon;
|
||||
const isSelected = penaltyType === penalty.type;
|
||||
return (
|
||||
<button
|
||||
key={penalty.type}
|
||||
onClick={() => {
|
||||
setPenaltyType(penalty.type);
|
||||
setPenaltyValue(penalty.defaultValue);
|
||||
}}
|
||||
className={`p-2 rounded-lg border transition-all text-left ${
|
||||
isSelected
|
||||
? `${penalty.color} border`
|
||||
: 'border-charcoal-outline hover:border-gray-600 bg-iron-gray/30'
|
||||
}`}
|
||||
title={penalty.description}
|
||||
>
|
||||
<Icon className={`h-3.5 w-3.5 mb-0.5 ${isSelected ? '' : 'text-gray-500'}`} />
|
||||
<p className={`text-[10px] font-medium leading-tight ${isSelected ? '' : 'text-gray-500'}`}>
|
||||
{penalty.label}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedPenalty?.requiresValue && (
|
||||
<div className="mt-3">
|
||||
<label className="text-xs font-medium text-gray-400 mb-1 block">
|
||||
Value ({selectedPenalty.valueLabel})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={penaltyValue}
|
||||
onChange={(e) => setPenaltyValue(Number(e.target.value))}
|
||||
min="1"
|
||||
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:border-primary-blue"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steward Notes */}
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-medium text-gray-400 mb-1 block">Decision Reasoning *</label>
|
||||
<textarea
|
||||
value={stewardNotes}
|
||||
onChange={(e) => setStewardNotes(e.target.value)}
|
||||
placeholder="Explain your decision..."
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 text-sm focus:outline-none focus:border-primary-blue resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleSubmitDecision}
|
||||
disabled={!decision || !stewardNotes.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Decision'}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Already Resolved Info */}
|
||||
{!isPending && (
|
||||
<Card className="p-4">
|
||||
<div className={`text-center py-4 ${
|
||||
protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'
|
||||
}`}>
|
||||
<Gavel className="w-8 h-8 mx-auto mb-2" />
|
||||
<p className="font-semibold">Case Closed</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user