- 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>
345 lines
11 KiB
Vue
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>
|