- 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>
427 lines
14 KiB
Vue
427 lines
14 KiB
Vue
<template>
|
|
<div class="unified-lineup-tab">
|
|
<!-- Pre-game: Show LineupBuilder -->
|
|
<template v-if="!isGameActive">
|
|
<LineupBuilder
|
|
:game-id="gameId"
|
|
:team-id="myTeamId"
|
|
@lineups-submitted="handleLineupSubmit"
|
|
/>
|
|
</template>
|
|
|
|
<!-- Mid-game: Read-only lineup with substitution actions -->
|
|
<template v-else>
|
|
<!-- Team Tabs -->
|
|
<div class="flex gap-2 mb-4 bg-gray-800/50 p-1 rounded-xl inline-flex">
|
|
<button
|
|
:class="[
|
|
'px-4 py-2 text-sm font-semibold rounded-lg transition-colors select-none',
|
|
isViewingAwayTeam ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-gray-700/50'
|
|
]"
|
|
@click="viewingTeamId = awayTeamId"
|
|
>
|
|
{{ awayTeamName }}
|
|
</button>
|
|
<button
|
|
:class="[
|
|
'px-4 py-2 text-sm font-semibold rounded-lg transition-colors select-none',
|
|
!isViewingAwayTeam ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-gray-700/50'
|
|
]"
|
|
@click="viewingTeamId = homeTeamId"
|
|
>
|
|
{{ homeTeamName }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Context indicator -->
|
|
<p class="text-xs text-gray-400 mb-4">
|
|
Mid-game view
|
|
<template v-if="isMyTeam">
|
|
• Your team is {{ isBatting ? 'BATTING' : 'FIELDING' }}
|
|
</template>
|
|
</p>
|
|
|
|
<!-- Batting Order Section -->
|
|
<div class="mb-4">
|
|
<h2 class="text-sm font-semibold text-gray-400 mb-2 uppercase tracking-wide">Batting Order</h2>
|
|
|
|
<LineupSlotRow
|
|
v-for="slot in battingOrderSlots"
|
|
:key="slot.lineup_id"
|
|
:player="slot"
|
|
:batting-order="slot.batting_order"
|
|
:state="getSlotState(slot)"
|
|
:is-expanded="expandedSlotId === slot.lineup_id && expandedMode !== 'position_change'"
|
|
:show-actions="canSubstitute(slot)"
|
|
:show-position-change="canChangePosition(slot)"
|
|
:substitute-label="getSubstituteLabel(slot)"
|
|
@substitute="openSubstitution(slot)"
|
|
@change-position="openPositionChange(slot)"
|
|
@cancel="closeExpanded"
|
|
>
|
|
<template #expanded>
|
|
<InlineSubstitutionPanel
|
|
:substitution-type="getSubstitutionType(slot)"
|
|
:bench-players="benchPlayers"
|
|
:current-position="slot.position"
|
|
:team-id="viewingTeamId"
|
|
:player-out-lineup-id="slot.lineup_id"
|
|
@submit="handleSubstitutionSubmit"
|
|
/>
|
|
</template>
|
|
</LineupSlotRow>
|
|
</div>
|
|
|
|
<!-- Runners on Base Section (when batting) -->
|
|
<div v-if="isBatting && runnersOnBase.length > 0" class="mb-4">
|
|
<h2 class="text-sm font-semibold text-green-400 mb-2 uppercase tracking-wide flex items-center gap-2">
|
|
<span>◆</span> Runners on Base
|
|
</h2>
|
|
|
|
<div class="bg-green-900/20 rounded-lg border border-green-700/30 p-2.5 space-y-2">
|
|
<LineupSlotRow
|
|
v-for="runner in runnersOnBase"
|
|
:key="`runner-${runner.lineup_id}`"
|
|
:player="runner"
|
|
:base-position="getBasePosition(runner)"
|
|
:state="'on_base'"
|
|
:is-expanded="expandedSlotId === runner.lineup_id && expandedMode === 'pinch_runner'"
|
|
:show-actions="canSubstitute(runner)"
|
|
:show-position-change="false"
|
|
substitute-label="Pinch Run"
|
|
@substitute="openPinchRunner(runner)"
|
|
@cancel="closeExpanded"
|
|
>
|
|
<template #expanded>
|
|
<InlineSubstitutionPanel
|
|
substitution-type="pinch_runner"
|
|
:bench-players="benchPlayers"
|
|
:current-position="runner.position"
|
|
:team-id="viewingTeamId"
|
|
:player-out-lineup-id="runner.lineup_id"
|
|
@submit="handleSubstitutionSubmit"
|
|
/>
|
|
</template>
|
|
</LineupSlotRow>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pitcher Section -->
|
|
<div class="mb-4">
|
|
<h2 class="text-sm font-semibold text-gray-400 mb-2 uppercase tracking-wide">
|
|
{{ isStartingPitcher ? 'Starting Pitcher' : 'Current Pitcher' }}
|
|
</h2>
|
|
|
|
<LineupSlotRow
|
|
v-if="currentPitcherLineup"
|
|
:player="currentPitcherLineup"
|
|
:is-pitcher="true"
|
|
:state="expandedSlotId === currentPitcherLineup.lineup_id ? 'expanded' : 'normal'"
|
|
:is-expanded="expandedSlotId === currentPitcherLineup.lineup_id"
|
|
:show-actions="canChangePitcher"
|
|
:show-position-change="false"
|
|
substitute-label="Change Pitcher"
|
|
@substitute="openPitchingChange"
|
|
@cancel="closeExpanded"
|
|
>
|
|
<template #expanded>
|
|
<InlineSubstitutionPanel
|
|
substitution-type="relief_pitcher"
|
|
:bench-players="benchPlayers"
|
|
:current-position="'P'"
|
|
:team-id="viewingTeamId"
|
|
:player-out-lineup-id="currentPitcherLineup.lineup_id"
|
|
@submit="handleSubstitutionSubmit"
|
|
/>
|
|
</template>
|
|
</LineupSlotRow>
|
|
</div>
|
|
|
|
<!-- Position Change Panel (separate from substitution) -->
|
|
<div v-if="expandedMode === 'position_change' && positionChangeSlot" class="mb-4">
|
|
<h2 class="text-sm font-semibold text-blue-400 mb-2 uppercase tracking-wide">Position Change</h2>
|
|
|
|
<LineupSlotRow
|
|
:player="positionChangeSlot"
|
|
:state="'position_change'"
|
|
:is-expanded="true"
|
|
:show-actions="false"
|
|
@cancel="closeExpanded"
|
|
>
|
|
<template #expanded>
|
|
<InlineSubstitutionPanel
|
|
substitution-type="position_change"
|
|
:bench-players="[]"
|
|
:current-position="positionChangeSlot.position"
|
|
:team-id="viewingTeamId"
|
|
:player-out-lineup-id="positionChangeSlot.lineup_id"
|
|
@submit="handleSubstitutionSubmit"
|
|
/>
|
|
</template>
|
|
</LineupSlotRow>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import { useGameStore } from '~/store/game'
|
|
import { useGameActions } from '~/composables/useGameActions'
|
|
import type { Lineup } from '~/types'
|
|
import LineupBuilder from '~/components/Game/LineupBuilder.vue'
|
|
import LineupSlotRow from './LineupSlotRow.vue'
|
|
import InlineSubstitutionPanel from './InlineSubstitutionPanel.vue'
|
|
|
|
type ExpandedMode = 'substitution' | 'position_change' | 'pinch_runner' | null
|
|
|
|
interface Props {
|
|
gameId: string
|
|
myTeamId: number | null
|
|
homeTeamName?: string
|
|
awayTeamName?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
homeTeamName: 'Home',
|
|
awayTeamName: 'Away',
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
lineupsSubmitted: [result: unknown]
|
|
}>()
|
|
|
|
const gameStore = useGameStore()
|
|
const { submitSubstitution, getBench } = useGameActions()
|
|
|
|
// Local state
|
|
const viewingTeamId = ref<number>(props.myTeamId ?? gameStore.gameState?.away_team_id ?? 0)
|
|
const expandedSlotId = ref<number | null>(null)
|
|
const expandedMode = ref<ExpandedMode>(null)
|
|
|
|
// Computed - Game state
|
|
const isGameActive = computed(() => gameStore.gameStatus === 'active')
|
|
const homeTeamId = computed(() => gameStore.gameState?.home_team_id ?? 0)
|
|
const awayTeamId = computed(() => gameStore.gameState?.away_team_id ?? 0)
|
|
|
|
// Computed - Team viewing
|
|
const isViewingAwayTeam = computed(() => viewingTeamId.value === awayTeamId.value)
|
|
const isMyTeam = computed(() => viewingTeamId.value === props.myTeamId)
|
|
const isBatting = computed(() => viewingTeamId.value === gameStore.battingTeamId)
|
|
const isFielding = computed(() => viewingTeamId.value === gameStore.fieldingTeamId)
|
|
|
|
// Computed - Lineups
|
|
const currentTeamLineup = computed(() => {
|
|
return isViewingAwayTeam.value
|
|
? gameStore.awayLineup
|
|
: gameStore.homeLineup
|
|
})
|
|
|
|
const activeLineup = computed(() => {
|
|
return currentTeamLineup.value.filter(p => p.is_active)
|
|
})
|
|
|
|
const benchPlayers = computed(() => {
|
|
return isViewingAwayTeam.value
|
|
? gameStore.awayBench
|
|
: gameStore.homeBench
|
|
})
|
|
|
|
const battingOrderSlots = computed(() => {
|
|
return activeLineup.value
|
|
.filter(p => p.batting_order !== null && p.position !== 'P')
|
|
.sort((a, b) => (a.batting_order ?? 0) - (b.batting_order ?? 0))
|
|
})
|
|
|
|
// Computed - Runners on base
|
|
const runnersOnBase = computed(() => {
|
|
const runners: Array<Lineup & { base: '1B' | '2B' | '3B' }> = []
|
|
const state = gameStore.gameState
|
|
|
|
if (!state) return runners
|
|
|
|
// Get runners from game state and find their lineup data
|
|
if (state.on_first) {
|
|
const lineup = gameStore.findPlayerInLineup(state.on_first.lineup_id)
|
|
if (lineup) runners.push({ ...lineup, base: '1B' })
|
|
}
|
|
if (state.on_second) {
|
|
const lineup = gameStore.findPlayerInLineup(state.on_second.lineup_id)
|
|
if (lineup) runners.push({ ...lineup, base: '2B' })
|
|
}
|
|
if (state.on_third) {
|
|
const lineup = gameStore.findPlayerInLineup(state.on_third.lineup_id)
|
|
if (lineup) runners.push({ ...lineup, base: '3B' })
|
|
}
|
|
|
|
return runners
|
|
})
|
|
|
|
// Computed - Pitcher
|
|
const currentPitcherLineup = computed(() => {
|
|
// Get pitcher for the viewing team
|
|
return activeLineup.value.find(p => p.position === 'P')
|
|
})
|
|
|
|
const isStartingPitcher = computed(() => {
|
|
return currentPitcherLineup.value?.is_starter ?? true
|
|
})
|
|
|
|
// Note: For demo/testing, allow pitcher changes for the team being viewed
|
|
const canChangePitcher = computed(() => {
|
|
return isFielding.value
|
|
})
|
|
|
|
// Computed - Current batter
|
|
const currentBatterLineupId = computed(() => {
|
|
return gameStore.currentBatter?.lineup_id ?? null
|
|
})
|
|
|
|
// Computed - Position change slot
|
|
const positionChangeSlot = computed(() => {
|
|
if (expandedMode.value !== 'position_change' || !expandedSlotId.value) return null
|
|
return activeLineup.value.find(p => p.lineup_id === expandedSlotId.value) ?? null
|
|
})
|
|
|
|
// Get slot state for display
|
|
function getSlotState(slot: Lineup): 'normal' | 'at_bat' | 'on_base' | 'expanded' {
|
|
if (expandedSlotId.value === slot.lineup_id) return 'expanded'
|
|
if (isBatting.value && slot.lineup_id === currentBatterLineupId.value) return 'at_bat'
|
|
if (runnersOnBase.value.some(r => r.lineup_id === slot.lineup_id)) return 'on_base'
|
|
return 'normal'
|
|
}
|
|
|
|
// Get base position for a runner
|
|
function getBasePosition(runner: Lineup & { base?: '1B' | '2B' | '3B' }): '1B' | '2B' | '3B' | null {
|
|
return runner.base ?? null
|
|
}
|
|
|
|
// Can substitute at this slot?
|
|
// Note: For demo/testing, allow subs for whichever team is being viewed
|
|
// In production, could restrict to only your managed team
|
|
function canSubstitute(slot: Lineup): boolean {
|
|
if (!slot.is_active) return false
|
|
return true
|
|
}
|
|
|
|
// Can change position at this slot?
|
|
// Note: For demo/testing, allow position changes for the team being viewed
|
|
function canChangePosition(slot: Lineup): boolean {
|
|
if (!slot.is_active) return false
|
|
// Can change position when that team is fielding
|
|
return isFielding.value
|
|
}
|
|
|
|
// Get substitute button label
|
|
function getSubstituteLabel(slot: Lineup): string {
|
|
if (isBatting.value && slot.lineup_id === currentBatterLineupId.value) {
|
|
return 'Pinch Hit'
|
|
}
|
|
return 'Substitute'
|
|
}
|
|
|
|
// Get substitution type for a slot
|
|
function getSubstitutionType(slot: Lineup): 'pinch_hitter' | 'pinch_runner' | 'defensive_replacement' | 'relief_pitcher' {
|
|
if (slot.position === 'P') return 'relief_pitcher'
|
|
if (isBatting.value && slot.lineup_id === currentBatterLineupId.value) return 'pinch_hitter'
|
|
if (runnersOnBase.value.some(r => r.lineup_id === slot.lineup_id)) return 'pinch_runner'
|
|
return 'defensive_replacement'
|
|
}
|
|
|
|
// Open substitution panel for a slot
|
|
function openSubstitution(slot: Lineup) {
|
|
expandedSlotId.value = slot.lineup_id
|
|
expandedMode.value = 'substitution'
|
|
// Fetch bench players when opening substitution panel
|
|
getBench(viewingTeamId.value)
|
|
}
|
|
|
|
// Open pinch runner panel
|
|
function openPinchRunner(runner: Lineup) {
|
|
expandedSlotId.value = runner.lineup_id
|
|
expandedMode.value = 'pinch_runner'
|
|
// Fetch bench players when opening substitution panel
|
|
getBench(viewingTeamId.value)
|
|
}
|
|
|
|
// Open position change panel
|
|
function openPositionChange(slot: Lineup) {
|
|
expandedSlotId.value = slot.lineup_id
|
|
expandedMode.value = 'position_change'
|
|
}
|
|
|
|
// Open pitching change panel
|
|
function openPitchingChange() {
|
|
if (currentPitcherLineup.value) {
|
|
expandedSlotId.value = currentPitcherLineup.value.lineup_id
|
|
expandedMode.value = 'substitution'
|
|
// Fetch bench players when opening substitution panel
|
|
getBench(viewingTeamId.value)
|
|
}
|
|
}
|
|
|
|
// Close expanded panel
|
|
function closeExpanded() {
|
|
expandedSlotId.value = null
|
|
expandedMode.value = null
|
|
}
|
|
|
|
// Handle substitution submit
|
|
async function handleSubstitutionSubmit(payload: {
|
|
playerOutLineupId: number
|
|
playerInCardId?: number
|
|
newPosition: string
|
|
teamId: number
|
|
type: string
|
|
}) {
|
|
try {
|
|
// Map our type to backend substitution type
|
|
let backendType: 'pinch_hitter' | 'defensive_replacement' | 'pitching_change'
|
|
|
|
switch (payload.type) {
|
|
case 'pinch_hitter':
|
|
case 'pinch_runner':
|
|
backendType = 'pinch_hitter' // Backend treats PR as PH for now
|
|
break
|
|
case 'relief_pitcher':
|
|
backendType = 'pitching_change'
|
|
break
|
|
case 'position_change':
|
|
// TODO: Position change endpoint
|
|
console.log('Position change not yet implemented:', payload)
|
|
closeExpanded()
|
|
return
|
|
default:
|
|
backendType = 'defensive_replacement'
|
|
}
|
|
|
|
if (payload.playerInCardId) {
|
|
await submitSubstitution({
|
|
game_id: props.gameId,
|
|
type: backendType,
|
|
player_out_lineup_id: payload.playerOutLineupId,
|
|
player_in_card_id: payload.playerInCardId,
|
|
team_id: payload.teamId,
|
|
new_position: payload.newPosition,
|
|
})
|
|
}
|
|
|
|
closeExpanded()
|
|
} catch (error) {
|
|
console.error('Substitution failed:', error)
|
|
// TODO: Show error toast
|
|
}
|
|
}
|
|
|
|
// Handle lineup submit from LineupBuilder
|
|
function handleLineupSubmit(result: unknown) {
|
|
emit('lineupsSubmitted', result)
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.unified-lineup-tab {
|
|
@apply w-full;
|
|
}
|
|
</style>
|