392 lines
16 KiB
TypeScript
392 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import { Scale, Users, Clock, Bell, Shield, Vote, UserCheck, AlertTriangle } from 'lucide-react';
|
|
import type { LeagueConfigFormModel, LeagueStewardingFormDTO } from '@gridpilot/racing/application';
|
|
import type { StewardingDecisionMode } from '@gridpilot/racing/domain/entities/League';
|
|
|
|
interface LeagueStewardingSectionProps {
|
|
form: LeagueConfigFormModel;
|
|
onChange: (form: LeagueConfigFormModel) => void;
|
|
readOnly?: boolean;
|
|
}
|
|
|
|
type DecisionModeOption = {
|
|
value: StewardingDecisionMode;
|
|
label: string;
|
|
description: string;
|
|
icon: React.ReactNode;
|
|
requiresVotes: boolean;
|
|
};
|
|
|
|
const decisionModeOptions: DecisionModeOption[] = [
|
|
{
|
|
value: 'admin_only',
|
|
label: 'Admin Decision',
|
|
description: 'League admins make all penalty decisions',
|
|
icon: <Shield className="w-5 h-5" />,
|
|
requiresVotes: false,
|
|
},
|
|
{
|
|
value: 'steward_vote',
|
|
label: 'Steward Vote',
|
|
description: 'Designated stewards vote to uphold protests',
|
|
icon: <Scale className="w-5 h-5" />,
|
|
requiresVotes: true,
|
|
},
|
|
{
|
|
value: 'member_vote',
|
|
label: 'Member Vote',
|
|
description: 'All league members vote on protests',
|
|
icon: <Users className="w-5 h-5" />,
|
|
requiresVotes: true,
|
|
},
|
|
{
|
|
value: 'steward_veto',
|
|
label: 'Steward Veto',
|
|
description: 'Protests upheld unless stewards vote against',
|
|
icon: <Vote className="w-5 h-5" />,
|
|
requiresVotes: true,
|
|
},
|
|
{
|
|
value: 'member_veto',
|
|
label: 'Member Veto',
|
|
description: 'Protests upheld unless members vote against',
|
|
icon: <UserCheck className="w-5 h-5" />,
|
|
requiresVotes: true,
|
|
},
|
|
];
|
|
|
|
export function LeagueStewardingSection({
|
|
form,
|
|
onChange,
|
|
readOnly = false,
|
|
}: LeagueStewardingSectionProps) {
|
|
// Provide default stewarding settings if not present
|
|
const stewarding = form.stewarding ?? {
|
|
decisionMode: 'admin_only' as const,
|
|
requiredVotes: 2,
|
|
requireDefense: false,
|
|
defenseTimeLimit: 48,
|
|
voteTimeLimit: 72,
|
|
protestDeadlineHours: 48,
|
|
stewardingClosesHours: 168,
|
|
notifyAccusedOnProtest: true,
|
|
notifyOnVoteRequired: true,
|
|
};
|
|
|
|
const updateStewarding = (updates: Partial<LeagueStewardingFormDTO>) => {
|
|
onChange({
|
|
...form,
|
|
stewarding: {
|
|
...stewarding,
|
|
...updates,
|
|
},
|
|
});
|
|
};
|
|
|
|
const selectedMode = decisionModeOptions.find((m) => m.value === stewarding.decisionMode);
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Decision Mode Selection */}
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-white mb-1 flex items-center gap-2">
|
|
<Scale className="w-4 h-4 text-primary-blue" />
|
|
How are protest decisions made?
|
|
</h3>
|
|
<p className="text-xs text-gray-400 mb-4">
|
|
Choose who has the authority to issue penalties
|
|
</p>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{decisionModeOptions.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
disabled={readOnly}
|
|
onClick={() => updateStewarding({ decisionMode: option.value })}
|
|
className={`
|
|
relative flex flex-col items-start gap-2 p-4 rounded-xl border-2 transition-all text-left
|
|
${stewarding.decisionMode === option.value
|
|
? 'border-primary-blue bg-primary-blue/5 shadow-[0_0_16px_rgba(25,140,255,0.15)]'
|
|
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500'
|
|
}
|
|
${readOnly ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}
|
|
`}
|
|
>
|
|
<div
|
|
className={`p-2 rounded-lg ${
|
|
stewarding.decisionMode === option.value
|
|
? 'bg-primary-blue/20 text-primary-blue'
|
|
: 'bg-charcoal-outline/50 text-gray-400'
|
|
}`}
|
|
>
|
|
{option.icon}
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-white">{option.label}</p>
|
|
<p className="text-xs text-gray-400 mt-0.5">{option.description}</p>
|
|
</div>
|
|
{stewarding.decisionMode === option.value && (
|
|
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-primary-blue" />
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Vote Requirements (conditional) */}
|
|
{selectedMode?.requiresVotes && (
|
|
<div className="p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline space-y-4">
|
|
<h4 className="text-sm font-medium text-white flex items-center gap-2">
|
|
<Vote className="w-4 h-4 text-primary-blue" />
|
|
Voting Configuration
|
|
</h4>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-400 mb-1.5">
|
|
Required votes to {stewarding.decisionMode.includes('veto') ? 'block' : 'uphold'}
|
|
</label>
|
|
<select
|
|
value={stewarding.requiredVotes ?? 2}
|
|
onChange={(e) => updateStewarding({ requiredVotes: parseInt(e.target.value, 10) })}
|
|
disabled={readOnly}
|
|
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue"
|
|
>
|
|
<option value={1}>1 vote</option>
|
|
<option value={2}>2 votes</option>
|
|
<option value={3}>3 votes (majority of 5)</option>
|
|
<option value={4}>4 votes</option>
|
|
<option value={5}>5 votes</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-400 mb-1.5">
|
|
Voting time limit
|
|
</label>
|
|
<select
|
|
value={stewarding.voteTimeLimit}
|
|
onChange={(e) => updateStewarding({ voteTimeLimit: parseInt(e.target.value, 10) })}
|
|
disabled={readOnly}
|
|
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue"
|
|
>
|
|
<option value={24}>24 hours</option>
|
|
<option value={48}>48 hours</option>
|
|
<option value={72}>72 hours (3 days)</option>
|
|
<option value={96}>96 hours (4 days)</option>
|
|
<option value={168}>168 hours (7 days)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Defense Settings */}
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-white mb-1 flex items-center gap-2">
|
|
<Shield className="w-4 h-4 text-primary-blue" />
|
|
Defense Requirements
|
|
</h3>
|
|
<p className="text-xs text-gray-400 mb-4">
|
|
Should accused drivers be required to submit a defense?
|
|
</p>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<button
|
|
type="button"
|
|
disabled={readOnly}
|
|
onClick={() => updateStewarding({ requireDefense: false })}
|
|
className={`
|
|
flex items-center gap-3 p-4 rounded-xl border-2 transition-all text-left
|
|
${!stewarding.requireDefense
|
|
? 'border-primary-blue bg-primary-blue/5'
|
|
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500'
|
|
}
|
|
${readOnly ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}
|
|
`}
|
|
>
|
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
|
|
!stewarding.requireDefense ? 'border-primary-blue' : 'border-gray-500'
|
|
}`}>
|
|
{!stewarding.requireDefense && <div className="w-2 h-2 rounded-full bg-primary-blue" />}
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-white">Defense optional</p>
|
|
<p className="text-xs text-gray-400">Proceed without waiting for defense</p>
|
|
</div>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={readOnly}
|
|
onClick={() => updateStewarding({ requireDefense: true })}
|
|
className={`
|
|
flex items-center gap-3 p-4 rounded-xl border-2 transition-all text-left
|
|
${stewarding.requireDefense
|
|
? 'border-primary-blue bg-primary-blue/5'
|
|
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500'
|
|
}
|
|
${readOnly ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}
|
|
`}
|
|
>
|
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
|
|
stewarding.requireDefense ? 'border-primary-blue' : 'border-gray-500'
|
|
}`}>
|
|
{stewarding.requireDefense && <div className="w-2 h-2 rounded-full bg-primary-blue" />}
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-white">Defense required</p>
|
|
<p className="text-xs text-gray-400">Wait for defense before deciding</p>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
{stewarding.requireDefense && (
|
|
<div className="mt-4 p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline">
|
|
<label className="block text-xs font-medium text-gray-400 mb-1.5">
|
|
Defense time limit
|
|
</label>
|
|
<select
|
|
value={stewarding.defenseTimeLimit}
|
|
onChange={(e) => updateStewarding({ defenseTimeLimit: parseInt(e.target.value, 10) })}
|
|
disabled={readOnly}
|
|
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue"
|
|
>
|
|
<option value={24}>24 hours</option>
|
|
<option value={48}>48 hours (2 days)</option>
|
|
<option value={72}>72 hours (3 days)</option>
|
|
<option value={96}>96 hours (4 days)</option>
|
|
</select>
|
|
<p className="text-xs text-gray-500 mt-2">
|
|
After this time, the decision can proceed without a defense
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Deadlines */}
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-white mb-1 flex items-center gap-2">
|
|
<Clock className="w-4 h-4 text-primary-blue" />
|
|
Deadlines
|
|
</h3>
|
|
<p className="text-xs text-gray-400 mb-4">
|
|
Set time limits for filing protests and closing stewarding
|
|
</p>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline">
|
|
<label className="block text-xs font-medium text-gray-400 mb-1.5">
|
|
Protest filing deadline (after race)
|
|
</label>
|
|
<select
|
|
value={stewarding.protestDeadlineHours}
|
|
onChange={(e) => updateStewarding({ protestDeadlineHours: parseInt(e.target.value, 10) })}
|
|
disabled={readOnly}
|
|
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue"
|
|
>
|
|
<option value={12}>12 hours</option>
|
|
<option value={24}>24 hours (1 day)</option>
|
|
<option value={48}>48 hours (2 days)</option>
|
|
<option value={72}>72 hours (3 days)</option>
|
|
<option value={168}>168 hours (7 days)</option>
|
|
</select>
|
|
<p className="text-xs text-gray-500 mt-2">
|
|
Drivers cannot file protests after this time
|
|
</p>
|
|
</div>
|
|
|
|
<div className="p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline">
|
|
<label className="block text-xs font-medium text-gray-400 mb-1.5">
|
|
Stewarding closes (after race)
|
|
</label>
|
|
<select
|
|
value={stewarding.stewardingClosesHours}
|
|
onChange={(e) => updateStewarding({ stewardingClosesHours: parseInt(e.target.value, 10) })}
|
|
disabled={readOnly}
|
|
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue"
|
|
>
|
|
<option value={72}>72 hours (3 days)</option>
|
|
<option value={96}>96 hours (4 days)</option>
|
|
<option value={168}>168 hours (7 days)</option>
|
|
<option value={336}>336 hours (14 days)</option>
|
|
</select>
|
|
<p className="text-xs text-gray-500 mt-2">
|
|
All stewarding must be concluded by this time
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notifications */}
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-white mb-1 flex items-center gap-2">
|
|
<Bell className="w-4 h-4 text-primary-blue" />
|
|
Notifications
|
|
</h3>
|
|
<p className="text-xs text-gray-400 mb-4">
|
|
Configure automatic notifications for involved parties
|
|
</p>
|
|
|
|
<div className="space-y-3">
|
|
<label
|
|
className={`flex items-center gap-3 p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline cursor-pointer hover:bg-iron-gray/60 transition-colors ${
|
|
readOnly ? 'opacity-60 cursor-not-allowed' : ''
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={stewarding.notifyAccusedOnProtest}
|
|
onChange={(e) => updateStewarding({ notifyAccusedOnProtest: e.target.checked })}
|
|
disabled={readOnly}
|
|
className="w-4 h-4 rounded border-charcoal-outline bg-deep-graphite text-primary-blue focus:ring-primary-blue focus:ring-offset-0"
|
|
/>
|
|
<div>
|
|
<p className="text-sm font-medium text-white">Notify accused driver</p>
|
|
<p className="text-xs text-gray-400">
|
|
Send notification when a protest is filed against them
|
|
</p>
|
|
</div>
|
|
</label>
|
|
|
|
<label
|
|
className={`flex items-center gap-3 p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline cursor-pointer hover:bg-iron-gray/60 transition-colors ${
|
|
readOnly ? 'opacity-60 cursor-not-allowed' : ''
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={stewarding.notifyOnVoteRequired}
|
|
onChange={(e) => updateStewarding({ notifyOnVoteRequired: e.target.checked })}
|
|
disabled={readOnly}
|
|
className="w-4 h-4 rounded border-charcoal-outline bg-deep-graphite text-primary-blue focus:ring-primary-blue focus:ring-offset-0"
|
|
/>
|
|
<div>
|
|
<p className="text-sm font-medium text-white">Notify voters</p>
|
|
<p className="text-xs text-gray-400">
|
|
Send notification to stewards/members when their vote is needed
|
|
</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Warning about strict settings */}
|
|
{stewarding.requireDefense && stewarding.decisionMode !== 'admin_only' && (
|
|
<div className="flex items-start gap-3 p-4 rounded-xl bg-warning-amber/10 border border-warning-amber/20">
|
|
<AlertTriangle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="text-sm font-medium text-warning-amber">Strict settings enabled</p>
|
|
<p className="text-xs text-warning-amber/80 mt-1">
|
|
Requiring defense and voting may delay penalty decisions. Make sure your stewards/members
|
|
are active enough to meet the deadlines.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |