538 lines
19 KiB
TypeScript
538 lines
19 KiB
TypeScript
'use client';
|
||
|
||
import { Trophy, Award, Star, Target } from 'lucide-react';
|
||
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||
import Button from '@/components/ui/Button';
|
||
import PresetCard from '@/components/ui/PresetCard';
|
||
|
||
interface LeagueScoringSectionProps {
|
||
form: LeagueConfigFormModel;
|
||
presets: LeagueScoringPresetDTO[];
|
||
onChange?: (form: LeagueConfigFormModel) => void;
|
||
readOnly?: boolean;
|
||
/**
|
||
* When true, only render the scoring pattern panel.
|
||
*/
|
||
patternOnly?: boolean;
|
||
/**
|
||
* When true, only render the championships panel.
|
||
*/
|
||
championshipsOnly?: boolean;
|
||
}
|
||
|
||
interface ScoringPatternSectionProps {
|
||
scoring: LeagueConfigFormModel['scoring'];
|
||
presets: LeagueScoringPresetDTO[];
|
||
readOnly?: boolean;
|
||
patternError?: string;
|
||
onChangePatternId?: (patternId: string) => void;
|
||
onToggleCustomScoring?: () => void;
|
||
}
|
||
|
||
interface ChampionshipsSectionProps {
|
||
form: LeagueConfigFormModel;
|
||
onChange?: (form: LeagueConfigFormModel) => void;
|
||
readOnly?: boolean;
|
||
}
|
||
|
||
export function LeagueScoringSection({
|
||
form,
|
||
presets,
|
||
onChange,
|
||
readOnly,
|
||
patternOnly,
|
||
championshipsOnly,
|
||
}: LeagueScoringSectionProps) {
|
||
const disabled = readOnly || !onChange;
|
||
|
||
const updateScoring = (
|
||
patch: Partial<LeagueConfigFormModel['scoring']>,
|
||
) => {
|
||
if (!onChange) return;
|
||
onChange({
|
||
...form,
|
||
scoring: {
|
||
...form.scoring,
|
||
...patch,
|
||
},
|
||
});
|
||
};
|
||
|
||
const updateChampionships = (
|
||
patch: Partial<LeagueConfigFormModel['championships']>,
|
||
) => {
|
||
if (!onChange) return;
|
||
onChange({
|
||
...form,
|
||
championships: {
|
||
...form.championships,
|
||
...patch,
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleSelectPreset = (presetId: string) => {
|
||
if (disabled) return;
|
||
updateScoring({
|
||
patternId: presetId,
|
||
customScoringEnabled: false,
|
||
});
|
||
};
|
||
|
||
const handleToggleCustomScoring = () => {
|
||
if (disabled) return;
|
||
const current = !!form.scoring.customScoringEnabled;
|
||
updateScoring({
|
||
customScoringEnabled: !current,
|
||
});
|
||
};
|
||
|
||
const currentPreset =
|
||
presets.find((p) => p.id === form.scoring.patternId) ?? null;
|
||
|
||
const isTeamsMode = form.structure.mode === 'fixedTeams';
|
||
|
||
const renderPrimaryChampionshipLabel = () => {
|
||
if (!currentPreset) {
|
||
return '—';
|
||
}
|
||
switch (currentPreset.primaryChampionshipType) {
|
||
case 'driver':
|
||
return 'Driver championship';
|
||
case 'team':
|
||
return 'Team championship';
|
||
case 'nations':
|
||
return 'Nations championship';
|
||
case 'trophy':
|
||
return 'Trophy championship';
|
||
default:
|
||
return currentPreset.primaryChampionshipType;
|
||
}
|
||
};
|
||
|
||
const selectedPreset =
|
||
currentPreset ??
|
||
(presets.length > 0
|
||
? presets.find((p) => p.id === form.scoring.patternId) ?? null
|
||
: null);
|
||
|
||
const patternPanel = (
|
||
<ScoringPatternSection
|
||
scoring={form.scoring}
|
||
presets={presets}
|
||
readOnly={readOnly}
|
||
onChangePatternId={
|
||
!readOnly && onChange ? (id) => handleSelectPreset(id) : undefined
|
||
}
|
||
onToggleCustomScoring={disabled ? undefined : handleToggleCustomScoring}
|
||
/>
|
||
);
|
||
|
||
const championshipsPanel = (
|
||
<ChampionshipsSection form={form} onChange={onChange} readOnly={readOnly} />
|
||
);
|
||
|
||
if (patternOnly) {
|
||
return <div>{patternPanel}</div>;
|
||
}
|
||
|
||
if (championshipsOnly) {
|
||
return <div>{championshipsPanel}</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)]">
|
||
{patternPanel}
|
||
{championshipsPanel}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Step 4 – scoring pattern preset picker used by the wizard.
|
||
*/
|
||
export function ScoringPatternSection({
|
||
scoring,
|
||
presets,
|
||
readOnly,
|
||
patternError,
|
||
onChangePatternId,
|
||
onToggleCustomScoring,
|
||
}: ScoringPatternSectionProps) {
|
||
const disabled = readOnly || !onChangePatternId;
|
||
const currentPreset =
|
||
presets.find((p) => p.id === scoring.patternId) ?? null;
|
||
|
||
const renderPrimaryLabel = (preset: LeagueScoringPresetDTO) => {
|
||
switch (preset.primaryChampionshipType) {
|
||
case 'driver':
|
||
return 'Driver focus';
|
||
case 'team':
|
||
return 'Team focus';
|
||
case 'nations':
|
||
return 'Nations focus';
|
||
case 'trophy':
|
||
return 'Trophy / cup focus';
|
||
default:
|
||
return preset.primaryChampionshipType;
|
||
}
|
||
};
|
||
|
||
const handleSelect = (presetId: string) => {
|
||
if (disabled) return;
|
||
onChangePatternId?.(presetId);
|
||
};
|
||
|
||
return (
|
||
<section className="space-y-4">
|
||
<header className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Trophy className="w-4 h-4 text-primary-blue" />
|
||
<h3 className="text-sm font-semibold text-white">Scoring pattern</h3>
|
||
</div>
|
||
<p className="text-xs text-gray-400">
|
||
Choose a preset that matches your race weekend format
|
||
</p>
|
||
</header>
|
||
|
||
{presets.length === 0 ? (
|
||
<p className="text-sm text-gray-400">No presets available.</p>
|
||
) : (
|
||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||
{presets.map((preset) => (
|
||
<PresetCard
|
||
key={preset.id}
|
||
title={preset.name}
|
||
subtitle={preset.sessionSummary}
|
||
primaryTag={renderPrimaryLabel(preset)}
|
||
stats={[
|
||
{ label: 'Sessions', value: preset.sessionSummary },
|
||
{ label: 'Drops', value: preset.dropPolicySummary },
|
||
{ label: 'Bonuses', value: preset.bonusSummary },
|
||
]}
|
||
selected={scoring.patternId === preset.id}
|
||
disabled={readOnly}
|
||
onSelect={() => handleSelect(preset.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{patternError && (
|
||
<p className="text-xs text-warning-amber">{patternError}</p>
|
||
)}
|
||
|
||
<div className="mt-3 space-y-3 rounded-lg border border-charcoal-outline/70 bg-iron-gray/40 p-4">
|
||
<div className="flex items-center gap-2">
|
||
<Star className="w-4 h-4 text-primary-blue" />
|
||
<div className="font-semibold text-gray-200">Selected pattern</div>
|
||
</div>
|
||
{currentPreset ? (
|
||
<div className="space-y-2 text-xs">
|
||
<div className="flex items-center gap-2">
|
||
<span className="inline-flex rounded-full bg-primary-blue/10 px-3 py-1 text-xs font-medium text-primary-blue">
|
||
{currentPreset.name}
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs">
|
||
<div className="flex items-start gap-2">
|
||
<Target className="w-3 h-3 mt-0.5 text-gray-400 shrink-0" />
|
||
<div>
|
||
<div className="text-gray-500">Sessions</div>
|
||
<div className="text-gray-200">{currentPreset.sessionSummary}</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-start gap-2">
|
||
<Trophy className="w-3 h-3 mt-0.5 text-gray-400 shrink-0" />
|
||
<div>
|
||
<div className="text-gray-500">Points focus</div>
|
||
<div className="text-gray-200">{renderPrimaryLabel(currentPreset)}</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-start gap-2">
|
||
<Award className="w-3 h-3 mt-0.5 text-gray-400 shrink-0" />
|
||
<div>
|
||
<div className="text-gray-500">Default drops</div>
|
||
<div className="text-gray-200">{currentPreset.dropPolicySummary}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="text-xs text-gray-500">
|
||
No pattern selected yet. Pick a card above to define your scoring style.
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-3 flex items-center justify-between gap-4 rounded-lg border border-charcoal-outline/70 bg-deep-graphite/60 p-3">
|
||
<div className="space-y-1">
|
||
<p className="text-xs font-medium text-gray-200">
|
||
Custom scoring (advanced)
|
||
</p>
|
||
<p className="text-[11px] text-gray-500">
|
||
In this alpha, presets still define the actual scoring; this flag marks intent only.
|
||
</p>
|
||
</div>
|
||
|
||
{readOnly ? (
|
||
<span
|
||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||
scoring.customScoringEnabled
|
||
? 'bg-primary-blue/15 text-primary-blue'
|
||
: 'bg-charcoal-outline/60 text-gray-300'
|
||
}`}
|
||
>
|
||
{scoring.customScoringEnabled
|
||
? 'Custom scoring flagged'
|
||
: 'Using preset scoring'}
|
||
</span>
|
||
) : (
|
||
<Button
|
||
type="button"
|
||
variant={scoring.customScoringEnabled ? 'primary' : 'secondary'}
|
||
disabled={!onToggleCustomScoring}
|
||
onClick={onToggleCustomScoring}
|
||
className="shrink-0"
|
||
>
|
||
{scoring.customScoringEnabled ? 'Custom scoring flagged' : 'Use preset scoring'}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Step 5 – championships-only panel used by the wizard.
|
||
*/
|
||
export function ChampionshipsSection({
|
||
form,
|
||
onChange,
|
||
readOnly,
|
||
}: ChampionshipsSectionProps) {
|
||
const disabled = readOnly || !onChange;
|
||
const isTeamsMode = form.structure.mode === 'fixedTeams';
|
||
|
||
const updateChampionships = (
|
||
patch: Partial<LeagueConfigFormModel['championships']>,
|
||
) => {
|
||
if (!onChange) return;
|
||
onChange({
|
||
...form,
|
||
championships: {
|
||
...form.championships,
|
||
...patch,
|
||
},
|
||
});
|
||
};
|
||
|
||
return (
|
||
<section className="space-y-4">
|
||
<header className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Award className="w-4 h-4 text-primary-blue" />
|
||
<h3 className="text-sm font-semibold text-white">Championships</h3>
|
||
</div>
|
||
<p className="text-xs text-gray-400">
|
||
Select which championship standings to track this season
|
||
</p>
|
||
</header>
|
||
|
||
<div className="space-y-3 text-xs text-gray-300">
|
||
{/* Driver championship */}
|
||
<div className="flex items-start justify-between gap-3 rounded-lg border border-charcoal-outline/50 bg-iron-gray/30 p-4 transition-all duration-200 hover:border-primary-blue/30">
|
||
<div className="flex items-start gap-3">
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
|
||
<Trophy className="w-4 h-4 text-primary-blue" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<div className="text-sm font-medium text-gray-100">Driver championship</div>
|
||
<p className="text-xs text-gray-500">
|
||
Per-driver season standings across all points-scoring sessions.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0">
|
||
{readOnly ? (
|
||
<span
|
||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||
form.championships.enableDriverChampionship
|
||
? 'bg-primary-blue/15 text-primary-blue'
|
||
: 'bg-charcoal-outline/60 text-gray-300'
|
||
}`}
|
||
>
|
||
{form.championships.enableDriverChampionship ? 'On' : 'Off'}
|
||
</span>
|
||
) : (
|
||
<label className="inline-flex items-center gap-2 text-xs">
|
||
<input
|
||
type="checkbox"
|
||
className="h-4 w-4 rounded border-charcoal-outline bg-iron-gray text-primary-blue"
|
||
checked={form.championships.enableDriverChampionship}
|
||
disabled={disabled}
|
||
onChange={(e) =>
|
||
updateChampionships({
|
||
enableDriverChampionship: e.target.checked,
|
||
})
|
||
}
|
||
/>
|
||
<span className="text-gray-200">On</span>
|
||
</label>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Team championship */}
|
||
<div className={`flex items-start justify-between gap-3 rounded-lg border border-charcoal-outline/50 bg-iron-gray/30 p-4 transition-all duration-200 ${isTeamsMode ? 'hover:border-primary-blue/30' : 'opacity-60'}`}>
|
||
<div className="flex items-start gap-3">
|
||
<div className={`flex h-8 w-8 items-center justify-center rounded-lg shrink-0 ${isTeamsMode ? 'bg-primary-blue/10' : 'bg-gray-500/10'}`}>
|
||
<Award className={`w-4 h-4 ${isTeamsMode ? 'text-primary-blue' : 'text-gray-500'}`} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<div className="text-sm font-medium text-gray-100">Team championship</div>
|
||
<p className="text-xs text-gray-500">
|
||
Aggregated season standings for fixed teams.
|
||
</p>
|
||
{!isTeamsMode && (
|
||
<p className="text-xs text-warning-amber/80">
|
||
Enable team mode in Structure to activate this
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0">
|
||
{readOnly ? (
|
||
<span
|
||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||
isTeamsMode && form.championships.enableTeamChampionship
|
||
? 'bg-primary-blue/15 text-primary-blue'
|
||
: 'bg-charcoal-outline/60 text-gray-300'
|
||
}`}
|
||
>
|
||
{isTeamsMode && form.championships.enableTeamChampionship ? 'On' : 'Off'}
|
||
</span>
|
||
) : (
|
||
<label className="inline-flex items-center gap-2 text-xs">
|
||
<input
|
||
type="checkbox"
|
||
className="h-4 w-4 rounded border-charcoal-outline bg-iron-gray text-primary-blue"
|
||
checked={
|
||
isTeamsMode && form.championships.enableTeamChampionship
|
||
}
|
||
disabled={disabled || !isTeamsMode}
|
||
onChange={(e) =>
|
||
updateChampionships({
|
||
enableTeamChampionship: e.target.checked,
|
||
})
|
||
}
|
||
/>
|
||
<span
|
||
className={`text-gray-200 ${
|
||
!isTeamsMode ? 'opacity-60' : ''
|
||
}`}
|
||
>
|
||
On
|
||
</span>
|
||
</label>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Nations championship */}
|
||
<div className="flex items-start justify-between gap-3 rounded-lg border border-charcoal-outline/50 bg-iron-gray/30 p-4 transition-all duration-200 hover:border-primary-blue/30">
|
||
<div className="flex items-start gap-3">
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
|
||
<Star className="w-4 h-4 text-primary-blue" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<div className="text-sm font-medium text-gray-100">Nations Cup</div>
|
||
<p className="text-xs text-gray-500">
|
||
Standings grouped by drivers' nationality or country flag.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0">
|
||
{readOnly ? (
|
||
<span
|
||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||
form.championships.enableNationsChampionship
|
||
? 'bg-primary-blue/15 text-primary-blue'
|
||
: 'bg-charcoal-outline/60 text-gray-300'
|
||
}`}
|
||
>
|
||
{form.championships.enableNationsChampionship ? 'On' : 'Off'}
|
||
</span>
|
||
) : (
|
||
<label className="inline-flex items-center gap-2 text-xs">
|
||
<input
|
||
type="checkbox"
|
||
className="h-4 w-4 rounded border-charcoal-outline bg-iron-gray text-primary-blue"
|
||
checked={form.championships.enableNationsChampionship}
|
||
disabled={disabled}
|
||
onChange={(e) =>
|
||
updateChampionships({
|
||
enableNationsChampionship: e.target.checked,
|
||
})
|
||
}
|
||
/>
|
||
<span className="text-gray-200">On</span>
|
||
</label>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Trophy championship */}
|
||
<div className="flex items-start justify-between gap-3 rounded-lg border border-charcoal-outline/50 bg-iron-gray/30 p-4 transition-all duration-200 hover:border-primary-blue/30">
|
||
<div className="flex items-start gap-3">
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
|
||
<Award className="w-4 h-4 text-primary-blue" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<div className="text-sm font-medium text-gray-100">Trophy / cup</div>
|
||
<p className="text-xs text-gray-500">
|
||
Extra cup-style standings for special categories or invite-only groups.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0">
|
||
{readOnly ? (
|
||
<span
|
||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||
form.championships.enableTrophyChampionship
|
||
? 'bg-primary-blue/15 text-primary-blue'
|
||
: 'bg-charcoal-outline/60 text-gray-300'
|
||
}`}
|
||
>
|
||
{form.championships.enableTrophyChampionship ? 'On' : 'Off'}
|
||
</span>
|
||
) : (
|
||
<label className="inline-flex items-center gap-2 text-xs">
|
||
<input
|
||
type="checkbox"
|
||
className="h-4 w-4 rounded border-charcoal-outline bg-iron-gray text-primary-blue"
|
||
checked={form.championships.enableTrophyChampionship}
|
||
disabled={disabled}
|
||
onChange={(e) =>
|
||
updateChampionships({
|
||
enableTrophyChampionship: e.target.checked,
|
||
})
|
||
}
|
||
/>
|
||
<span className="text-gray-200">On</span>
|
||
</label>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pt-2 rounded-md bg-deep-graphite/40 p-3 text-xs text-gray-500 border border-charcoal-outline/30">
|
||
<p className="flex items-start gap-2">
|
||
<span className="shrink-0">ℹ️</span>
|
||
<span>For this alpha, only driver standings are fully calculated. These toggles express intent for future seasons.</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
} |