strat-gameplay-webapp/frontend-sba/components/Lineup/InlineSubstitutionPanel.vue
Cal Corum e058bc4a6c CLAUDE: RosterLink refactor for bench players with cached player data
- Add player_positions JSONB column to roster_links (migration 006)
- Add player_data JSONB column to cache name/image/headshot (migration 007)
- Add is_pitcher/is_batter computed properties for two-way player support
- Update lineup submission to populate RosterLink with all players + positions
- Update get_bench handler to use cached data (no runtime API calls)
- Add BenchPlayer type to frontend with proper filtering
- Add new Lineup components: InlineSubstitutionPanel, LineupSlotRow,
  PositionSelector, UnifiedLineupTab
- Add integration tests for get_bench_players

Bench players now load instantly without API dependency, and properly
filter batters vs pitchers (including CP closer position).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 22:15:12 -06:00

345 lines
11 KiB
Vue

<template>
<div class="p-4 space-y-4">
<!-- Position Selector -->
<PositionSelector
v-if="showPositionSelector"
:mode="substitutionType"
v-model="selectedPosition"
:current-position="currentPosition"
:label="positionLabel"
:required="positionRequired"
/>
<!-- Available Players Section -->
<div v-if="!isPositionChangeOnly">
<div class="text-xs text-gray-400 mb-2">{{ playersLabel }}</div>
<!-- No players message -->
<div v-if="availablePlayers.length === 0" class="text-center py-6 text-gray-500 text-sm">
No players available
</div>
<!-- Player Grid -->
<div v-else class="grid grid-cols-2 gap-2">
<button
v-for="benchPlayer in availablePlayers"
:key="benchPlayer.roster_id"
:class="[
'rounded-lg p-2.5 text-left transition-all select-none touch-manipulation',
selectedPlayerId === benchPlayer.player_id
? 'bg-green-900/40 border-green-600/50 border ring-2 ring-green-500/30'
: 'bg-gray-700/60 hover:bg-green-900/40 border border-transparent hover:border-green-600/50'
]"
@click="selectPlayer(benchPlayer)"
>
<div class="flex items-center gap-2">
<div
:class="[
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold',
selectedPlayerId === benchPlayer.player_id
? 'bg-gradient-to-br from-green-700 to-green-800'
: 'bg-gradient-to-br from-gray-600 to-gray-700'
]"
>
<template v-if="selectedPlayerId === benchPlayer.player_id">
</template>
<template v-else>
{{ getInitials(benchPlayer.player.name) }}
</template>
</div>
<div>
<div class="font-medium text-sm">{{ benchPlayer.player.name }}</div>
<div :class="[
'text-[10px]',
selectedPlayerId === benchPlayer.player_id ? 'text-green-400' : 'text-gray-400'
]">
{{ formatPositions(benchPlayer) }}
</div>
</div>
</div>
</button>
</div>
</div>
<!-- Emergency Players (Pitchers for defensive subs) -->
<div v-if="showEmergencySection && emergencyPlayers.length > 0">
<button
class="w-full flex items-center justify-between text-xs text-gray-500 hover:text-gray-400 py-2 border-t border-gray-700/50 transition-colors select-none"
@click="showEmergency = !showEmergency"
>
<span>
<span class="mr-1">{{ showEmergency ? '▼' : '▶' }}</span>
Show Pitchers
<span class="text-amber-500">(emergency)</span>
</span>
<span class="bg-gray-700 px-1.5 py-0.5 rounded text-gray-400">
{{ emergencyPlayers.length }}
</span>
</button>
<div v-if="showEmergency" class="grid grid-cols-2 gap-2 mt-2">
<button
v-for="benchPlayer in emergencyPlayers"
:key="benchPlayer.roster_id"
:class="[
'rounded-lg p-2.5 text-left transition-all select-none touch-manipulation',
selectedPlayerId === benchPlayer.player_id
? 'bg-amber-900/40 border-amber-600/50 border'
: 'bg-amber-900/20 hover:bg-amber-900/40 border border-amber-700/30'
]"
@click="selectPlayer(benchPlayer)"
>
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-amber-700 to-amber-800 flex items-center justify-center text-xs font-bold">
{{ getInitials(benchPlayer.player.name) }}
</div>
<div>
<div class="font-medium text-sm text-amber-200">{{ benchPlayer.player.name }}</div>
<div class="text-[10px] text-amber-400">{{ formatPositions(benchPlayer) }}</div>
</div>
</div>
</button>
</div>
</div>
<!-- Position Change Confirm Button -->
<button
v-if="isPositionChangeOnly"
:disabled="!canSubmit"
:class="[
'w-full py-2.5 font-semibold rounded-lg transition-colors select-none touch-manipulation',
canSubmit
? 'bg-blue-600 hover:bg-blue-500 text-white'
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
]"
@click="handleSubmit"
>
Confirm Position Change
</button>
<!-- Substitution Submit (auto-submits when player selected for non-defensive) -->
<div
v-if="!isPositionChangeOnly && substitutionType === 'defensive_replacement' && selectedPlayerId"
class="pt-2"
>
<button
:disabled="!canSubmit"
:class="[
'w-full py-2.5 font-semibold rounded-lg transition-colors select-none touch-manipulation',
canSubmit
? 'bg-green-600 hover:bg-green-500 text-white'
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
]"
@click="handleSubmit"
>
Confirm Substitution
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { BenchPlayer } from '~/types'
import PositionSelector from './PositionSelector.vue'
type SubstitutionType = 'pinch_hitter' | 'pinch_runner' | 'defensive_replacement' | 'relief_pitcher' | 'position_change'
interface Props {
substitutionType: SubstitutionType
benchPlayers: BenchPlayer[]
currentPosition?: string | null
teamId: number
playerOutLineupId: number
}
const props = withDefaults(defineProps<Props>(), {
currentPosition: null,
})
const emit = defineEmits<{
submit: [payload: {
playerOutLineupId: number
playerInCardId?: number
newPosition: string
teamId: number
type: SubstitutionType
}]
}>()
// Local state
const selectedPosition = ref<string | null>(getDefaultPosition())
const selectedPlayerId = ref<number | null>(null)
const showEmergency = ref(false)
// Helper to get default position based on substitution type
function getDefaultPosition(): string | null {
switch (props.substitutionType) {
case 'pinch_hitter':
return 'PH'
case 'pinch_runner':
return 'PR'
case 'relief_pitcher':
return 'P'
case 'defensive_replacement':
return null // Required selection
case 'position_change':
return null
default:
return null
}
}
// Is this a position change only (no player selection)?
const isPositionChangeOnly = computed(() => props.substitutionType === 'position_change')
// Should we show position selector?
const showPositionSelector = computed(() => {
// Always show for position change
if (props.substitutionType === 'position_change') return true
// Don't show for relief pitcher (fixed to P)
if (props.substitutionType === 'relief_pitcher') return false
// Show for everything else
return true
})
// Position selector label
const positionLabel = computed(() => {
if (props.substitutionType === 'position_change') {
return 'Change position to:'
}
return 'Position for new player:'
})
// Is position required?
const positionRequired = computed(() => {
return props.substitutionType === 'defensive_replacement'
})
// Players label
const playersLabel = computed(() => {
switch (props.substitutionType) {
case 'relief_pitcher':
return 'Available Relievers:'
case 'defensive_replacement':
return 'Available Position Players:'
case 'pinch_hitter':
return 'Available Batters:'
case 'pinch_runner':
return 'Available Runners:'
default:
return 'Available Players:'
}
})
// Show emergency section for defensive replacements and pinch hitters
const showEmergencySection = computed(() => {
return props.substitutionType === 'defensive_replacement' ||
props.substitutionType === 'pinch_hitter'
})
// Filter available players based on substitution type
// Uses is_pitcher/is_batter computed properties from backend
// Supports two-way players (is_pitcher=true AND is_batter=true)
const availablePlayers = computed(() => {
switch (props.substitutionType) {
case 'relief_pitcher':
// Only show players with pitching positions
return props.benchPlayers.filter(p => p.is_pitcher)
case 'defensive_replacement':
case 'pinch_hitter':
// Show players with batting positions (includes two-way players)
return props.benchPlayers.filter(p => p.is_batter)
default:
// Show all bench players
return props.benchPlayers
}
})
// Emergency players (pitcher-only players for pinch hitters/defensive subs)
// These are pitchers who DON'T have batting positions (not two-way players)
const emergencyPlayers = computed(() => {
if (props.substitutionType !== 'defensive_replacement' &&
props.substitutionType !== 'pinch_hitter') return []
// Show pitcher-only players (is_pitcher=true but is_batter=false)
return props.benchPlayers.filter(p => p.is_pitcher && !p.is_batter)
})
// Can submit the substitution?
const canSubmit = computed(() => {
if (props.substitutionType === 'position_change') {
return selectedPosition.value !== null && selectedPosition.value !== props.currentPosition
}
if (props.substitutionType === 'defensive_replacement') {
return selectedPosition.value !== null && selectedPlayerId.value !== null
}
// For PH/PR/relief, position has default, just need player
return selectedPlayerId.value !== null
})
// Get player initials
function getInitials(name: string): string {
const parts = name.split(' ')
if (parts.length >= 2) {
return parts[0][0] + parts[parts.length - 1][0]
}
return name.substring(0, 2).toUpperCase()
}
// Format positions for display
// Uses player_positions array from backend (populated from RosterLink)
function formatPositions(benchPlayer: BenchPlayer): string {
if (benchPlayer.player_positions && benchPlayer.player_positions.length > 0) {
return benchPlayer.player_positions.slice(0, 3).join(', ')
}
return 'N/A'
}
// Select a player
function selectPlayer(benchPlayer: BenchPlayer) {
selectedPlayerId.value = benchPlayer.player_id
// For non-defensive subs with default position, auto-submit
if (props.substitutionType !== 'defensive_replacement' &&
props.substitutionType !== 'position_change' &&
selectedPosition.value) {
handleSubmit()
}
}
// Submit the substitution
function handleSubmit() {
if (!canSubmit.value) return
const payload: {
playerOutLineupId: number
playerInCardId?: number
newPosition: string
teamId: number
type: SubstitutionType
} = {
playerOutLineupId: props.playerOutLineupId,
newPosition: selectedPosition.value!,
teamId: props.teamId,
type: props.substitutionType,
}
// Include player ID for actual substitutions (not position changes)
if (!isPositionChangeOnly.value && selectedPlayerId.value) {
payload.playerInCardId = selectedPlayerId.value
}
emit('submit', payload)
}
// Reset position when substitution type changes
watch(() => props.substitutionType, () => {
selectedPosition.value = getDefaultPosition()
selectedPlayerId.value = null
showEmergency.value = false
})
</script>