strat-gameplay-webapp/frontend-sba/components/Lineup/LineupSlotRow.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

206 lines
5.3 KiB
Vue

<template>
<div
:class="[
'rounded-lg border mb-1.5 transition-all',
containerClass
]"
>
<!-- Slot Header Row -->
<div class="flex items-center gap-3 p-2.5">
<!-- Order Number / Position Badge -->
<div :class="['w-8 h-8 rounded-lg flex items-center justify-center', badgeClass]">
<span class="text-sm font-bold">{{ displayBadge }}</span>
</div>
<!-- Player Avatar -->
<div
:class="[
'w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold',
avatarClass
]"
>
<img
v-if="player.player.headshot"
:src="player.player.headshot"
:alt="player.player.name"
class="w-full h-full rounded-full object-cover"
>
<span v-else>{{ initials }}</span>
</div>
<!-- Player Info -->
<div class="flex-1 min-w-0">
<div class="font-medium text-sm truncate">{{ player.player.name }}</div>
<div :class="['text-xs', subtextClass]">
{{ player.position }}
<template v-if="statusText">
<span class="mx-1">{{ statusText }}</span>
</template>
</div>
</div>
<!-- Action Buttons -->
<template v-if="showActions && !isExpanded">
<button
class="px-3 py-1.5 text-xs font-medium bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors select-none touch-manipulation"
@click="$emit('substitute')"
>
{{ substituteLabel }}
</button>
<button
v-if="showPositionChange"
class="px-2 py-1.5 text-xs font-medium bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors select-none touch-manipulation"
title="Change Position"
@click="$emit('changePosition')"
>
</button>
</template>
<!-- Cancel Button (when expanded) -->
<button
v-if="isExpanded"
class="px-3 py-1.5 text-xs font-medium bg-red-900/50 hover:bg-red-900/70 text-red-300 rounded-lg transition-colors select-none touch-manipulation"
@click="$emit('cancel')"
>
Cancel
</button>
</div>
<!-- Slot for expanded content (accordion) -->
<div
v-if="isExpanded"
class="border-t border-gray-700/50"
>
<slot name="expanded" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Lineup } from '~/types'
type SlotState = 'normal' | 'at_bat' | 'on_base' | 'expanded' | 'position_change'
interface Props {
player: Lineup
battingOrder?: number | null
state?: SlotState
isExpanded?: boolean
showActions?: boolean
showPositionChange?: boolean
substituteLabel?: string
isPitcher?: boolean
basePosition?: '1B' | '2B' | '3B' | null // For runners on base display
}
const props = withDefaults(defineProps<Props>(), {
battingOrder: null,
state: 'normal',
isExpanded: false,
showActions: true,
showPositionChange: true,
substituteLabel: 'Substitute',
isPitcher: false,
basePosition: null,
})
defineEmits<{
substitute: []
changePosition: []
cancel: []
}>()
// Compute initials for avatar fallback
const initials = computed(() => {
const parts = props.player.player.name.split(' ')
if (parts.length >= 2) {
return parts[0][0] + parts[parts.length - 1][0]
}
return props.player.player.name.substring(0, 2).toUpperCase()
})
// Display badge (order number or base position)
const displayBadge = computed(() => {
if (props.basePosition) {
return props.basePosition
}
if (props.isPitcher) {
return 'P'
}
return props.battingOrder ?? props.player.batting_order ?? '?'
})
// Container styling based on state
const containerClass = computed(() => {
if (props.isExpanded) {
if (props.state === 'position_change') {
return 'bg-gray-800/60 border-blue-500/50 ring-2 ring-blue-500/30'
}
return 'bg-gray-800/60 border-amber-500/50 ring-2 ring-amber-500/30'
}
switch (props.state) {
case 'at_bat':
return 'bg-gray-800/60 border-amber-500/50 ring-2 ring-amber-500/30'
case 'on_base':
return 'bg-gray-800/60 border-green-700/50'
default:
return 'bg-gray-800/60 border-gray-700/50'
}
})
// Badge styling
const badgeClass = computed(() => {
if (props.basePosition) {
return 'bg-green-800/50 text-green-400'
}
if (props.isPitcher) {
return 'bg-blue-900/50 text-blue-400'
}
switch (props.state) {
case 'at_bat':
return 'bg-amber-900/50 text-amber-400'
default:
return 'bg-gray-700/50 text-gray-400'
}
})
// Avatar styling
const avatarClass = computed(() => {
switch (props.state) {
case 'at_bat':
return 'bg-gradient-to-br from-amber-700 to-amber-800'
case 'on_base':
return 'bg-gradient-to-br from-green-700 to-green-800 ring-2 ring-green-500/50'
default:
return 'bg-gradient-to-br from-gray-600 to-gray-700'
}
})
// Subtext styling
const subtextClass = computed(() => {
switch (props.state) {
case 'at_bat':
return 'text-amber-400'
case 'on_base':
return 'text-green-400'
default:
return 'text-gray-400'
}
})
// Status text (AT BAT, On 1st, etc.)
const statusText = computed(() => {
if (props.state === 'at_bat') {
return 'AT BAT'
}
if (props.basePosition) {
return `On ${props.basePosition}`
}
return null
})
</script>