Backend fixes: - state_manager: Properly recover current_pitcher and current_catcher from fielding team during game state recovery (fixes pitcher badge not showing) - handlers: Add headshot field to lineup data, use lineup_service for proper player data loading on cache miss - lineup_service: Minor adjustments for headshot support Frontend fixes: - player.ts: Update Lineup type to match WebSocket event format - lineup_id (was 'id'), card_id fields - player.headshot for UI circles - Optional fields for event variations - CurrentSituation.vue: Adapt to updated type structure - Substitution selectors: Use updated Lineup type fields This fixes the issue where pitcher badge wouldn't show after game recovery because current_pitcher was being set from batting team instead of fielding team. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
474 lines
11 KiB
Vue
474 lines
11 KiB
Vue
<template>
|
|
<div class="defensive-replacement-selector">
|
|
<div class="selector-container">
|
|
<!-- Header -->
|
|
<div class="selector-header">
|
|
<h3 class="selector-title">Defensive Replacement</h3>
|
|
<p class="selector-description">
|
|
Select a player from the bench to replace {{ playerOut?.player.name || 'current player' }} in the field
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Current Player Info -->
|
|
<div v-if="playerOut" class="current-player">
|
|
<div class="player-label">Current Player:</div>
|
|
<div class="player-card">
|
|
<div class="player-info">
|
|
<div class="player-name">{{ playerOut.player.name }}</div>
|
|
<div class="player-details">
|
|
{{ playerOut.position }} • Batting {{ playerOut.batting_order || 'N/A' }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Position Selection -->
|
|
<div class="position-section">
|
|
<div class="position-label">New Position:</div>
|
|
<div class="position-grid">
|
|
<button
|
|
v-for="pos in fieldPositions"
|
|
:key="pos"
|
|
:class="[
|
|
'position-button',
|
|
selectedPosition === pos ? 'position-selected' : 'position-default'
|
|
]"
|
|
@click="selectPosition(pos)"
|
|
>
|
|
{{ pos }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bench Players -->
|
|
<div class="bench-section">
|
|
<div class="bench-label">
|
|
Available Players
|
|
<span v-if="selectedPosition" class="bench-filter">
|
|
(eligible for {{ selectedPosition }})
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="filteredBenchPlayers.length === 0" class="no-players">
|
|
<svg class="w-12 h-12 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
|
</svg>
|
|
<div class="no-players-text">
|
|
{{ selectedPosition ? `No players eligible for ${selectedPosition}` : 'Select a position first' }}
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="bench-grid">
|
|
<button
|
|
v-for="player in filteredBenchPlayers"
|
|
:key="player.lineup_id"
|
|
:class="[
|
|
'bench-player-card',
|
|
selectedPlayerId === player.player.id ? 'bench-player-selected' : 'bench-player-default'
|
|
]"
|
|
@click="selectPlayer(player)"
|
|
>
|
|
<div class="player-info">
|
|
<div class="player-name">{{ player.player.name }}</div>
|
|
<div class="player-meta">
|
|
<span class="player-positions">
|
|
{{ formatPositions(player.player) }}
|
|
</span>
|
|
<span v-if="player.player.wara !== null && player.player.wara !== undefined" class="player-stat">
|
|
WARA: {{ player.player.wara.toFixed(1) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div v-if="selectedPlayerId === player.player.id" class="selected-indicator">
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
|
</svg>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action Buttons -->
|
|
<div class="action-buttons">
|
|
<button
|
|
class="button button-cancel"
|
|
@click="handleCancel"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
:disabled="!canSubmit"
|
|
:class="[
|
|
'button',
|
|
canSubmit ? 'button-submit' : 'button-submit-disabled'
|
|
]"
|
|
@click="handleSubmit"
|
|
>
|
|
Substitute Player
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import type { Lineup } from '~/types'
|
|
|
|
interface Props {
|
|
playerOut: Lineup | null
|
|
benchPlayers: Lineup[]
|
|
teamId: number
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
const emit = defineEmits<{
|
|
submit: [{ playerOutLineupId: number; playerInCardId: number; newPosition: string; teamId: number }]
|
|
cancel: []
|
|
}>()
|
|
|
|
// Field positions
|
|
const fieldPositions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF']
|
|
|
|
// Local state
|
|
const selectedPosition = ref<string | null>(null)
|
|
const selectedPlayerId = ref<number | null>(null)
|
|
|
|
// Computed
|
|
const availableBenchPlayers = computed(() => {
|
|
return props.benchPlayers.filter(p => !p.is_active && !p.is_fatigued)
|
|
})
|
|
|
|
const filteredBenchPlayers = computed(() => {
|
|
if (!selectedPosition.value) return []
|
|
|
|
// Filter players who can play the selected position
|
|
return availableBenchPlayers.value.filter(player => {
|
|
const positions = getPlayerPositions(player.player)
|
|
return positions.includes(selectedPosition.value!)
|
|
})
|
|
})
|
|
|
|
const selectedPlayer = computed(() => {
|
|
if (!selectedPlayerId.value) return null
|
|
return filteredBenchPlayers.value.find(p => p.player.id === selectedPlayerId.value)
|
|
})
|
|
|
|
const canSubmit = computed(() => {
|
|
return (
|
|
props.playerOut !== null &&
|
|
selectedPosition.value !== null &&
|
|
selectedPlayer.value !== null
|
|
)
|
|
})
|
|
|
|
// Methods
|
|
const getPlayerPositions = (player: any): string[] => {
|
|
const positions: string[] = []
|
|
for (let i = 1; i <= 8; i++) {
|
|
const pos = player[`pos_${i}`]
|
|
if (pos) positions.push(pos)
|
|
}
|
|
return positions
|
|
}
|
|
|
|
const selectPosition = (position: string) => {
|
|
selectedPosition.value = position
|
|
// Reset player selection when position changes
|
|
selectedPlayerId.value = null
|
|
}
|
|
|
|
const selectPlayer = (player: Lineup) => {
|
|
selectedPlayerId.value = player.player.id
|
|
}
|
|
|
|
const formatPositions = (player: any): string => {
|
|
const positions = getPlayerPositions(player)
|
|
return positions.slice(0, 3).join(', ')
|
|
}
|
|
|
|
const handleSubmit = () => {
|
|
if (!canSubmit.value || !props.playerOut || !selectedPlayer.value || !selectedPosition.value) {
|
|
return
|
|
}
|
|
|
|
emit('submit', {
|
|
playerOutLineupId: props.playerOut.lineup_id,
|
|
playerInCardId: selectedPlayer.value.player.id,
|
|
newPosition: selectedPosition.value,
|
|
teamId: props.teamId,
|
|
})
|
|
|
|
// Reset selections
|
|
selectedPosition.value = null
|
|
selectedPlayerId.value = null
|
|
}
|
|
|
|
const handleCancel = () => {
|
|
selectedPosition.value = null
|
|
selectedPlayerId.value = null
|
|
emit('cancel')
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.defensive-replacement-selector {
|
|
@apply w-full;
|
|
}
|
|
|
|
.selector-container {
|
|
@apply bg-white rounded-xl shadow-lg p-6 space-y-6;
|
|
}
|
|
|
|
/* Header */
|
|
.selector-header {
|
|
@apply pb-4 border-b border-gray-200;
|
|
}
|
|
|
|
.selector-title {
|
|
@apply text-xl font-bold text-gray-900 mb-1;
|
|
}
|
|
|
|
.selector-description {
|
|
@apply text-sm text-gray-600;
|
|
}
|
|
|
|
/* Current Player */
|
|
.current-player {
|
|
@apply bg-red-50 border border-red-200 rounded-lg p-4;
|
|
}
|
|
|
|
.player-label {
|
|
@apply text-xs font-semibold text-red-700 uppercase tracking-wide mb-2;
|
|
}
|
|
|
|
.player-card {
|
|
@apply bg-white rounded-lg p-3 shadow-sm;
|
|
}
|
|
|
|
.player-info {
|
|
@apply space-y-1;
|
|
}
|
|
|
|
.player-name {
|
|
@apply font-bold text-gray-900;
|
|
}
|
|
|
|
.player-details,
|
|
.player-meta {
|
|
@apply text-sm text-gray-600;
|
|
}
|
|
|
|
.player-positions {
|
|
@apply text-blue-600 font-medium;
|
|
}
|
|
|
|
.player-stat {
|
|
@apply text-gray-500 ml-2;
|
|
}
|
|
|
|
/* Position Section */
|
|
.position-section {
|
|
@apply space-y-3;
|
|
}
|
|
|
|
.position-label {
|
|
@apply text-sm font-semibold text-gray-700 uppercase tracking-wide;
|
|
}
|
|
|
|
.position-grid {
|
|
@apply grid grid-cols-3 gap-2;
|
|
}
|
|
|
|
@media (min-width: 640px) {
|
|
.position-grid {
|
|
@apply grid-cols-5;
|
|
}
|
|
}
|
|
|
|
.position-button {
|
|
@apply px-4 py-3 rounded-lg font-bold text-sm border-2 transition-all duration-150;
|
|
@apply min-h-[48px];
|
|
}
|
|
|
|
.position-default {
|
|
@apply bg-white border-gray-300 text-gray-700;
|
|
@apply hover:border-blue-400 hover:bg-blue-50;
|
|
}
|
|
|
|
.position-selected {
|
|
@apply bg-gradient-to-br from-blue-500 to-blue-600 border-blue-600 text-white;
|
|
@apply shadow-md;
|
|
}
|
|
|
|
/* Bench Section */
|
|
.bench-section {
|
|
@apply space-y-3;
|
|
}
|
|
|
|
.bench-label {
|
|
@apply text-sm font-semibold text-gray-700 uppercase tracking-wide;
|
|
}
|
|
|
|
.bench-filter {
|
|
@apply text-blue-600 font-normal normal-case;
|
|
}
|
|
|
|
.no-players {
|
|
@apply flex flex-col items-center justify-center py-12 text-gray-400;
|
|
}
|
|
|
|
.no-players-text {
|
|
@apply mt-3 text-sm font-medium;
|
|
}
|
|
|
|
.bench-grid {
|
|
@apply grid grid-cols-1 gap-3;
|
|
}
|
|
|
|
@media (min-width: 640px) {
|
|
.bench-grid {
|
|
@apply grid-cols-2;
|
|
}
|
|
}
|
|
|
|
/* Bench Player Cards */
|
|
.bench-player-card {
|
|
@apply relative p-4 rounded-lg border-2 transition-all duration-150;
|
|
@apply text-left min-h-[80px];
|
|
}
|
|
|
|
.bench-player-default {
|
|
@apply bg-white border-gray-300 hover:border-green-400 hover:bg-green-50;
|
|
}
|
|
|
|
.bench-player-selected {
|
|
@apply bg-gradient-to-br from-green-50 to-green-100 border-green-500 shadow-md;
|
|
}
|
|
|
|
.selected-indicator {
|
|
@apply absolute top-2 right-2 text-green-600;
|
|
}
|
|
|
|
/* Action Buttons */
|
|
.action-buttons {
|
|
@apply flex gap-3 pt-4 border-t border-gray-200;
|
|
}
|
|
|
|
.button {
|
|
@apply flex-1 px-6 py-3 rounded-lg font-bold text-base transition-all duration-200;
|
|
@apply shadow-md min-h-[52px];
|
|
}
|
|
|
|
.button-cancel {
|
|
@apply bg-white border-2 border-gray-300 text-gray-700;
|
|
@apply hover:border-gray-400 hover:bg-gray-50;
|
|
}
|
|
|
|
.button-submit {
|
|
@apply bg-gradient-to-r from-green-500 to-green-600 text-white;
|
|
@apply hover:from-green-600 hover:to-green-700 hover:shadow-lg;
|
|
@apply active:scale-95;
|
|
}
|
|
|
|
.button-submit-disabled {
|
|
@apply bg-gray-300 text-gray-500 cursor-not-allowed;
|
|
}
|
|
|
|
/* Dark mode */
|
|
@media (prefers-color-scheme: dark) {
|
|
.selector-container {
|
|
@apply bg-gray-800 border border-gray-700;
|
|
}
|
|
|
|
.selector-header {
|
|
@apply border-gray-700;
|
|
}
|
|
|
|
.selector-title {
|
|
@apply text-gray-100;
|
|
}
|
|
|
|
.selector-description {
|
|
@apply text-gray-400;
|
|
}
|
|
|
|
.current-player {
|
|
@apply bg-red-900 bg-opacity-20 border-red-700;
|
|
}
|
|
|
|
.player-label {
|
|
@apply text-red-400;
|
|
}
|
|
|
|
.player-card {
|
|
@apply bg-gray-700;
|
|
}
|
|
|
|
.player-name {
|
|
@apply text-gray-100;
|
|
}
|
|
|
|
.player-details,
|
|
.player-meta {
|
|
@apply text-gray-400;
|
|
}
|
|
|
|
.position-label,
|
|
.bench-label {
|
|
@apply text-gray-300;
|
|
}
|
|
|
|
.position-default {
|
|
@apply bg-gray-700 border-gray-600 text-gray-200;
|
|
@apply hover:border-blue-400 hover:bg-gray-600;
|
|
}
|
|
|
|
.bench-player-default {
|
|
@apply bg-gray-700 border-gray-600 text-gray-200;
|
|
@apply hover:border-green-400 hover:bg-gray-600;
|
|
}
|
|
|
|
.button-cancel {
|
|
@apply bg-gray-700 border-gray-600 text-gray-200;
|
|
@apply hover:border-gray-500 hover:bg-gray-600;
|
|
}
|
|
|
|
.action-buttons {
|
|
@apply border-gray-700;
|
|
}
|
|
}
|
|
|
|
/* Mobile optimizations */
|
|
@media (max-width: 640px) {
|
|
.selector-container {
|
|
@apply p-4;
|
|
}
|
|
|
|
.position-grid {
|
|
@apply gap-1.5;
|
|
}
|
|
|
|
.position-button {
|
|
@apply px-2 py-2 text-xs min-h-[44px];
|
|
}
|
|
|
|
.bench-grid {
|
|
@apply gap-2;
|
|
}
|
|
|
|
.bench-player-card {
|
|
@apply p-3 min-h-[70px];
|
|
}
|
|
|
|
.action-buttons {
|
|
@apply flex-col gap-2;
|
|
}
|
|
|
|
.button {
|
|
@apply w-full;
|
|
}
|
|
}
|
|
</style>
|